Skip to content

Commit 10f54ac

Browse files
Add incremental discovery invalidation.
Track a lightweight repos tree signature in workspace discovery caching so unchanged trees can reuse cached results while repo folder changes trigger automatic refresh, with updated tests and handover documentation. Made-with: Cursor
1 parent 773d0c0 commit 10f54ac

4 files changed

Lines changed: 82 additions & 8 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
- Confirmed updated local test-suite pass outside sandbox with `pnpm --dir "repos/workspace-hub" test` (`11 passed, 0 failed`).
1717
- Added a split summary path with lightweight diagnostics mode: new `GET /api/workspace/summary/base` route and optional `includeDiagnostics` toggle in workspace summary building.
1818
- Added base-summary test coverage in `repos/workspace-hub/test/workspace-cache-search.test.ts` and confirmed local test-suite pass outside sandbox (`12 passed, 0 failed`).
19+
- Added incremental discovery invalidation in `repos/workspace-hub/server/workspace.ts` by tracking a lightweight repo-tree signature so cached discovery can be reused when unchanged and refreshed automatically when repo folders change.
20+
- Updated workspace cache tests in `repos/workspace-hub/test/workspace-cache-search.test.ts` to verify both repo-tree-driven refresh and explicit cache invalidation behavior.
21+
- Confirmed updated local test-suite pass outside sandbox with `pnpm --dir "repos/workspace-hub" test` (`13 passed, 0 failed`).
1922

2023
## 2026-04-05
2124

docs/HANDOVER.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,28 @@ Verification after base-summary slice:
309309
- `pnpm --dir "repos/workspace-hub" lint`: passed
310310
- `pnpm --dir "repos/workspace-hub" typecheck`: passed
311311
- `pnpm --dir "repos/workspace-hub" test`: passed outside sandbox in local terminal (`12 passed, 0 failed`, duration ~`3114ms`)
312+
313+
### Implementation update (2026-04-07, incremental discovery invalidation slice)
314+
315+
Completed in `repos/workspace-hub`:
316+
317+
1. Added repo-tree signature based cache reuse.
318+
- Workspace discovery cache now tracks a lightweight `repos/` tree signature (root + immediate child directory stats).
319+
- Cached discovery can be reused past TTL when snapshot state and repo-tree signature are unchanged.
320+
321+
2. Added automatic refresh trigger on repo-tree changes.
322+
- Discovery now re-runs when repo tree signature changes (for example, new repo folder appears), even without explicit cache invalidation.
323+
324+
3. Preserved explicit invalidation behavior.
325+
- `invalidateWorkspaceSummaryCache()` remains supported for guaranteed refresh in action flows.
326+
327+
4. Updated cache behavior tests.
328+
- `repos/workspace-hub/test/workspace-cache-search.test.ts` now verifies:
329+
- summary refresh when repo tree changes
330+
- explicit invalidation still forces refresh
331+
332+
Verification after incremental invalidation slice:
333+
334+
- `pnpm --dir "repos/workspace-hub" lint`: passed
335+
- `pnpm --dir "repos/workspace-hub" typecheck`: passed
336+
- `pnpm --dir "repos/workspace-hub" test`: passed outside sandbox in local terminal (`13 passed, 0 failed`, duration ~`2518ms`)

repos/workspace-hub/server/workspace.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execFile } from 'node:child_process'
22
import { type Dirent } from 'node:fs'
3-
import { access, readFile, readdir } from 'node:fs/promises'
3+
import { access, readFile, readdir, stat } from 'node:fs/promises'
44
import path from 'node:path'
55
import { fileURLToPath } from 'node:url'
66
import { promisify } from 'node:util'
@@ -176,6 +176,7 @@ let cachedWorkspaceDiscovery:
176176
| {
177177
expiresAt: number
178178
key: string
179+
repoTreeSignature: string
179180
value: WorkspaceDiscoveryResult
180181
}
181182
| null = null
@@ -206,6 +207,33 @@ export function invalidateWorkspaceSummaryCache() {
206207
cachedWorkspaceDiscovery = null
207208
}
208209

210+
async function buildReposTreeSignature() {
211+
try {
212+
const rootStat = await stat(reposRoot)
213+
const rootEntries = await readVisibleEntries(reposRoot)
214+
const directoryEntries = rootEntries.filter((entry) => entry.isDirectory())
215+
const directorySignatures = await Promise.all(
216+
directoryEntries.map(async (entry) => {
217+
const fullPath = path.join(reposRoot, entry.name)
218+
try {
219+
const entryStat = await stat(fullPath)
220+
return `${entry.name}:${entryStat.mtimeMs}:${entryStat.size}`
221+
} catch {
222+
return `${entry.name}:missing`
223+
}
224+
}),
225+
)
226+
227+
directorySignatures.sort((left, right) => left.localeCompare(right))
228+
return [
229+
`root:${rootStat.mtimeMs}:${rootStat.size}`,
230+
`dirs:${directorySignatures.join('|')}`,
231+
].join('::')
232+
} catch {
233+
return 'repos-root-unavailable'
234+
}
235+
}
236+
209237
function isVisible(name: string) {
210238
return !name.startsWith('.')
211239
}
@@ -1483,11 +1511,16 @@ async function discoverWorkspace(
14831511
) {
14841512
const includeDiagnostics = options.includeDiagnostics ?? true
14851513
const cacheKey = `${buildSnapshotCacheKey(installSnapshots, runtimeSnapshots)}::diagnostics=${includeDiagnostics ? 'full' : 'base'}`
1514+
const repoTreeSignature = await buildReposTreeSignature()
14861515
if (
14871516
cachedWorkspaceDiscovery &&
14881517
cachedWorkspaceDiscovery.key === cacheKey &&
1489-
cachedWorkspaceDiscovery.expiresAt > Date.now()
1518+
cachedWorkspaceDiscovery.repoTreeSignature === repoTreeSignature
14901519
) {
1520+
if (cachedWorkspaceDiscovery.expiresAt <= Date.now()) {
1521+
cachedWorkspaceDiscovery.expiresAt =
1522+
Date.now() + Math.max(0, workspaceDiscoveryCacheTtlMs)
1523+
}
14911524
return cachedWorkspaceDiscovery.value
14921525
}
14931526

@@ -1591,6 +1624,7 @@ async function discoverWorkspace(
15911624
cachedWorkspaceDiscovery = {
15921625
expiresAt: Date.now() + Math.max(0, workspaceDiscoveryCacheTtlMs),
15931626
key: cacheKey,
1627+
repoTreeSignature,
15941628
value: nextValue,
15951629
}
15961630

repos/workspace-hub/test/workspace-cache-search.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ after(async () => {
6565
}
6666
})
6767

68-
test('workspace summary cache invalidation refreshes discovered repo set', async () => {
68+
test('workspace summary refreshes discovered repo set when repo tree changes', async () => {
6969
await createNodeRepo(tempWorkspaceRoot, 'repo-a')
7070
const workspaceModule = await importWorkspaceModule(tempWorkspaceRoot, '60000')
7171

@@ -74,16 +74,28 @@ test('workspace summary cache invalidation refreshes discovered repo set', async
7474

7575
await createNodeRepo(tempWorkspaceRoot, 'repo-b')
7676

77-
const cachedSummary = await workspaceModule.buildWorkspaceSummary(4101, new Map(), new Map())
77+
const cachedSummary = await workspaceModule.buildWorkspaceSummary(
78+
4101,
79+
new Map(),
80+
new Map(),
81+
)
7882
assert.equal(
7983
cachedSummary.repos.length,
80-
1,
81-
'Expected cached summary to remain stable before explicit invalidation.',
84+
2,
85+
'Expected summary to refresh when repo tree signature changes.',
8286
)
87+
})
8388

89+
test('workspace summary cache invalidation can force a refresh', async () => {
90+
const workspaceModule = await importWorkspaceModule(tempWorkspaceRoot, '60000')
8491
workspaceModule.invalidateWorkspaceSummaryCache()
85-
const refreshedSummary = await workspaceModule.buildWorkspaceSummary(4101, new Map(), new Map())
86-
assert.equal(refreshedSummary.repos.length, 2)
92+
93+
const refreshedSummary = await workspaceModule.buildWorkspaceSummary(
94+
4101,
95+
new Map(),
96+
new Map(),
97+
)
98+
assert.ok(refreshedSummary.repos.length >= 2)
8799
})
88100

89101
test('artifact search indexing is opt-in via env gate', async () => {

0 commit comments

Comments
 (0)