| author | |
| committer | |
| log | a1830915ecf70e3ad4fbe8efb15ae9f18517eb54 |
| tree | 2d8b57669d0ead856dad2442527a3f372227fec5 |
| parent | 4b063a042ec93418a196c020b852459f57e17b57 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 126 insertions(+), 34 deletions(-)
apps/mobile/src/features/threads/use-composer-command-menu.ts+22-12| ... | @@ -9,7 +9,10 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread | ... | @@ -9,7 +9,10 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread |
| 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 10 | 10 | ||
| 11 | const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = []; | 11 | const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = []; |
| 12 | import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; | 12 | import { |
| 13 | COMPOSER_CONTEXT_MAX_RECORDS, | ||
| 14 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 13 | import { Alert } from "react-native"; | 16 | import { Alert } from "react-native"; |
| 14 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | 17 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; |
| 15 | import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; | 18 | import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; |
| ... | @@ -37,6 +40,7 @@ import { | ... | @@ -37,6 +40,7 @@ import { |
| 37 | getProviderSlashCommandsForSlashMenu, | 40 | getProviderSlashCommandsForSlashMenu, |
| 38 | isProviderSkillUserInvocable, | 41 | isProviderSkillUserInvocable, |
| 39 | hasCompleteProviderWorkspaceSnapshot, | 42 | hasCompleteProviderWorkspaceSnapshot, |
| 43 | hasCurrentProviderWorkspaceSnapshot, | ||
| 40 | resolveProviderSkillsForCwd, | 44 | resolveProviderSkillsForCwd, |
| 41 | resolveProviderSlashCommandsForCwd, | 45 | resolveProviderSlashCommandsForCwd, |
| 42 | } from "@t3tools/client-runtime/providerSkills"; | 46 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | @@ -250,7 +254,9 @@ export function useComposerCommandMenu({ | ... | @@ -250,7 +254,9 @@ export function useComposerCommandMenu({ |
| 250 | selectedProviderStatus, | 254 | selectedProviderStatus, |
| 251 | projectCwd, | 255 | projectCwd, |
| 252 | ); | 256 | ); |
| 253 | const workspaceRefreshKeyRef = useRef<string | null>(null); | 257 | // The last scan this composer asked for. A request inside the TTL is not |
| 258 | // repeated, so a client clock ahead of the server's cannot loop rescans. | ||
| 259 | const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); | ||
| 254 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ | 260 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 255 | key: string; | 261 | key: string; |
| 256 | notBefore: number; | 262 | notBefore: number; |
| ... | @@ -289,17 +295,23 @@ export function useComposerCommandMenu({ | ... | @@ -289,17 +295,23 @@ export function useComposerCommandMenu({ |
| 289 | useEffect(() => { | 295 | useEffect(() => { |
| 290 | if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; | 296 | if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; |
| 291 | const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; | 297 | const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; |
| 292 | if (workspaceRefreshKeyRef.current === key) return; | 298 | const now = Date.now(); |
| 293 | if (hasWorkspaceSnapshot) { | 299 | const lastRequest = workspaceRefreshKeyRef.current; |
| 294 | workspaceRefreshKeyRef.current = key; | 300 | if ( |
| 301 | lastRequest?.key === key && | ||
| 302 | now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS | ||
| 303 | ) | ||
| 304 | return; | ||
| 305 | if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) { | ||
| 295 | setWorkspaceRefreshRetry(null); | 306 | setWorkspaceRefreshRetry(null); |
| 296 | return; | 307 | return; |
| 297 | } | 308 | } |
| 298 | const retry = workspaceRefreshRetry; | 309 | const retry = workspaceRefreshRetry; |
| 299 | if (retry?.key === key && Date.now() < retry.notBefore) return; | 310 | if (retry?.key === key && now < retry.notBefore) return; |
| 300 | workspaceRefreshKeyRef.current = key; | 311 | const request = { key, requestedAt: now }; |
| 312 | workspaceRefreshKeyRef.current = request; | ||
| 301 | const retryLater = () => { | 313 | const retryLater = () => { |
| 302 | if (workspaceRefreshKeyRef.current !== key) return; | 314 | if (workspaceRefreshKeyRef.current !== request) return; |
| 303 | workspaceRefreshKeyRef.current = null; | 315 | workspaceRefreshKeyRef.current = null; |
| 304 | setWorkspaceRefreshRetry({ | 316 | setWorkspaceRefreshRetry({ |
| 305 | key, | 317 | key, |
| ... | @@ -318,17 +330,15 @@ export function useComposerCommandMenu({ | ... | @@ -318,17 +330,15 @@ export function useComposerCommandMenu({ |
| 318 | ), | 330 | ), |
| 319 | projectCwd, | 331 | projectCwd, |
| 320 | ); | 332 | ); |
| 321 | if (!refreshed && workspaceRefreshKeyRef.current === key) { | 333 | if (!refreshed) retryLater(); |
| 322 | retryLater(); | ||
| 323 | } | ||
| 324 | }, retryLater); | 334 | }, retryLater); |
| 325 | }, [ | 335 | }, [ |
| 326 | draftMessage, | 336 | draftMessage, |
| 327 | environmentId, | 337 | environmentId, |
| 328 | hasWorkspaceSnapshot, | ||
| 329 | projectCwd, | 338 | projectCwd, |
| 330 | refreshProviders, | 339 | refreshProviders, |
| 331 | selectedProviderInstanceId, | 340 | selectedProviderInstanceId, |
| 341 | selectedProviderStatus, | ||
| 332 | workspaceRefreshRetry, | 342 | workspaceRefreshRetry, |
| 333 | ]); | 343 | ]); |
| 334 | 344 |
apps/server/src/provider/ProviderRegistry.test.ts+9-1| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | ClaudeSettings, | 23 | ClaudeSettings, |
| 24 | CodexSettings, | 24 | CodexSettings, |
| 25 | DEFAULT_SERVER_SETTINGS, | 25 | DEFAULT_SERVER_SETTINGS, |
| 26 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | ||
| 26 | ProviderDriverKind, | 27 | ProviderDriverKind, |
| 27 | ProviderInstanceId, | 28 | ProviderInstanceId, |
| 28 | ServerSettings, | 29 | ServerSettings, |
| ... | @@ -1768,6 +1769,13 @@ it.layer( | ... | @@ -1768,6 +1769,13 @@ it.layer( |
| 1768 | ); | 1769 | ); |
| 1769 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | 1770 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); |
| 1770 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | 1771 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); |
| 1772 | // Nothing watches skill directories, so an expired scan is redone on use. | ||
| 1773 | yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); | ||
| 1774 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1775 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1776 | yield* TestClock.adjust(1); | ||
| 1777 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1778 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | ||
| 1771 | const newSkills = [ | 1779 | const newSkills = [ |
| 1772 | ...scopedProvider.skills, | 1780 | ...scopedProvider.skills, |
| 1773 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, | 1781 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, |
| ... | @@ -1778,7 +1786,7 @@ it.layer( | ... | @@ -1778,7 +1786,7 @@ it.layer( |
| 1778 | cwd: "/workspace", | 1786 | cwd: "/workspace", |
| 1779 | fresh: true, | 1787 | fresh: true, |
| 1780 | }); | 1788 | }); |
| 1781 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | 1789 | assert.strictEqual(yield* Ref.get(snapshotCalls), 5); |
| 1782 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); | 1790 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); |
| 1783 | assert.deepStrictEqual( | 1791 | assert.deepStrictEqual( |
| 1784 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | 1792 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), |
apps/server/src/provider/ProviderRegistry.ts+14-2| ... | @@ -29,6 +29,7 @@ | ... | @@ -29,6 +29,7 @@ |
| 29 | */ | 29 | */ |
| 30 | import { | 30 | import { |
| 31 | defaultInstanceIdForDriver, | 31 | defaultInstanceIdForDriver, |
| 32 | isProviderWorkspaceSnapshotCurrent, | ||
| 32 | ProviderDriverKind, | 33 | ProviderDriverKind, |
| 33 | type ProviderInstanceId, | 34 | type ProviderInstanceId, |
| 34 | type ServerProvider, | 35 | type ServerProvider, |
| ... | @@ -36,6 +37,7 @@ import { | ... | @@ -36,6 +37,7 @@ import { |
| 36 | } from "@t3tools/contracts"; | 37 | } from "@t3tools/contracts"; |
| 37 | import * as Cause from "effect/Cause"; | 38 | import * as Cause from "effect/Cause"; |
| 38 | import * as Context from "effect/Context"; | 39 | import * as Context from "effect/Context"; |
| 40 | import * as DateTime from "effect/DateTime"; | ||
| 39 | import * as Deferred from "effect/Deferred"; | 41 | import * as Deferred from "effect/Deferred"; |
| 40 | import * as Effect from "effect/Effect"; | 42 | import * as Effect from "effect/Effect"; |
| 41 | import * as Equal from "effect/Equal"; | 43 | import * as Equal from "effect/Equal"; |
| ... | @@ -1048,13 +1050,20 @@ export const layer = Layer.effect( | ... | @@ -1048,13 +1050,20 @@ export const layer = Layer.effect( |
| 1048 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => | 1050 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => |
| 1049 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); | 1051 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); |
| 1050 | const scannedFrom = workspaceSnapshotOf(provider); | 1052 | const scannedFrom = workspaceSnapshotOf(provider); |
| 1053 | const now = yield* DateTime.now; | ||
| 1051 | if ( | 1054 | if ( |
| 1052 | !provider || | 1055 | !provider || |
| 1053 | !provider.enabled || | 1056 | !provider.enabled || |
| 1054 | (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) | 1057 | (!input.fresh && |
| 1058 | scannedFrom && | ||
| 1059 | !scannedFrom.slashCommandsPending && | ||
| 1060 | isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) | ||
| 1055 | ) { | 1061 | ) { |
| 1056 | return providers; | 1062 | return providers; |
| 1057 | } | 1063 | } |
| 1064 | // Drivers spread their machine snapshot, whose `checkedAt` is the last | ||
| 1065 | // health check. The TTL needs the time this scan started reading files. | ||
| 1066 | const scannedAt = DateTime.formatIso(now); | ||
| 1058 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | 1067 | const instance = yield* instanceRegistry.getInstance(input.instanceId); |
| 1059 | if (!instance?.snapshotForCwd) return providers; | 1068 | if (!instance?.snapshotForCwd) return providers; |
| 1060 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { | 1069 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { |
| ... | @@ -1086,7 +1095,10 @@ export const layer = Layer.effect( | ... | @@ -1086,7 +1095,10 @@ export const layer = Layer.effect( |
| 1086 | currentProviders.map((candidate) => | 1095 | currentProviders.map((candidate) => |
| 1087 | candidate.instanceId === input.instanceId && | 1096 | candidate.instanceId === input.instanceId && |
| 1088 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) | 1097 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) |
| 1089 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | 1098 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { |
| 1099 | ...scopedSnapshot, | ||
| 1100 | checkedAt: scannedAt, | ||
| 1101 | }) | ||
| 1090 | : candidate, | 1102 | : candidate, |
| 1091 | ), | 1103 | ), |
| 1092 | ); | 1104 | ); |
apps/web/src/components/chat/ChatComposer.tsx+18-14| ... | @@ -44,6 +44,7 @@ import { | ... | @@ -44,6 +44,7 @@ import { |
| 44 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 44 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 45 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 45 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 46 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 46 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 47 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | ||
| 47 | } from "@t3tools/contracts"; | 48 | } from "@t3tools/contracts"; |
| 48 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | 49 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 49 | import { | 50 | import { |
| ... | @@ -1123,6 +1124,7 @@ import { | ... | @@ -1123,6 +1124,7 @@ import { |
| 1123 | getProviderSlashCommandsForSlashMenu, | 1124 | getProviderSlashCommandsForSlashMenu, |
| 1124 | getProviderSkillsForSlashMenu, | 1125 | getProviderSkillsForSlashMenu, |
| 1125 | hasCompleteProviderWorkspaceSnapshot, | 1126 | hasCompleteProviderWorkspaceSnapshot, |
| 1127 | hasCurrentProviderWorkspaceSnapshot, | ||
| 1126 | resolveProviderSkillsForCwd, | 1128 | resolveProviderSkillsForCwd, |
| 1127 | resolveProviderSlashCommandsForCwd, | 1129 | resolveProviderSlashCommandsForCwd, |
| 1128 | } from "@t3tools/client-runtime/providerSkills"; | 1130 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | @@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2194 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | 2196 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 2195 | reportFailure: false, | 2197 | reportFailure: false, |
| 2196 | }); | 2198 | }); |
| 2197 | const workspaceRefreshKeyRef = useRef<string | null>(null); | 2199 | // The last scan this composer asked for. A request inside the TTL is not |
| 2200 | // repeated, so a client clock ahead of the server's cannot loop rescans. | ||
| 2201 | const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); | ||
| 2198 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ | 2202 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 2199 | key: string; | 2203 | key: string; |
| 2200 | notBefore: number; | 2204 | notBefore: number; |
| ... | @@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2237 | useEffect(() => { | 2241 | useEffect(() => { |
| 2238 | if (!gitCwd || !selectedProviderEntry) return; | 2242 | if (!gitCwd || !selectedProviderEntry) return; |
| 2239 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; | 2243 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; |
| 2240 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( | 2244 | const now = Date.now(); |
| 2241 | selectedProviderStatus, | 2245 | const lastRequest = workspaceRefreshKeyRef.current; |
| 2242 | gitCwd, | 2246 | if ( |
| 2243 | ); | 2247 | lastRequest?.key === key && |
| 2244 | if (workspaceRefreshKeyRef.current === key) return; | 2248 | now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS |
| 2245 | if (hasWorkspaceSnapshot) { | 2249 | ) |
| 2246 | workspaceRefreshKeyRef.current = key; | 2250 | return; |
| 2251 | if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) { | ||
| 2247 | setWorkspaceRefreshRetry(null); | 2252 | setWorkspaceRefreshRetry(null); |
| 2248 | return; | 2253 | return; |
| 2249 | } | 2254 | } |
| 2250 | const retry = workspaceRefreshRetry; | 2255 | const retry = workspaceRefreshRetry; |
| 2251 | if (retry?.key === key && Date.now() < retry.notBefore) return; | 2256 | if (retry?.key === key && now < retry.notBefore) return; |
| 2252 | workspaceRefreshKeyRef.current = key; | 2257 | const request = { key, requestedAt: now }; |
| 2258 | workspaceRefreshKeyRef.current = request; | ||
| 2253 | const retryLater = () => { | 2259 | const retryLater = () => { |
| 2254 | if (workspaceRefreshKeyRef.current !== key) return; | 2260 | if (workspaceRefreshKeyRef.current !== request) return; |
| 2255 | workspaceRefreshKeyRef.current = null; | 2261 | workspaceRefreshKeyRef.current = null; |
| 2256 | setWorkspaceRefreshRetry({ | 2262 | setWorkspaceRefreshRetry({ |
| 2257 | key, | 2263 | key, |
| ... | @@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2270 | ), | 2276 | ), |
| 2271 | gitCwd, | 2277 | gitCwd, |
| 2272 | ); | 2278 | ); |
| 2273 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | 2279 | if (!hasWorkspaceSnapshot) retryLater(); |
| 2274 | retryLater(); | ||
| 2275 | } | ||
| 2276 | }, retryLater); | 2280 | }, retryLater); |
| 2277 | }, [ | 2281 | }, [ |
| 2278 | environmentId, | 2282 | environmentId, |
packages/client-runtime/src/providerSkills.test.ts+30-1| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { |
| 2 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 8 | ||
| 4 | import { | 9 | import { |
| ... | @@ -7,6 +12,7 @@ import { | ... | @@ -7,6 +12,7 @@ import { |
| 7 | getProviderSlashCommandsForSlashMenu, | 12 | getProviderSlashCommandsForSlashMenu, |
| 8 | getProviderSkillsForSlashMenu, | 13 | getProviderSkillsForSlashMenu, |
| 9 | hasCompleteProviderWorkspaceSnapshot, | 14 | hasCompleteProviderWorkspaceSnapshot, |
| 15 | hasCurrentProviderWorkspaceSnapshot, | ||
| 10 | resolveProviderSkillsForCwd, | 16 | resolveProviderSkillsForCwd, |
| 11 | resolveProviderSlashCommandsForCwd, | 17 | resolveProviderSlashCommandsForCwd, |
| 12 | resolveProviderSkillSourceKind, | 18 | resolveProviderSkillSourceKind, |
| ... | @@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => { | ... | @@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => { |
| 274 | expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); | 280 | expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); |
| 275 | expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); | 281 | expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); |
| 276 | }); | 282 | }); |
| 283 | |||
| 284 | it("asks for a rescan once the workspace snapshot outlives its TTL", () => { | ||
| 285 | const scannedAt = Date.parse("2026-01-01T00:01:00.000Z"); | ||
| 286 | const cwd = "/workspace/project-a"; | ||
| 287 | expect(hasCurrentProviderWorkspaceSnapshot(provider, cwd, scannedAt)).toBe(true); | ||
| 288 | expect( | ||
| 289 | hasCurrentProviderWorkspaceSnapshot( | ||
| 290 | provider, | ||
| 291 | cwd, | ||
| 292 | scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1, | ||
| 293 | ), | ||
| 294 | ).toBe(true); | ||
| 295 | expect( | ||
| 296 | hasCurrentProviderWorkspaceSnapshot( | ||
| 297 | provider, | ||
| 298 | cwd, | ||
| 299 | scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | ||
| 300 | ), | ||
| 301 | ).toBe(false); | ||
| 302 | expect(hasCurrentProviderWorkspaceSnapshot(provider, "/workspace/project-b", scannedAt)).toBe( | ||
| 303 | false, | ||
| 304 | ); | ||
| 305 | }); | ||
| 277 | }); | 306 | }); |
packages/client-runtime/src/providerSkills.ts+19-4| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import type { | 1 | import { |
| 2 | ServerProvider, | 2 | isProviderWorkspaceSnapshotCurrent, |
| 3 | ServerProviderSkill, | 3 | type ServerProvider, |
| 4 | ServerProviderSlashCommand, | 4 | type ServerProviderSkill, |
| 5 | type ServerProviderSlashCommand, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | ||
| 7 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; | 8 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| ... | @@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot( | ... | @@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot( |
| 119 | return Boolean(snapshot && !snapshot.slashCommandsPending); | 120 | return Boolean(snapshot && !snapshot.slashCommandsPending); |
| 120 | } | 121 | } |
| 121 | 122 | ||
| 123 | /** A complete snapshot young enough that opening a composer need not rescan. */ | ||
| 124 | export function hasCurrentProviderWorkspaceSnapshot( | ||
| 125 | provider: ServerProvider | null | undefined, | ||
| 126 | cwd: string | null | undefined, | ||
| 127 | nowMs: number, | ||
| 128 | ): boolean { | ||
| 129 | const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); | ||
| 130 | return Boolean( | ||
| 131 | snapshot && | ||
| 132 | !snapshot.slashCommandsPending && | ||
| 133 | isProviderWorkspaceSnapshotCurrent(snapshot, nowMs), | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 122 | export function resolveProviderSkillsForCwd( | 137 | export function resolveProviderSkillsForCwd( |
| 123 | provider: ServerProvider, | 138 | provider: ServerProvider, |
| 124 | cwd: string | null | undefined, | 139 | cwd: string | null | undefined, |
packages/contracts/src/server.ts+14| ... | @@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ | ... | @@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ |
| 131 | }); | 131 | }); |
| 132 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; | 132 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; |
| 133 | 133 | ||
| 134 | /** | ||
| 135 | * How long a workspace's skill and command scan stays current. Nothing watches | ||
| 136 | * skill directories, so a composer opened after this rescans on use, and the | ||
| 137 | * server answers repeat requests inside the window from its cache. | ||
| 138 | */ | ||
| 139 | export const PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS = 5 * 60_000; | ||
| 140 | |||
| 141 | export function isProviderWorkspaceSnapshotCurrent( | ||
| 142 | snapshot: Pick<ServerProviderWorkspaceSnapshot, "checkedAt">, | ||
| 143 | nowMs: number, | ||
| 144 | ): boolean { | ||
| 145 | return nowMs - Date.parse(snapshot.checkedAt) < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS; | ||
| 146 | } | ||
| 147 | |||
| 134 | /** | 148 | /** |
| 135 | * Availability of a configured provider instance from the runtime's POV. | 149 | * Availability of a configured provider instance from the runtime's POV. |
| 136 | * | 150 | * |