diff --git a/.changeset/fix-windows-workspace-duplicates.md b/.changeset/fix-windows-workspace-duplicates.md new file mode 100644 index 0000000000..31e141738c --- /dev/null +++ b/.changeset/fix-windows-workspace-duplicates.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix duplicate workspace groups on Windows when the same folder is opened with different path spellings, such as a different drive-letter casing; all of the folder's sessions now list under the single merged group. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 8c0c13db57..90df990aa7 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -33,6 +33,7 @@ import { STORAGE_KEYS, } from '../../lib/storage'; import { parseDiff } from '../../lib/parseDiff'; +import { workspaceRootKey } from '../../lib/rootKey'; import { sessionExportTraceToJsonl, traceKeyEvent } from '../../debug/trace'; import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute'; import type { SessionUrlMode } from '../../lib/sessionRoute'; @@ -963,9 +964,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // clobber the name with the default basename. const override = loadWorkspaceNameOverrides()[workspace.root]; const ws = override !== undefined ? { ...workspace, name: override } : workspace; - // Re-adding a path the user previously removed should bring it back. - if (rawState.hiddenWorkspaceRoots.includes(ws.root)) { - rawState.hiddenWorkspaceRoots = rawState.hiddenWorkspaceRoots.filter((r) => r !== ws.root); + // Re-adding a path the user previously removed should bring it back. The + // hidden match in mergeWorkspaces is folded, so the removal must fold too + // — otherwise hiding `C:\Foo` and re-adding `c:\foo` leaves the folded + // entry hiding the workspace forever. + const wsKey = workspaceRootKey(ws.root); + if (rawState.hiddenWorkspaceRoots.some((r) => workspaceRootKey(r) === wsKey)) { + rawState.hiddenWorkspaceRoots = rawState.hiddenWorkspaceRoots.filter( + (r) => workspaceRootKey(r) !== wsKey, + ); saveHiddenWorkspacesToStorage(rawState.hiddenWorkspaceRoots); } const index = rawState.workspaces.findIndex( diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index dde665342d..7ffc1b0d93 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -14,6 +14,7 @@ import { type WorkspaceSortMode, } from '../lib/workspaceOrder'; import { mergeWorkspaces } from '../lib/mergeWorkspaces'; +import { workspaceRootKey } from '../lib/rootKey'; import { mergeSnapshotMessages } from '../lib/snapshotMessages'; import { mergeSnapshotSubagents } from '../lib/taskMerge'; import { createCoalescedAsyncRunner } from '../lib/snapshotSync'; @@ -2263,12 +2264,19 @@ const changesByPath = computed>(() => { // --------------------------------------------------------------------------- /** - * The workspace id a session belongs to: prefer the daemon-provided - * session.workspaceId; otherwise map by cwd (in derived/fallback mode the - * workspace id IS the cwd). + * The workspace id a session belongs to: the first registered workspace whose + * root identity-matches the session cwd (folds Windows case/slash variants — + * keeps grouping consistent with `mergeWorkspaces` so a session never falls + * out of the group the merge rendered); otherwise the daemon-provided + * session.workspaceId; otherwise the cwd itself (derived/fallback mode). */ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string { - return rawState.workspaces.find((w) => w.root === s.cwd)?.id ?? s.workspaceId ?? s.cwd; + const cwdKey = workspaceRootKey(s.cwd); + return ( + rawState.workspaces.find((w) => workspaceRootKey(w.root) === cwdKey)?.id ?? + s.workspaceId ?? + s.cwd + ); } /** diff --git a/apps/kimi-web/src/lib/mergeWorkspaces.test.ts b/apps/kimi-web/src/lib/mergeWorkspaces.test.ts new file mode 100644 index 0000000000..c01b3e6f36 --- /dev/null +++ b/apps/kimi-web/src/lib/mergeWorkspaces.test.ts @@ -0,0 +1,164 @@ +// apps/kimi-web/src/lib/mergeWorkspaces.test.ts +import { describe, expect, it } from 'vitest'; +import type { AppWorkspace } from '../api/types'; +import { mergeWorkspaces, type MergeWorkspacesInput } from './mergeWorkspaces'; +import { workspaceRootKey } from './rootKey'; + +function ws(root: string, extra: Partial = {}): AppWorkspace { + return { id: `wd_${root}`, root, name: root, sessionCount: 0, ...extra }; +} + +function input(overrides: Partial): MergeWorkspacesInput { + return { + workspaces: [], + sessions: [], + hiddenWorkspaceRoots: [], + sessionsHasMoreByWorkspace: {}, + ...overrides, + }; +} + +describe('mergeWorkspaces — folded root identity', () => { + it('assigns a session to the registered workspace when only the drive-letter case differs', () => { + const w = ws('C:\\Users\\Foo\\Proj', { id: 'wd_proj' }); + const result = mergeWorkspaces( + input({ workspaces: [w], sessions: [{ id: 's1', cwd: 'c:\\Users\\Foo\\Proj' }] }), + ); + expect(result).toHaveLength(1); // no derived duplicate group + expect(result[0]!.id).toBe('wd_proj'); + expect(result[0]!.root).toBe('C:\\Users\\Foo\\Proj'); // registered spelling kept + expect(result[0]!.sessionCount).toBe(1); // counted under the registered id + }); + + it('collapses legacy registered duplicates whose roots only differ by case', () => { + const first = ws('C:\\Users\\Foo\\Proj', { id: 'wd_first' }); + const second = ws('c:\\Users\\Foo\\Proj', { id: 'wd_second' }); + const result = mergeWorkspaces( + input({ + workspaces: [first, second], + sessions: [ + { id: 's1', cwd: 'C:\\Users\\Foo\\Proj' }, + { id: 's2', cwd: 'c:\\Users\\Foo\\Proj' }, + ], + }), + ); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('wd_first'); // daemon order: first entry wins + expect(result[0]!.root).toBe('C:\\Users\\Foo\\Proj'); + expect(result[0]!.sessionCount).toBe(2); // both cwd variants assigned to it + }); + + it('merges slash variants of the same Windows path', () => { + const w = ws('C:/Users/Foo/Proj', { id: 'wd_proj' }); + const result = mergeWorkspaces( + input({ workspaces: [w], sessions: [{ id: 's1', cwd: 'C:\\Users\\Foo\\Proj' }] }), + ); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('wd_proj'); + expect(result[0]!.root).toBe('C:/Users/Foo/Proj'); + expect(result[0]!.sessionCount).toBe(1); + }); + + it('hides Windows-shaped roots case-insensitively', () => { + const w = ws('C:\\Users\\Foo\\Proj', { id: 'wd_proj' }); + const result = mergeWorkspaces( + input({ + workspaces: [w], + sessions: [{ id: 's1', cwd: 'c:\\Users\\Foo\\Proj' }], + hiddenWorkspaceRoots: ['c:\\users\\foo\\proj'], + }), + ); + expect(result).toHaveLength(0); // one casing removed, all spellings hidden + }); + + it('hides POSIX roots only by exact directory (trailing slash tolerated)', () => { + const hidden = mergeWorkspaces( + input({ workspaces: [ws('/home/Foo')], hiddenWorkspaceRoots: ['/home/Foo/'] }), + ); + expect(hidden).toHaveLength(0); + const kept = mergeWorkspaces( + input({ workspaces: [ws('/home/Foo')], hiddenWorkspaceRoots: ['/home/foo'] }), + ); + expect(kept).toHaveLength(1); + }); + + it('keeps POSIX paths case-sensitive', () => { + const result = mergeWorkspaces( + input({ workspaces: [ws('/home/Foo', { id: 'wd_a' }), ws('/home/foo', { id: 'wd_b' })] }), + ); + expect(result.map((w) => w.root)).toEqual(['/home/Foo', '/home/foo']); + }); + + it('keeps case-variant derived POSIX cwds as separate groups', () => { + const result = mergeWorkspaces( + input({ + sessions: [ + { id: 's1', cwd: '/home/Foo' }, + { id: 's2', cwd: '/home/foo' }, + ], + }), + ); + expect(result).toHaveLength(2); + }); + + it('merges derived groups whose session cwds differ only by case', () => { + const result = mergeWorkspaces( + input({ + sessions: [ + { id: 's1', cwd: 'C:\\Users\\Foo\\Proj' }, + { id: 's2', cwd: 'c:\\Users\\Foo\\Proj' }, + ], + }), + ); + expect(result).toHaveLength(1); + expect(result[0]!.root).toBe('C:\\Users\\Foo\\Proj'); // first-seen cwd kept + }); + + it('orders real workspaces in daemon order and derived ones by display root', () => { + const result = mergeWorkspaces( + input({ + workspaces: [ws('/home/x/bbb'), ws('/home/x/aaa')], + sessions: [ + { id: 's1', cwd: '/home/x/ccc' }, + { id: 's2', cwd: '/home/x/000' }, + ], + }), + ); + expect(result.map((w) => w.root)).toEqual([ + '/home/x/bbb', + '/home/x/aaa', + '/home/x/000', + '/home/x/ccc', + ]); + }); +}); + +describe('workspaceRootKey', () => { + it('folds drive-letter casing', () => { + expect(workspaceRootKey('C:\\Users\\Foo')).toBe(workspaceRootKey('c:\\Users\\Foo')); + expect(workspaceRootKey('C:\\Users\\Foo')).toBe('c:/users/foo'); + }); + + it('folds drive roots before separator stripping can mask the shape', () => { + // `C:\` would strip to `C:` and stop reading as Windows-shaped. + expect(workspaceRootKey('C:\\')).toBe('c:'); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:\\')); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:/')); + }); + + it('folds UNC paths', () => { + expect(workspaceRootKey('\\\\HOST\\Share\\Dir')).toBe('//host/share/dir'); + expect(workspaceRootKey('\\\\HOST\\Share\\Dir')).toBe(workspaceRootKey('//host/share/dir')); + }); + + it('normalizes slashes and strips trailing separators', () => { + expect(workspaceRootKey('C:\\Users\\Foo\\')).toBe('c:/users/foo'); + expect(workspaceRootKey('C:/Users/Foo/')).toBe('c:/users/foo'); + expect(workspaceRootKey('/home/Foo/')).toBe('/home/Foo'); + }); + + it('never folds POSIX paths', () => { + expect(workspaceRootKey('/home/Foo')).toBe('/home/Foo'); + expect(workspaceRootKey('/home/Foo')).not.toBe(workspaceRootKey('/home/foo')); + }); +}); diff --git a/apps/kimi-web/src/lib/mergeWorkspaces.ts b/apps/kimi-web/src/lib/mergeWorkspaces.ts index c9a0e2504d..89ae43572f 100644 --- a/apps/kimi-web/src/lib/mergeWorkspaces.ts +++ b/apps/kimi-web/src/lib/mergeWorkspaces.ts @@ -6,15 +6,18 @@ import type { AppSession, AppWorkspace } from '../api/types'; import { basename } from './pathBasename'; +import { workspaceRootKey } from './rootKey'; /** The workspace id a session belongs to: prefer the registered workspace whose * root matches the session cwd; otherwise the daemon-provided workspaceId; - * otherwise the cwd itself (derived/fallback mode). */ + * otherwise the cwd itself (derived/fallback mode). Roots compare by folded + * identity key, never by exact string (Windows casing/slash variants). */ function workspaceIdForSession( workspaces: AppWorkspace[], s: { workspaceId?: string; cwd: string }, ): string { - return workspaces.find((w) => w.root === s.cwd)?.id ?? s.workspaceId ?? s.cwd; + const cwdKey = workspaceRootKey(s.cwd); + return workspaces.find((w) => workspaceRootKey(w.root) === cwdKey)?.id ?? s.workspaceId ?? s.cwd; } export interface MergeWorkspacesInput { @@ -42,25 +45,30 @@ export function mergeWorkspaces(input: MergeWorkspacesInput): AppWorkspace[] { sessionsHasMoreByWorkspace, } = input; - const hidden = new Set(hiddenWorkspaceRoots); - const byRoot = new Map(); - // Real workspaces win on root (unless the user removed them from the sidebar). - // Keep the FIRST entry per root: the daemon orders by last_opened_at desc, so - // the most recently opened (typically the canonical re-add) comes first. This - // must match `workspaceIdForSession` / the sidebar's first-match session - // assignment — if byRoot kept a different id than sessions are counted and - // grouped under, the only rendered workspace would look empty. + // "Same root?" is always decided by the folded key; the first-seen original + // string is kept for display. + const hidden = new Set(hiddenWorkspaceRoots.map(workspaceRootKey)); + const byRoot = new Map(); // root key -> workspace + // Real workspaces win on root key (unless the user removed them from the + // sidebar). Keep the FIRST entry per key: the daemon orders by + // last_opened_at desc, so the most recently opened (typically the canonical + // re-add) comes first. This must match `workspaceIdForSession` / the + // sidebar's first-match session assignment — if byRoot kept a different id + // than sessions are counted and grouped under, the only rendered workspace + // would look empty. The entry keeps its ORIGINAL `root` string for display. for (const w of workspaces) { - if (hidden.has(w.root)) continue; - if (!byRoot.has(w.root)) byRoot.set(w.root, { ...w }); + const key = workspaceRootKey(w.root); + if (hidden.has(key)) continue; + if (!byRoot.has(key)) byRoot.set(key, { ...w }); } // Derive from sessions for any cwd without a real workspace. for (const s of sessions) { const root = s.cwd; if (!root) continue; - if (hidden.has(root)) continue; // removed from the sidebar — keep it hidden - if (!byRoot.has(root)) { - byRoot.set(root, { + const key = workspaceRootKey(root); + if (hidden.has(key)) continue; // removed from the sidebar — keep it hidden + if (!byRoot.has(key)) { + byRoot.set(key, { // Use the session's REAL daemon workspace_id (wd__) so // createSession({ workspaceId }) is accepted; fall back to cwd only // when the daemon hasn't tagged the session yet. @@ -79,22 +87,28 @@ export function mergeWorkspaces(input: MergeWorkspacesInput): AppWorkspace[] { } // Order: real workspaces in listWorkspaces order, then derived workspaces - // sorted by root path so the order is stable (not tied to session activity). - // Hidden roots must be excluded here too — `byRoot` skips them, so a hidden - // real workspace would otherwise make `byRoot.get(root)` return undefined. + // sorted by display root so the order is stable (not tied to session + // activity). Both lists hold root KEYS. Hidden roots must be excluded here + // too — `byRoot` skips them, so a hidden real workspace would otherwise + // make `byRoot.get(key)` return undefined. // - // Dedup by root: the registry can legitimately hold two entries for the same - // folder (e.g. a legacy id from an older encodeWorkDirKey plus the current - // one). `byRoot` already collapses them, but a duplicated root in the - // ordering list would render the same workspace twice — and because both - // copies share an id, selecting one would highlight both. - const realRoots = [...new Set(workspaces.filter((w) => !hidden.has(w.root)).map((w) => w.root))]; - const derivedRoots = [...byRoot.keys()].filter((r) => !realRoots.includes(r)); - derivedRoots.sort((a, b) => a.localeCompare(b)); + // Dedup by root key: the registry can legitimately hold two entries for the + // same folder (e.g. a legacy id from an older encodeWorkDirKey plus the + // current one, or casing variants of the same Windows path). `byRoot` + // already collapses them, but a duplicated key in the ordering list would + // render the same workspace twice — and because both copies share an id, + // selecting one would highlight both. + const realKeys: string[] = []; + for (const w of workspaces) { + const key = workspaceRootKey(w.root); + if (!hidden.has(key) && !realKeys.includes(key)) realKeys.push(key); + } + const derivedKeys = [...byRoot.keys()].filter((k) => !realKeys.includes(k)); + derivedKeys.sort((a, b) => byRoot.get(a)!.root.localeCompare(byRoot.get(b)!.root)); const result: AppWorkspace[] = []; - for (const root of [...realRoots, ...derivedRoots]) { - const w = byRoot.get(root)!; + for (const key of [...realKeys, ...derivedKeys]) { + const w = byRoot.get(key)!; // When a workspace's sessions are fully loaded (hasMore === false), the // local count is exact — prefer it so archiving the last session drops the // count to 0 immediately. While pages remain, the local count is only a diff --git a/apps/kimi-web/src/lib/rootKey.ts b/apps/kimi-web/src/lib/rootKey.ts new file mode 100644 index 0000000000..f1fdecac2a --- /dev/null +++ b/apps/kimi-web/src/lib/rootKey.ts @@ -0,0 +1,26 @@ +// apps/kimi-web/src/lib/rootKey.ts + +// Windows-shaped: drive-letter (C:\, C:/) or UNC (\\host\share, //host/share). +// Shape-based detection (not any platform check): the daemon may run on +// Windows while the browser runs anywhere, and tests must fold the same way +// on every host. +const WIN_SHAPED = /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/; + +/** + * Identity key for "same workspace directory?" comparisons: slash-normalize, + * strip trailing separators, case-fold Windows-shaped paths (NTFS lookups are + * case-insensitive by default). Display strings are never rewritten — this is + * for comparison only. Mirrors the server-side copies (agent-core + * session/store/workdir-key.ts, agent-core-v2 _base/utils/workdir-slug.ts); + * keep the three in sync. Per-directory case sensitivity / WSL paths are a + * documented non-goal; POSIX paths never fold. + */ +export function workspaceRootKey(root: string): string { + const slashed = root.replaceAll('\\', '/'); + // Test the shape BEFORE stripping trailing separators: a drive root + // (`C:\`) loses its only separator to the strip (`C:`) and would no + // longer read as Windows-shaped, escaping the case-fold. + const shaped = WIN_SHAPED.test(slashed); + const normalized = slashed.replace(/\/+$/, ''); + return shaped ? normalized.toLowerCase() : normalized; +} diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 4322a88950..7c9f8c8966 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1736,3 +1736,37 @@ describe('useWorkspaceState — loadAllSessions usage preservation', () => { expect(next[0].usage.contextTokens).toBe(0); }); }); + +describe('useWorkspaceState — upsertWorkspacePreserveOrder hidden roots', () => { + beforeEach(() => { + installStorage(createMemoryStorage()); + }); + + afterEach(() => { + installStorage(createMemoryStorage()); + }); + + it('clears a folded hidden entry when the same directory is re-added with a different spelling', () => { + // mergeWorkspaces hides by folded key, so hiding `C:\Foo` then re-adding + // `c:\foo` must un-hide too — otherwise the add succeeds but the group + // never reappears. + const state = createState(); + state.hiddenWorkspaceRoots = ['C:\\Users\\Foo\\Proj']; + const ws = useWorkspaceState(state, createDeps()); + + ws.upsertWorkspacePreserveOrder(workspace('wd_x', 'c:\\users\\foo\\proj', 'proj')); + + expect(state.hiddenWorkspaceRoots).toEqual([]); + expect(state.workspaces[0]?.root).toBe('c:\\users\\foo\\proj'); + }); + + it('keeps hidden entries for case-distinct POSIX roots', () => { + const state = createState(); + state.hiddenWorkspaceRoots = ['/home/Foo']; + const ws = useWorkspaceState(state, createDeps()); + + ws.upsertWorkspacePreserveOrder(workspace('wd_y', '/home/foo', 'foo')); + + expect(state.hiddenWorkspaceRoots).toEqual(['/home/Foo']); + }); +}); diff --git a/packages/agent-core-v2/src/_base/utils/workdir-slug.ts b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts index f3a3fdb264..23ed091936 100644 --- a/packages/agent-core-v2/src/_base/utils/workdir-slug.ts +++ b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts @@ -5,7 +5,9 @@ * `encodeWorkDirKey` derives the stable, opaque `workspaceId` for a working * directory (`wd__`). The `workspaceId` is the backend-neutral * identity used to group sessions and to key the workspace registry; backends - * never expose the raw working-directory path. + * never expose the raw working-directory path. `workspaceRootKey` is the + * comparison-only companion: it answers "is this the same directory?" without + * changing the id that was already minted for it. */ import { createHash } from 'node:crypto'; @@ -31,3 +33,32 @@ export function encodeWorkDirKey(workDir: string): string { const hash = createHash('sha256').update(normalized).digest('hex').slice(0, HASH_LENGTH); return `${WORKDIR_KEY_PREFIX}${slug}_${hash}`; } + +// Windows-shaped: drive-letter (C:\, C:/) or UNC (\\host\share, //host/share). +// Shape-based detection (not process.platform): browser/remote daemons and +// tests must fold the same way regardless of host OS. +const WIN_SHAPED = /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/; + +/** + * Platform-aware identity key for "is this the same workspace directory?" + * comparisons. Slash-normalizes, strips trailing separators, and case-folds + * Windows-shaped paths (NTFS lookups are case-insensitive by default), so the + * drive-letter casing the process happened to inherit and typed-vs-realpath + * spelling variants collapse onto one key; POSIX paths never fold. + * + * Comparison-only: the minted `workspaceId` (`encodeWorkDirKey`) stays + * case-sensitive so already-persisted session buckets keep resolving. Pure + * string ops on purpose — a path library (`pathe.resolve`/`normalize`) would + * treat a Windows-shaped string as relative on a POSIX host and join the + * process cwd. Per-directory case sensitivity (`fsutil`) and WSL mount + * translations are a documented non-goal. + */ +export function workspaceRootKey(root: string): string { + const slashed = root.replaceAll('\\', '/'); + // Test the shape BEFORE stripping trailing separators: a drive root + // (`C:\`) loses its only separator to the strip (`C:`) and would no + // longer read as Windows-shaped, escaping the case-fold. + const shaped = WIN_SHAPED.test(slashed); + const normalized = slashed.replace(/\/+$/, ''); + return shaped ? normalized.toLowerCase() : normalized; +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index f135781274..e898b6c273 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -32,7 +32,14 @@ export interface SessionSummary { } export interface SessionListQuery { - readonly workspaceId?: string; + /** + * Restrict to sessions persisted under any of these workspace ids. A single + * workspace is `[id]`; callers resolving a legacy split bucket (one + * directory, several id spellings — see `IWorkspaceRegistry.resolveAliasIds`) + * pass the whole alias set and get one merged listing. Absent lists every + * bucket. + */ + readonly workspaceIds?: readonly string[]; readonly sessionId?: string; readonly includeArchived?: boolean; readonly cursor?: string; @@ -43,9 +50,11 @@ export interface SessionListQuery { export interface ISessionIndex { readonly _serviceBrand: undefined; + /** List persisted sessions, optionally filtered by a set of workspace ids. */ list(query: SessionListQuery): Promise>; get(id: string): Promise; - countActive(workspaceId: string): Promise; + /** Count non-archived sessions across the given set of workspace ids. */ + countActive(workspaceIds: readonly string[]): Promise; } export const ISessionIndex: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 481745449e..c17047dba3 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -7,6 +7,14 @@ * session ids are enumerated via `IFileSystemStorageService.list`, and each session's * metadata document is read via `IAtomicDocumentStore` to build its summary. * + * One physical folder may be split across sibling buckets by legacy id + * spellings (Windows casing/slash variants minted different `workspaceId`s for + * the same directory; see `IWorkspaceRegistry.resolveAliasIds`). A list or + * `countActive` query takes the workspace-id *set*, enumerates each bucket, + * and merges before the single recency sort and `limit` step — the merged + * listing is observably identical to a single-bucket list (same sort key, + * same cursor shape); filtering options keep their meaning. + * * The session metadata document lives at `/state.json`, a layout * shared by v1 and v2; the `version` field distinguishes them (`2` = v2, * epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also @@ -116,11 +124,11 @@ export class FileSessionIndex implements ISessionIndex { ); } - async countActive(workspaceId: string): Promise { - if (!this.readModelEnabled()) return this.countActiveLegacy(workspaceId); + async countActive(workspaceIds: readonly string[]): Promise { + if (!this.readModelEnabled()) return this.countActiveLegacy(workspaceIds); return this.withReadModelFallback( - () => this.countActiveFromReadModel(workspaceId), - () => this.countActiveLegacy(workspaceId), + () => this.countActiveFromReadModel(workspaceIds), + () => this.countActiveLegacy(workspaceIds), ); } @@ -149,8 +157,7 @@ export class FileSessionIndex implements ISessionIndex { return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; } - const workspaceIds = - query.workspaceId !== undefined ? [query.workspaceId] : await this.listWorkspaceIds(); + const workspaceIds = query.workspaceIds ?? (await this.listWorkspaceIds()); const items: SessionSummary[] = []; for (const workspaceId of workspaceIds) { for (const sessionId of await this.listSessionIds(workspaceId)) { @@ -175,11 +182,13 @@ export class FileSessionIndex implements ISessionIndex { return undefined; } - private async countActiveFromReadModel(workspaceId: string): Promise { + private async countActiveFromReadModel(workspaceIds: readonly string[]): Promise { let count = 0; - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.getCachedSummary(workspaceId, sessionId); - if (summary !== undefined && !summary.archived) count += 1; + for (const workspaceId of workspaceIds) { + for (const sessionId of await this.listSessionIds(workspaceId)) { + const summary = await this.getCachedSummary(workspaceId, sessionId); + if (summary !== undefined && !summary.archived) count += 1; + } } return count; } @@ -227,8 +236,7 @@ export class FileSessionIndex implements ISessionIndex { return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; } - const workspaceIds = - query.workspaceId !== undefined ? [query.workspaceId] : await this.listWorkspaceIds(); + const workspaceIds = query.workspaceIds ?? (await this.listWorkspaceIds()); const items: SessionSummary[] = []; for (const workspaceId of workspaceIds) { for (const sessionId of await this.listSessionIds(workspaceId)) { @@ -252,11 +260,13 @@ export class FileSessionIndex implements ISessionIndex { return undefined; } - private async countActiveLegacy(workspaceId: string): Promise { + private async countActiveLegacy(workspaceIds: readonly string[]): Promise { let count = 0; - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.readSummary(workspaceId, sessionId); - if (summary !== undefined && !summary.archived) count += 1; + for (const workspaceId of workspaceIds) { + for (const sessionId of await this.listSessionIds(workspaceId)) { + const summary = await this.readSummary(workspaceId, sessionId); + if (summary !== undefined && !summary.archived) count += 1; + } } return count; } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 5eb7d3c57e..1922a81955 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -16,7 +16,10 @@ * sessions are discovered through the `sessionIndex` read model, and workspace * roots are remembered through `workspaceRegistry`. On create / fork the * session is also appended to the shared `session_index.jsonl` so v1 clients - * (TUI, export) can discover sessions created by the v2 engine. Fork flushes + * (TUI, export) can discover sessions created by the v2 engine; the entry is + * indexed under the registry-resolved workspace id — the same id seeding the + * session's storage scope — so an alias spelling of the workDir cannot split + * the session into a bucket v1 readers never look in. Fork flushes * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent. */ @@ -37,7 +40,6 @@ import { } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { Emitter, type Event } from '#/_base/event'; -import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection'; import { IAgentPlanService } from '#/agent/plan/plan'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -134,7 +136,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); const handle = await this.materializeSession({ ...opts, sessionId }); - await this.appendSessionIndexEntry(sessionId, opts.workDir); + // Index the session under the workspace id the registry actually resolved + // (the same one seeding the session's storage scope), not a recomputed + // `encodeWorkDirKey` — with root folding the two can diverge. + await this.appendSessionIndexEntry( + sessionId, + opts.workDir, + handle.accessor.get(ISessionContext).workspaceId, + ); if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { const main = await ensureMainAgent(handle); await main.accessor.get(IAgentPlanService).enter(); @@ -188,8 +197,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } - private async appendSessionIndexEntry(sessionId: string, workDir: string): Promise { - const workspaceId = encodeWorkDirKey(workDir); + /** + * Append one entry to the v1-compatible `session_index.jsonl`. `workspaceId` + * must be the SAME id the session was materialized with (registry-resolved, + * possibly folded from an alias spelling) — recomputing + * `encodeWorkDirKey(workDir)` here could mint a different bucket and orphan + * the session for v1 readers. + */ + private async appendSessionIndexEntry( + sessionId: string, + workDir: string, + workspaceId: string, + ): Promise { const sessionDir = this.bootstrap.sessionDir(workspaceId, sessionId); this.appendLogStore.append('', 'session_index.jsonl', { sessionId, @@ -394,7 +413,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - await this.appendSessionIndexEntry(targetId, workspace.root); + await this.appendSessionIndexEntry(targetId, workspace.root, targetCtx.workspaceId); this._onDidForkSession.fire({ sourceSessionId: sourceId, sessionId: targetId, diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts index b124edbb6f..64255ca63e 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts @@ -18,7 +18,7 @@ export class WorkspaceQueryService implements IWorkspaceQueryService { constructor(@ISessionIndex private readonly index: ISessionIndex) {} async listRecentSessions(workspaceId: string): Promise { - const page = await this.index.list({ workspaceId, limit: RECENT_SESSIONS_LIMIT }); + const page = await this.index.list({ workspaceIds: [workspaceId], limit: RECENT_SESSIONS_LIMIT }); return page.items; } } diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts index 2a0960d9d4..b2c08e045d 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts @@ -26,6 +26,20 @@ export interface IWorkspaceRegistry { list(): Promise; get(id: string): Promise; + /** + * Every persisted id that addresses the same physical directory as `id`: + * registered entries whose `workspaceRootKey` identity matches, plus + * session-index-only spellings (`session_index.jsonl` workDirs never seen by + * the registry, i.e. legacy split buckets). Read-only — ids/buckets are never + * rewritten. An unknown `id` resolves to `[id]` so callers keep their + * existing not-found semantics. + */ + resolveAliasIds(id: string): Promise; + /** + * Register (or refresh `lastOpenedAt` for) a workspace rooted at `root`. + * Throws `fs.path_not_found` when `root` is missing or not a directory — + * callers opening a session must ensure the directory exists first. + */ createOrTouch(root: string, name?: string): Promise; update(id: string, patch: WorkspaceUpdate): Promise; delete(id: string): Promise; diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 75277c2920..27ba9458d7 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -37,13 +37,31 @@ * identity stays lexical — v1 deliberately never realpaths the root either. * The rebuild and merge paths bypass the check on purpose — they catalog * where sessions *were*, not where new ones may open. Bound at App scope. + * + * One physical folder can arrive under several spellings — most visibly on + * Windows, where drive-letter casing, slash direction, and typed-vs-realpath + * casing all differ for one directory. Every "same directory?" judgment + * (`createOrTouch` reuse, the session-index rebuild, and the `list` merge in + * `dedupeByRoot`) therefore goes through the `workspaceRootKey` identity key + * rather than the raw root string, while the minted `workspaceId` stays the + * case-sensitive `encodeWorkDirKey` so already-persisted session buckets, + * `workspaces.json` entries, and session metadata keep resolving with zero + * data migration. + * + * Legacy data may still be split: two registry entries (or a registry entry + * plus session-index-only spellings) for one physical folder, with sessions + * bucketed per id. `resolveAliasIds` is the read-only counterpart to the + * write-path folding: it enumerates every id spelling that identifies one + * directory (registered entries by `workspaceRootKey`, plus `workDir` + * spellings recorded only in `session_index.jsonl`) so readers can query all + * sibling buckets at once without rewriting any stored id. */ import { basename, isAbsolute } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -51,6 +69,9 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersistence'; +// Legacy v1 session index, read for the one-shot rebuild and for +// `resolveAliasIds` (session-index-only id spellings). Empty scope resolves to +// `/` (join skips empty segments). const SESSION_INDEX_SCOPE = ''; const SESSION_INDEX_KEY = 'session_index.jsonl'; @@ -92,6 +113,43 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { }); } + resolveAliasIds(id: string): Promise { + return this.runExclusive(async () => { + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const entry = catalog.workspaces.find((ws) => ws.id === id); + // Unknown ids stay singletons so callers keep their not-found semantics. + if (entry === undefined) return [id]; + return this.collectAliasIds(catalog, entry.root); + }); + } + + /** Every id identifying `root`'s directory: all registered spellings plus + * `workDir` spellings recorded only in `session_index.jsonl` (legacy split + * buckets that were never registered). Callers must already hold the op + * mutex — this is the unfolded core of `resolveAliasIds`, shared with + * `delete`. */ + private async collectAliasIds(catalog: WorkspaceCatalog, root: string): Promise { + const rootKey = workspaceRootKey(root); + const ids: string[] = []; + const seen = new Set(); + const add = (alias: string): void => { + if (seen.has(alias)) return; + seen.add(alias); + ids.push(alias); + }; + for (const ws of catalog.workspaces) { + if (workspaceRootKey(ws.root) === rootKey) add(ws.id); + } + // Legacy split buckets may exist under spellings never registered: fold + // in every session-index `workDir` that identifies the same directory. + // Best-effort — a missing/corrupt index simply contributes nothing. + for (const line of await this.readSessionIndexEntries()) { + if (workspaceRootKey(line.workDir) === rootKey) add(encodeWorkDirKey(line.workDir)); + } + return ids; + } + createOrTouch(root: string, name?: string): Promise { return this.runExclusive(async () => { let stat; @@ -119,7 +177,21 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws])); const deletedIds = new Set(catalog.deletedIds); const id = encodeWorkDirKey(root); - const existing = byId.get(id); + let existing = byId.get(id); + if (existing === undefined) { + // Fold identity-equivalent spellings (`workspaceRootKey`: Windows + // drive-letter/realpath casing, slash direction) onto the registered + // entry instead of minting a second id for the same folder. The first + // matching entry wins wholesale — its id, root, and name are kept; + // only `lastOpenedAt` advances. + const rootKey = workspaceRootKey(root); + for (const entry of byId.values()) { + if (workspaceRootKey(entry.root) === rootKey) { + existing = entry; + break; + } + } + } const now = Date.now(); const ws: Workspace = existing !== undefined @@ -131,9 +203,9 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { createdAt: now, lastOpenedAt: now, }; - byId.set(id, ws); + byId.set(ws.id, ws); // An explicit add clears any prior deletion tombstone. - deletedIds.delete(id); + deletedIds.delete(ws.id); await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); return ws; }); @@ -162,10 +234,30 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { await this.ensureMerged(); const catalog = await this.loadCatalog(); // Soft delete: tombstone the id so the session-index merge cannot - // resurrect it, even if sessions still reference the workDir. + // resurrect it, even if sessions still reference the workDir. Folded + // aliases must die with it — a sibling spelling left registered (or + // resurrectable from the session index) would resurface as this + // directory's representative on the next list(). + let root = catalog.workspaces.find((ws) => ws.id === id)?.root; + if (root === undefined) { + // Derived/unknown id: recover its spelling from the session index so + // the whole alias set can still be tombstoned. + root = (await this.readSessionIndexEntries()).find( + (line) => encodeWorkDirKey(line.workDir) === id, + )?.workDir; + } + if (root === undefined) { + await this.store.save({ + workspaces: catalog.workspaces.filter((ws) => ws.id !== id), + deletedIds: [...new Set([...catalog.deletedIds, id])], + }); + return; + } + const rootKey = workspaceRootKey(root); + const aliasIds = await this.collectAliasIds(catalog, root); await this.store.save({ - workspaces: catalog.workspaces.filter((ws) => ws.id !== id), - deletedIds: [...new Set([...catalog.deletedIds, id])], + workspaces: catalog.workspaces.filter((ws) => workspaceRootKey(ws.root) !== rootKey), + deletedIds: [...new Set([...catalog.deletedIds, ...aliasIds])], }); }); } @@ -223,13 +315,20 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { private async rebuildFromSessionIndex(): Promise> { const result = new Map(); const now = Date.now(); - for (const workDir of await this.readSessionIndexWorkDirs()) { - const id = encodeWorkDirKey(workDir); - if (result.has(id)) continue; + // Dedupe by identity key, not by minted id: casing/slash variants of one + // directory (Windows) collapse here too. First seen wins — the id stays + // `encodeWorkDirKey` of that first-seen workDir string. + const seenRootKeys = new Set(); + for (const entry of await this.readSessionIndexEntries()) { + if (!isAbsolute(entry.workDir)) continue; + const rootKey = workspaceRootKey(entry.workDir); + if (seenRootKeys.has(rootKey)) continue; + seenRootKeys.add(rootKey); + const id = encodeWorkDirKey(entry.workDir); result.set(id, { id, - root: workDir, - name: basename(workDir), + root: entry.workDir, + name: basename(entry.workDir), createdAt: now, lastOpenedAt: now, }); @@ -252,6 +351,25 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return workDirs; } + /** + * Parse the legacy v1 session index. Blank and malformed lines are skipped + * individually so one bad record never fails the whole file (matches the + * rebuild's tolerance). + */ + private async readSessionIndexEntries(): Promise { + const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + if (bytes === undefined) return []; + const entries: SessionIndexLine[] = []; + for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseSessionIndexLine(trimmed); + if (entry === undefined) continue; + entries.push(entry); + } + return entries; + } + private runExclusive(op: () => Promise): Promise { const next = this.opQueue.then(op, op); this.opQueue = next.then( @@ -284,17 +402,29 @@ function parseSessionIndexLine(line: string): SessionIndexLine | undefined { } } +/** + * Collapse registered workspaces that identify the same directory. The + * persisted catalog (v1-compatible `workspaces.json`) can hold legacy entries + * whose id was computed by an older `encodeWorkDirKey` (e.g. realpath-based on + * Windows) for the same folder, and Windows roots additionally differ by + * casing or slash spelling, so one directory may map to multiple ids. Entries + * merge on the `workspaceRootKey` identity key; prefer the entry whose id + * matches the canonical key computed on its own root string so current + * sessions' `workspace_id` still resolves and the same folder is not listed + * twice. + */ function dedupeByRoot(byId: ReadonlyMap): Workspace[] { const byRoot = new Map(); for (const ws of byId.values()) { - const existing = byRoot.get(ws.root); + const rootKey = workspaceRootKey(ws.root); + const existing = byRoot.get(rootKey); if (existing === undefined) { - byRoot.set(ws.root, ws); + byRoot.set(rootKey, ws); continue; } const canonicalId = encodeWorkDirKey(ws.root); if (existing.id !== canonicalId && ws.id === canonicalId) { - byRoot.set(ws.root, ws); + byRoot.set(rootKey, ws); } } return [...byRoot.values()]; diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 1b567758d6..0107e08ad4 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -882,6 +882,7 @@ function registerSessionExportServices( createdAt: 1, lastOpenedAt: 2, }), + resolveAliasIds: async (id) => [id], createOrTouch: async (root) => ({ id: 'ws_created', root, diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 48bfcc813a..706152d051 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -96,7 +96,7 @@ describe('FileSessionIndex (legacy)', () => { await seedEmpty('no-state'); const store = build(); - const page = await store.list({ workspaceId }); + const page = await store.list({ workspaceIds: [workspaceId] }); expect(page.items.map((s) => s.id).toSorted()).toEqual(['active']); expect(page.items[0]?.workspaceId).toBe(workspaceId); expect(page.items[0]?.archived).toBe(false); @@ -107,7 +107,7 @@ describe('FileSessionIndex (legacy)', () => { await seedSession('archived', { archived: true }); const store = build(); - const page = await store.list({ workspaceId, includeArchived: true }); + const page = await store.list({ workspaceIds: [workspaceId], includeArchived: true }); expect(page.items.map((s) => s.id).toSorted()).toEqual(['active', 'archived']); }); @@ -184,8 +184,56 @@ describe('FileSessionIndex (legacy)', () => { await seedEmpty('no-state'); const store = build(); - expect(await store.countActive(workspaceId)).toBe(2); - expect(await store.countActive('wd_unknown')).toBe(0); + expect(await store.countActive([workspaceId])).toBe(2); + expect(await store.countActive(['wd_unknown'])).toBe(0); + }); + + it('list merges a workspace-id set into one recency-ordered page', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('a1', { createdAt: 1, updatedAt: 1 }); + await seedSession('a3', { createdAt: 3, updatedAt: 3 }); + await seedSession('b2', { createdAt: 2, updatedAt: 2 }, otherId); + await seedSession('b4', { createdAt: 4, updatedAt: 4 }, otherId); + + const store = build(); + const page = await store.list({ workspaceIds: [workspaceId, otherId] }); + expect(page.items.map((s) => s.id)).toEqual(['b4', 'a3', 'b2', 'a1']); + expect(page.items[0]?.workspaceId).toBe(otherId); + }); + + it('list applies limit after the cross-bucket merge', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('a1', { createdAt: 1, updatedAt: 1 }); + await seedSession('a3', { createdAt: 3, updatedAt: 3 }); + await seedSession('b2', { createdAt: 2, updatedAt: 2 }, otherId); + + const store = build(); + const page = await store.list({ workspaceIds: [workspaceId, otherId], limit: 2 }); + expect(page.items.map((s) => s.id)).toEqual(['a3', 'b2']); + }); + + it('list filters archived across every bucket of the id set', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('active', {}); + await seedSession('archived', { archived: true }, otherId); + + const store = build(); + const visible = await store.list({ workspaceIds: [workspaceId, otherId] }); + expect(visible.items.map((s) => s.id)).toEqual(['active']); + + const all = await store.list({ workspaceIds: [workspaceId, otherId], includeArchived: true }); + expect(all.items.map((s) => s.id).toSorted()).toEqual(['active', 'archived']); + }); + + it('countActive sums over the workspace-id set', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('a', {}); + await seedSession('b', {}, otherId); + await seedSession('archived', { archived: true }, otherId); + + const store = build(); + expect(await store.countActive([workspaceId, otherId])).toBe(2); + expect(await store.countActive([otherId])).toBe(1); }); }); @@ -265,7 +313,7 @@ describe('FileSessionIndex (read model)', () => { await seedSession('archived', { archived: true }); const store = build(); - const first = await store.list({ workspaceId }); + const first = await store.list({ workspaceIds: [workspaceId] }); expect(first.items.map((s) => s.id)).toEqual(['active']); expect(first.items[0]?.title).toBe('hello'); @@ -274,7 +322,7 @@ describe('FileSessionIndex (read model)', () => { 'active', summary('active', { title: 'renamed', updatedAt: 3 }), ); - const second = await store.list({ workspaceId }); + const second = await store.list({ workspaceIds: [workspaceId] }); expect(second.items[0]?.title).toBe('renamed'); }); @@ -317,10 +365,34 @@ describe('FileSessionIndex (read model)', () => { await seedSession('b', { archived: true }); const store = build(); - expect(await store.countActive(workspaceId)).toBe(1); + expect(await store.countActive([workspaceId])).toBe(1); await queryStore.put(SESSION_COLLECTION, 'a', summary('a', { archived: true })); - expect(await store.countActive(workspaceId)).toBe(0); + expect(await store.countActive([workspaceId])).toBe(0); + }); + + it('list merges a workspace-id set into one recency-ordered page', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('a1', { createdAt: 1, updatedAt: 1 }); + await seedSession('a3', { createdAt: 3, updatedAt: 3 }); + await seedSession('b2', { createdAt: 2, updatedAt: 2 }, otherId); + await seedSession('b4', { createdAt: 4, updatedAt: 4 }, otherId); + + const store = build(); + const page = await store.list({ workspaceIds: [workspaceId, otherId] }); + expect(page.items.map((s) => s.id)).toEqual(['b4', 'a3', 'b2', 'a1']); + expect(page.items[0]?.workspaceId).toBe(otherId); + }); + + it('countActive sums over the workspace-id set', async () => { + const otherId = encodeWorkDirKey('/home/user/other'); + await seedSession('a', {}); + await seedSession('b', {}, otherId); + await seedSession('archived', { archived: true }, otherId); + + const store = build(); + expect(await store.countActive([workspaceId, otherId])).toBe(2); + expect(await store.countActive([otherId])).toBe(1); }); it('falls back to the legacy disk path when the query store is locked', async () => { @@ -343,11 +415,13 @@ describe('FileSessionIndex (read model)', () => { ]); disposeHost = () => { host.dispose(); }; const store = host.app.accessor.get(ISessionIndex); - const page = await store.list({ workspaceId }); + // The read model throws storage.locked; the index serves from disk. + const page = await store.list({ workspaceIds: [workspaceId] }); expect(page.items.map((s) => s.id)).toEqual(['active']); expect(page.items[0]?.title).toBe('from disk'); expect(await store.get('active')).toMatchObject({ id: 'active', title: 'from disk' }); - expect(await store.countActive(workspaceId)).toBe(1); + expect(await store.countActive([workspaceId])).toBe(1); + // The lock is warned about once, then the read model stays disabled. expect(warnings).toEqual(['query-store locked by another process; disabling read model']); } finally { await lockHolder.close(); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index a82aee93df..4ac8b047b4 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -155,6 +155,7 @@ function workspaceRegistryStub(): IWorkspaceRegistry { _serviceBrand: undefined, list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), + resolveAliasIds: (id) => Promise.resolve([id]), createOrTouch: (root, name) => Promise.resolve({ id: 'wd_stub', @@ -191,6 +192,7 @@ function persistentWorkspaceRegistryStub(): IWorkspaceRegistry { _serviceBrand: undefined, list: () => Promise.resolve([...workspaces.values()]), get: (id) => Promise.resolve(workspaces.get(id)), + resolveAliasIds: (id) => Promise.resolve([id]), createOrTouch: (root, name) => { const id = encodeWorkDirKey(root); const now = 1; @@ -494,9 +496,13 @@ describe('SessionLifecycleService', () => { }), ]); - await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - const workspaceId = encodeWorkDirKey('/tmp/proj'); + // The index entry addresses the session under the registry-resolved + // workspace id — the same id seeding the session's storage scope — not a + // recomputed encodeWorkDirKey, so the v1 reader finds it in the bucket it + // was materialized into. + const workspaceId = handle.accessor.get(ISessionContext).workspaceId; expect(appended).toEqual([ { scope: '', @@ -510,6 +516,46 @@ describe('SessionLifecycleService', () => { ]); }); + it('indexes the session under the registry-resolved id when the workDir is an alias spelling', async () => { + const appended: unknown[] = []; + const svc = build([ + stubPair(IAppendLogStore, { + ...appendLogStoreStub(), + append: (scope: string, key: string, record: unknown) => { + appended.push({ scope, key, record }); + }, + }), + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + // As the real registry does after folding: the id minted for the + // first-seen spelling is reused for the alias. + createOrTouch: (root: string, name?: string) => + Promise.resolve({ + id: 'wd_first_spelling', + root, + name: name ?? 'proj', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + + const handle = await svc.create({ sessionId: 's1', workDir: 'c:\\users\\foo\\proj' }); + + expect(handle.accessor.get(ISessionContext).workspaceId).toBe('wd_first_spelling'); + expect(appended).toEqual([ + { + scope: '', + key: 'session_index.jsonl', + record: { + sessionId: 's1', + sessionDir: '/tmp/sessions/wd_first_spelling/s1', + workDir: 'c:\\users\\foo\\proj', + }, + }, + ]); + }); + it('registers the workspace during create so a cold resume can resolve the workdir', async () => { const workDir = '/tmp/proj'; const workspaceRegistry = persistentWorkspaceRegistryStub(); @@ -570,6 +616,7 @@ describe('SessionLifecycleService', () => { } : undefined, ), + resolveAliasIds: (id) => Promise.resolve([id]), createOrTouch: (root, name) => Promise.resolve({ id: encodeWorkDirKey(root), diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts index c88d06cfdf..e69143afbe 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts @@ -28,7 +28,7 @@ class FakeSessionIndex implements ISessionIndex { return undefined; } - async countActive(_workspaceId: string): Promise { + async countActive(_workspaceIds: readonly string[]): Promise { return 0; } } @@ -69,7 +69,7 @@ describe('WorkspaceQueryService', () => { await query.listRecentSessions('wd_abc'); expect(index.lastListQuery).toEqual({ - workspaceId: 'wd_abc', + workspaceIds: ['wd_abc'], limit: RECENT_SESSIONS_LIMIT, }); expect(RECENT_SESSIONS_LIMIT).toBe(20); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 3b242dfe7a..8532e9b589 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -11,7 +11,7 @@ import { registerScopedService, } from '#/_base/di/scope'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2 } from '#/errors'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -59,12 +59,12 @@ describe('WorkspaceRegistryService (file-backed)', () => { await fsp.rm(homeDir, { recursive: true, force: true }); }); - function build(): IWorkspaceRegistry { + function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceRegistry { const fileStorage = new FileStorageService(homeDir); const host = createScopedTestHost([ stubPair(IFileSystemStorageService, fileStorage), stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), - stubPair(IHostFileSystem, new HostFileSystem()), + stubPair(IHostFileSystem, hostFs), ]); currentHost = host; return host.app.accessor.get(IWorkspaceRegistry); @@ -76,6 +76,17 @@ describe('WorkspaceRegistryService (file-backed)', () => { return build(); } + /** + * hostFs stub that stats every path as an existing directory, so tests can + * exercise Windows-shaped roots on Linux CI — real-fs stat of `C:\...` is + * ENOENT there, and real fs case behavior must never be relied on. + */ + function allDirsHostFs(): IHostFileSystem { + return { + stat: () => Promise.resolve({ isFile: false, isDirectory: true, size: 0 }), + } as unknown as IHostFileSystem; + } + async function seedSessionIndex(entries: SessionIndexLine[]): Promise { const text = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`; await fsp.writeFile(join(homeDir, 'session_index.jsonl'), text, 'utf8'); @@ -420,4 +431,235 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(matches).toHaveLength(1); expect(matches[0]?.id).toBe(canonicalId); }); + + it('folds Windows casing/slash variants onto the first-registered entry', async () => { + const registry = build(allDirsHostFs()); + + const first = await registry.createOrTouch('C:\\Users\\Foo\\Proj'); + const cased = await registry.createOrTouch('c:\\Users\\Foo\\Proj'); + const slashed = await registry.createOrTouch('C:/Users/Foo/Proj/'); + + expect(cased.id).toBe(first.id); + expect(slashed.id).toBe(first.id); + // Folding never rewrites the stored root/name — the first spelling stays; + // only lastOpenedAt advances. + expect(cased.root).toBe('C:\\Users\\Foo\\Proj'); + expect(cased.name).toBe(first.name); + expect(cased.lastOpenedAt).toBeGreaterThanOrEqual(first.lastOpenedAt); + expect(await registry.list()).toHaveLength(1); + + // ...and the fold persists: a fresh instance over the same homeDir still + // lists one entry under the first-seen spelling. + const reloaded = await restart().list(); + expect(reloaded).toHaveLength(1); + expect(reloaded[0]?.root).toBe('C:\\Users\\Foo\\Proj'); + }); + + it('merges legacy entries whose roots differ only by casing, preferring the canonical id', async () => { + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const legacyId = 'wd_proj_deadbeef0002'; + const canonicalId = encodeWorkDirKey(lowerRoot); + const entry = (root: string): PersistedWorkspaceEntry => ({ + root, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }); + await writeWorkspacesJson({ + // Legacy first so the canonical entry must actively replace it. + [legacyId]: entry(typedRoot), + [canonicalId]: entry(lowerRoot), + }); + + const list = await build().list(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe(canonicalId); + expect(list[0]?.root).toBe(lowerRoot); + }); + + it('rebuild folds session-index workDir variants into one workspace', async () => { + // UNC paths are Windows-shaped (so they case-fold) yet still `isAbsolute` + // on POSIX hosts, so this exercises case folding on Linux CI. + const firstSeen = '//Host/Share/Proj'; + await seedSessionIndex([ + { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: firstSeen }, + { sessionId: 's2', sessionDir: 'sessions/b/s2', workDir: '//host/share/Proj/' }, + { sessionId: 's3', sessionDir: 'sessions/c/s3', workDir: '//HOST/SHARE/PROJ' }, + ]); + + const list = await build().list(); + // First seen wins: the id is minted from the first-seen workDir string. + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe(encodeWorkDirKey(firstSeen)); + expect(list[0]?.root).toBe(firstSeen); + }); + + it('keeps POSIX roots case-sensitive', async () => { + const registry = build(allDirsHostFs()); + + const upper = await registry.createOrTouch('/tmp/Foo'); + const lower = await registry.createOrTouch('/tmp/foo'); + + expect(lower.id).not.toBe(upper.id); + expect((await registry.list()).map((w) => w.root).toSorted()).toEqual(['/tmp/Foo', '/tmp/foo']); + }); + + it('resolveAliasIds returns every registered id for one physical directory', async () => { + // A legacy catalog holds two entries whose roots differ only by casing — + // one physical folder, two bucket ids (this is what `dedupeByRoot` merges + // for listing; the alias set exposes both for multi-bucket reads). + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const legacyId = 'wd_proj_deadbeef0002'; + const canonicalId = encodeWorkDirKey(lowerRoot); + const entry = (root: string): PersistedWorkspaceEntry => ({ + root, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }); + await writeWorkspacesJson({ + [legacyId]: entry(typedRoot), + [canonicalId]: entry(lowerRoot), + }); + + const registry = build(); + for (const id of [legacyId, canonicalId]) { + expect((await registry.resolveAliasIds(id)).toSorted()).toEqual( + [legacyId, canonicalId].toSorted(), + ); + } + }); + + it('resolveAliasIds folds in session-index-only spellings of the same root', async () => { + // The sibling bucket's spelling was never registered: only the legacy + // session index remembers it. Malformed index lines are skipped, never + // thrown. + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const typedId = encodeWorkDirKey(typedRoot); + const indexOnlyId = encodeWorkDirKey('c:\\Users\\Foo\\Proj'); + await writeWorkspacesJson({ + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }); + await seedSessionIndex([ + { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: typedRoot }, + { sessionId: 's2', sessionDir: 'sessions/b/s2', workDir: 'c:\\Users\\Foo\\Proj' }, + { sessionId: 's3', sessionDir: 'sessions/c/s3', workDir: join(homeDir, 'unrelated') }, + ]); + await fsp.appendFile(join(homeDir, 'session_index.jsonl'), 'not-json\n{}\n', 'utf8'); + + const registry = build(); + expect((await registry.resolveAliasIds(typedId)).toSorted()).toEqual( + [typedId, indexOnlyId].toSorted(), + ); + }); + + it('resolveAliasIds keeps unknown ids and POSIX roots singleton', async () => { + const root = join(homeDir, 'posix'); + const id = encodeWorkDirKey(root); + await writeWorkspacesJson({ + [id]: { + root, + name: 'posix', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }); + + const registry = build(); + // Unknown id: callers keep their existing not-found semantics. + expect(await registry.resolveAliasIds('wd_missing_000000000000')).toEqual([ + 'wd_missing_000000000000', + ]); + // POSIX roots never fold, so the alias set is just the id itself. + expect(await registry.resolveAliasIds(id)).toEqual([id]); + }); + + it('delete tombstones every folded alias so a legacy split cannot resurface', async () => { + // Split legacy state: two registered spellings of one Windows root, plus a + // third spelling remembered only by the session index. + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const typedId = encodeWorkDirKey(typedRoot); + const aliasRoot = 'c:\\Users\\Foo\\Proj'; + const aliasId = encodeWorkDirKey(aliasRoot); + const indexOnlyRoot = 'C:/users/foo/proj'; + const indexOnlyId = encodeWorkDirKey(indexOnlyRoot); + await writeWorkspacesJson({ + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + [aliasId]: { + root: aliasRoot, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }); + await seedSessionIndex([ + { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: typedRoot }, + { sessionId: 's2', sessionDir: 'sessions/b/s2', workDir: indexOnlyRoot }, + { sessionId: 's3', sessionDir: 'sessions/c/s3', workDir: join(homeDir, 'unrelated') }, + ]); + + const registry = build(); + await registry.delete(typedId); + + // The directory itself is gone (unrelated entries survive); nothing + // identity-matching the deleted root remains, and every id that could + // carry it is tombstoned so the merge cannot resurrect it. + const stillListed = (await registry.list()).filter( + (w) => workspaceRootKey(w.root) === workspaceRootKey(typedRoot), + ); + expect(stillListed).toEqual([]); + const unrelatedId = encodeWorkDirKey(join(homeDir, 'unrelated')); + const saved = await readWorkspacesJson(); + expect(Object.keys(saved.workspaces)).toEqual([unrelatedId]); + expect([...(saved.deleted_workspace_ids as string[])].toSorted()).toEqual( + [typedId, aliasId, indexOnlyId].toSorted(), + ); + + // A fresh process (merge re-runs against the session index) does not + // bring the directory back either. + const reopened = restart(); + const relisted = (await reopened.list()).filter( + (w) => workspaceRootKey(w.root) === workspaceRootKey(typedRoot), + ); + expect(relisted).toEqual([]); + const afterMerge = await readWorkspacesJson(); + expect(Object.keys(afterMerge.workspaces)).toEqual([unrelatedId]); + }); +}); + +describe('workspaceRootKey', () => { + it('folds drive-letter casing and slash direction', () => { + expect(workspaceRootKey('C:\\Users\\Foo\\Proj')).toBe('c:/users/foo/proj'); + expect(workspaceRootKey('c:/Users/Foo/Proj/')).toBe('c:/users/foo/proj'); + expect(workspaceRootKey('C:\\Users\\Foo\\Proj')).toBe(workspaceRootKey('c:/users/foo/proj')); + }); + + it('folds drive roots before separator stripping can mask the shape', () => { + // `C:\` would strip to `C:` and stop reading as Windows-shaped. + expect(workspaceRootKey('C:\\')).toBe('c:'); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:\\')); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:/')); + }); + + it('folds UNC hosts and shares', () => { + expect(workspaceRootKey('\\\\HOST\\Share\\Dir')).toBe('//host/share/dir'); + expect(workspaceRootKey('//HOST/Share/Dir/')).toBe('//host/share/dir'); + }); + + it('strips trailing separators but never case-folds POSIX paths', () => { + expect(workspaceRootKey('/tmp/Foo/')).toBe('/tmp/Foo'); + expect(workspaceRootKey('/tmp/Foo')).not.toBe(workspaceRootKey('/tmp/foo')); + }); }); diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index cee4952631..19c8cfc457 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -169,6 +169,15 @@ export interface KimiCoreOptions { readonly runtime?: ToolServices | undefined; readonly kimiRequestHeaders?: Record | undefined; readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; + /** + * Workspace-id resolver handed to the session store: the registered + * workspace id for the same physical root as a session's workDir (identity + * comparison folds case/slashes for Windows-shaped paths), so bucket + * derivation reuses the registered id instead of minting a split bucket. + * Wired by the services layer from the workspace registry; when omitted the + * store always mints (legacy behavior). + */ + readonly resolveWorkspaceId?: (workDir: string) => Promise; readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly appVersion?: string; @@ -248,7 +257,9 @@ export class KimiCore implements PromisableMethods { this.config.experimental, ); this.imageLimits = new ImageLimits(process.env, this.config.image); - this.sessionStore = new SessionStore(this.homeDir); + this.sessionStore = new SessionStore(this.homeDir, { + resolveWorkspaceId: options.resolveWorkspaceId, + }); this.globalMcpConfig = new GlobalMcpConfigStore(this.homeDir); this.globalMcpOAuth = new McpOAuthService({ kimiHomeDir: this.homeDir }); this.plugins = new PluginManager({ kimiHomeDir: this.homeDir }); diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/services/coreProcess/coreProcessService.ts index fa7fd5bd06..970adb5172 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessService.ts @@ -20,6 +20,7 @@ import { IEnvironmentService } from '../environment/environment'; import { IEventService } from '../event/event'; import { ILogService } from '../logger/logger'; import { IQuestionService } from '../question/question'; +import { IWorkspaceRegistry } from '../workspace/workspaceRegistry'; import { ICoreProcessService, type CoreProcessServiceOptions } from './coreProcess'; export class CoreProcessService extends Disposable implements ICoreProcessService { @@ -69,6 +70,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic @IApprovalService approvalService: IApprovalService, @IQuestionService questionService: IQuestionService, @ILogService logService: ILogService, + @IWorkspaceRegistry workspaceRegistry: IWorkspaceRegistry, ) { super(); @@ -113,6 +115,17 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic const appVersion: string | undefined = options.appVersion ?? options.identity?.version; + // Default-wire the workspace-id resolver. Without it, KimiCore's session + // store mints a bucket hash from the workDir string as-is, so a case/slash + // variant of a registered Windows root splits sessions into a second + // bucket that the registered workspace cannot page. The registry's + // identity-aware lookup reuses the registered id. Caller-supplied + // `resolveWorkspaceId` always wins — same override contract as + // `resolveOAuthTokenProvider` above. + const resolveWorkspaceId = + options.resolveWorkspaceId ?? + ((workDir: string) => workspaceRegistry.findWorkspaceIdByRoot(workDir)); + // 2. Construct the core. KimiCore's ctor wires itself into `coreRpc` and // exposes `this.sdk: Promise` for the reverse direction. this._core = new KimiCore(coreRpc, { @@ -122,6 +135,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic kimiRequestHeaders: this.kimiRequestHeaders, appVersion, resolveOAuthTokenProvider, + resolveWorkspaceId, }); // 3. Satisfy the SDK side with a BridgeClientAPI that routes to peer services. @@ -241,7 +255,8 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // Self-register under the global singleton registry. Ctor signature is // `(options, @IEnvironmentService, @IEventService, @IApprovalService, -// @IQuestionService, @ILogService)` — the leading `options` slot is a pure data bag so we +// @IQuestionService, @ILogService, @IWorkspaceRegistry)` — the leading +// `options` slot is a pure data bag so we // register with `[{}]` as a sane default. Daemon-side `start.ts` overrides // this descriptor via `services.set(ICoreProcessService, new // SyncDescriptor(CoreProcessService, [opts.coreProcessOptions ?? {}], false))` diff --git a/packages/agent-core/src/services/session/session.ts b/packages/agent-core/src/services/session/session.ts index 3f16e1389c..e9e7d3307a 100644 --- a/packages/agent-core/src/services/session/session.ts +++ b/packages/agent-core/src/services/session/session.ts @@ -23,6 +23,12 @@ import { export interface SessionListQuery extends CursorQuery { busy?: boolean; workDir?: string; + /** + * Filter by workspace id: widens to every alias spelling of the same + * physical root (Windows case/slash splits) and returns the union of all + * alias buckets. Takes precedence over `workDir` when both are set. + */ + workspaceId?: string; includeArchive?: boolean; /** When true, hide sessions the user has never interacted with (no prompt yet). */ excludeEmpty?: boolean; @@ -94,6 +100,7 @@ export class SessionNotFoundError extends Error { export function toProtocolSession( summary: SessionSummary, meta?: SessionMeta | undefined, + workspaceIdOverride?: string, ): Session { const summaryMetadata = (summary.metadata ?? {}) as Record; const customMetadata = (meta?.custom ?? {}) as Record; @@ -112,7 +119,12 @@ export function toProtocolSession( }; const title = meta?.title ?? summary.title ?? ''; - const workspaceId = encodeWorkDirKey(summary.workDir); + // Prefer the registered workspace id (resolved by the caller from the + // workspace registry via identity-key match) so the sidebar groups sessions + // under the registered workspace even when the session's workDir string + // differs from the registered root in case/slashes (Windows). Fallback is + // the minted key — the pre-resolver behavior. + const workspaceId = workspaceIdOverride ?? encodeWorkDirKey(summary.workDir); return { id: summary.id, diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index 39de8d5858..636ae39458 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -5,6 +5,7 @@ import { isRealUserInput } from '../../agent/compaction'; import type { AgentContextData, ContextMessage } from '../../agent/context'; import type { JsonObject, ListSessionsPayload, SessionSummary } from '../../rpc'; import type { SessionMeta } from '../../session'; +import { workspaceRootKey } from '../../session/store'; import { type CompactSessionRequest, type CompactSessionResponse, @@ -29,6 +30,7 @@ import { IEventService } from '../event/event'; import { toProtocolMessage } from '../message/message'; import { IPromptService, type AgentStatePatch } from '../prompt/prompt'; import { IQuestionService } from '../question/question'; +import { IWorkspaceRegistry } from '../workspace/workspaceRegistry'; import { ISessionService, SessionNotFoundError, @@ -116,6 +118,7 @@ export class SessionService extends Disposable implements ISessionService { @IInstantiationService private readonly instantiation: IInstantiationService, @IApprovalService private readonly approvalService: IApprovalService, @IQuestionService private readonly questionService: IQuestionService, + @IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry, ) { super(); this._register( @@ -249,17 +252,15 @@ export class SessionService extends Disposable implements ISessionService { } } const meta = await this.tryGetMeta(summary.id); - const session = this._patchSessionStatus(toProtocolSession(summary, meta)); + const session = this._patchSessionStatus( + toProtocolSession(summary, meta, await this.tryResolveWorkspaceId(summary.workDir)), + ); this.emitCreated(session); return session; } async list(query: SessionListQuery): Promise> { - const corePayload: ListSessionsPayload = { - workDir: query.workDir, - includeArchive: query.includeArchive, - }; - const all = await this.core.rpc.listSessions(corePayload); + const all = await this.listSummaries(query); const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt); // Hide sessions the user has never interacted with: a session is "empty" when // it has no lastPrompt (the first prompt has not been sent yet). Filtered @@ -290,7 +291,9 @@ export class SessionService extends Disposable implements ISessionService { const items = await Promise.all( pageSummaries.map(async (s) => - this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id))) + this._patchSessionStatus( + toProtocolSession(s, await this.tryGetMeta(s.id), await this.tryResolveWorkspaceId(s.workDir)), + ) ), ); @@ -307,7 +310,9 @@ export class SessionService extends Disposable implements ISessionService { throw new SessionNotFoundError(id); } const meta = await this.tryGetMeta(id); - return this._patchSessionStatus(toProtocolSession(summary, meta)); + return this._patchSessionStatus( + toProtocolSession(summary, meta, await this.tryResolveWorkspaceId(summary.workDir)), + ); } async update(id: string, input: SessionUpdate): Promise { @@ -355,7 +360,9 @@ export class SessionService extends Disposable implements ISessionService { const allAfter = await this.core.rpc.listSessions({}); const summaryAfter = allAfter.find((s) => s.id === id) ?? summary; const meta = await this.tryGetMeta(id); - return this._patchSessionStatus(toProtocolSession(summaryAfter, meta)); + return this._patchSessionStatus( + toProtocolSession(summaryAfter, meta, await this.tryResolveWorkspaceId(summaryAfter.workDir)), + ); } async fork(id: string, input: SessionFork): Promise { @@ -368,7 +375,9 @@ export class SessionService extends Disposable implements ISessionService { metadata, }); const meta = await this.tryGetMeta(summary.id); - const session = this._patchSessionStatus(toProtocolSession(summary, meta)); + const session = this._patchSessionStatus( + toProtocolSession(summary, meta, await this.tryResolveWorkspaceId(summary.workDir)), + ); this.emitCreated(session); return session; } @@ -404,7 +413,9 @@ export class SessionService extends Disposable implements ISessionService { const pageSummaries = slice.slice(0, pageSize); const items = await Promise.all( pageSummaries.map(async (s) => - this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id))) + this._patchSessionStatus( + toProtocolSession(s, await this.tryGetMeta(s.id), await this.tryResolveWorkspaceId(s.workDir)), + ) ), ); const filtered = @@ -430,7 +441,9 @@ export class SessionService extends Disposable implements ISessionService { metadata, }); const meta = await this.tryGetMeta(summary.id); - const session = this._patchSessionStatus(toProtocolSession(summary, meta)); + const session = this._patchSessionStatus( + toProtocolSession(summary, meta, await this.tryResolveWorkspaceId(summary.workDir)), + ); this.emitCreated(session); return session; } @@ -576,6 +589,43 @@ export class SessionService extends Disposable implements ISessionService { } } + /** + * Summary universe for a list query. A workspace filter widens to every + * alias spelling of the same physical root: pre-resolver legacy splits + * parked sessions in parallel buckets that a single workDir query cannot + * reach — the store resolves a spelling of a registered root back onto the + * registered bucket, hiding the split one. The index-wide list is filtered + * by identity key instead. Read-only: buckets and the index stay untouched. + */ + private async listSummaries(query: SessionListQuery): Promise { + if (query.workspaceId === undefined) { + const corePayload: ListSessionsPayload = { + workDir: query.workDir, + includeArchive: query.includeArchive, + }; + return this.core.rpc.listSessions(corePayload); + } + const aliases = await this.workspaceRegistry.resolveAliasWorkDirs(query.workspaceId); + if (aliases.length === 0) return []; + const aliasKeys = new Set(aliases.map((dir) => workspaceRootKey(dir))); + const all = await this.core.rpc.listSessions({ includeArchive: query.includeArchive }); + return all.filter((summary) => aliasKeys.has(workspaceRootKey(summary.workDir))); + } + + /** + * Registered workspace id for the wire projection, when a registry entry + * identity-matches the session's workDir (case/slash variants of one + * Windows directory fold). Falls back to undefined — the projection then + * mints the key itself, the pre-resolver behavior. + */ + private async tryResolveWorkspaceId(workDir: string): Promise { + try { + return await this.workspaceRegistry.findWorkspaceIdByRoot(workDir); + } catch { + return undefined; + } + } + override dispose(): void { if (this._store.isDisposed) return; super.dispose(); diff --git a/packages/agent-core/src/services/workspace/workspaceRegistry.ts b/packages/agent-core/src/services/workspace/workspaceRegistry.ts index c48a7fa9e6..9fab7e8033 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistry.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistry.ts @@ -41,6 +41,24 @@ export interface IWorkspaceRegistry { delete(workspaceId: string): Promise; resolveRoot(workspaceId: string): Promise; + + /** + * Identity-aware lookup: the id of the registered workspace whose root names + * the same physical directory as `root` (case/slash variants fold for + * Windows-shaped paths), or undefined when no registered entry matches. + * Comparison only — the stored root is never rewritten. + */ + findWorkspaceIdByRoot(root: string): Promise; + + /** + * Every workDir spelling naming the same physical root as `workspaceId` + * (identity-folded via `workspaceRootKey`, so Windows case/slash variants + * collapse): the resolved root itself plus each registered root and each + * session-index workDir sharing the identity key. Index spellings matter — + * split legacy buckets were never registered and exist only as index + * workDir strings. Read-only; ids unknown to both sources resolve to []. + */ + resolveAliasWorkDirs(workspaceId: string): Promise; } // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -54,4 +72,6 @@ export abstract class WorkspaceRegistryBase extends Disposable implements IWorks abstract update(workspaceId: string, patch: WorkspacePatch): Promise; abstract delete(workspaceId: string): Promise; abstract resolveRoot(workspaceId: string): Promise; + abstract findWorkspaceIdByRoot(root: string): Promise; + abstract resolveAliasWorkDirs(workspaceId: string): Promise; } diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index cc4b3b6373..a55e594d63 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -5,7 +5,7 @@ import { basename as posixBasename } from 'pathe'; import type { Stats } from 'node:fs'; import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import { encodeWorkDirKey, normalizeWorkDir } from '../../session/store'; +import { encodeWorkDirKey, normalizeWorkDir, workspaceRootKey } from '../../session/store'; import { readSessionIndex } from '../../session/store/session-index'; import { IEnvironmentService } from '../environment/environment'; import { IEventService } from '../event/event'; @@ -31,6 +31,29 @@ type WorkspaceRegistryEvent = | { type: 'event.workspace.updated'; workspace: Workspace } | { type: 'event.workspace.deleted'; workspace_id: string; root: string }; +/** + * Pure scan over registry entries: the id whose root identity-matches + * `rootKey` (see `workspaceRootKey`), or undefined. When several entries + * identity-match (e.g. a legacy-alias id plus a canonical one for the same + * folder), `preferredId` wins when present — callers pass the id the current + * code would mint for the query root, so post-scan behavior stays consistent + * with a fresh `encodeWorkDirKey`. Otherwise the first entry in file order + * wins. Extracted so the identity-reuse rule is unit-testable without fs. + */ +export function findRegisteredIdByRootKey( + workspaces: Record, + rootKey: string, + preferredId?: string, +): string | undefined { + let first: string | undefined; + for (const [id, entry] of Object.entries(workspaces)) { + if (workspaceRootKey(entry.root) !== rootKey) continue; + if (id === preferredId) return id; + first ??= id; + } + return first; +} + export class WorkspaceRegistryService extends Disposable implements IWorkspaceRegistry { readonly _serviceBrand: undefined; @@ -53,27 +76,31 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe const deleted = new Set(file.deleted_workspace_ids); const result: Workspace[] = []; - // Registered workspaces (explicitly added by the user). Dedup by root: the - // registry can hold legacy entries whose id was computed by an older - // encodeWorkDirKey (e.g. realpath-based on Windows) for the same folder, so - // a single root may map to multiple ids. Prefer the entry whose id matches - // the current canonical key so sessions' workspace_id still resolves and - // the sidebar doesn't render the same workspace twice. + // Registered workspaces (explicitly added by the user). Dedup by root + // identity (`workspaceRootKey` — slashes unified and case folded for + // Windows-shaped roots): the registry can hold multiple entries for the + // same folder — legacy ids computed by an older encodeWorkDirKey (e.g. + // realpath-based on Windows), or ids minted from case variants of one + // directory — so a single physical root may map to multiple ids. Prefer + // the entry whose id matches the current canonical key so sessions' + // workspace_id still resolves and the sidebar doesn't render the same + // workspace twice. // - // The session count is intentionally scoped to the representative's own - // bucket (via hydrate) rather than aggregated across every id for the root: - // GET /sessions?workspace_id= only pages the canonical - // bucket, so the count must reflect what the list can actually retrieve. + // The session count spans every alias bucket for the root (via hydrate): + // GET /sessions?workspace_id= pages the UNION of the + // root's alias buckets, so the count aggregates the same set the list + // can actually retrieve. const byRoot = new Map(); for (const [id, entry] of Object.entries(file.workspaces)) { - const existing = byRoot.get(entry.root); + const rootKey = workspaceRootKey(entry.root); + const existing = byRoot.get(rootKey); if (existing === undefined) { - byRoot.set(entry.root, { id, entry }); + byRoot.set(rootKey, { id, entry }); continue; } const canonicalId = encodeWorkDirKey(normalizeWorkDir(entry.root)); if (existing.id !== canonicalId && id === canonicalId) { - byRoot.set(entry.root, { id, entry }); + byRoot.set(rootKey, { id, entry }); } } for (const { id, entry } of byRoot.values()) { @@ -85,15 +112,32 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe // session index and never persisted, so the registry cannot drift from the // session store. const index = await readSessionIndex(this.homeDir, this.sessionsDir); - const derived = new Map(); // workspace id -> workDir + // Identity keys of every registered root: a session whose workDir only + // differs from a registered root by case/slash spelling (Windows) belongs + // to that registered workspace and must not resurface as a derived + // duplicate. Derived candidates themselves are likewise deduped by + // identity key (first wins). + const registeredKeys = new Set( + Object.values(file.workspaces).map((entry) => workspaceRootKey(entry.root)), + ); + const derived = new Map(); // identity key -> workspace id + workDir for (const entry of index.values()) { const id = encodeWorkDirKey(entry.workDir); - if (file.workspaces[id] !== undefined || deleted.has(id)) continue; - derived.set(id, entry.workDir); + // Deletion tombstones store exact ids, so this match stays exact-string: + // a deleted legacy-alias id whose minted string differs from the current + // session workDir's id can still resurface here as derived (known + // residual edge — the workspaces.json schema is shared with + // agent-core-v2 and must not change). + if (deleted.has(id)) continue; + const rootKey = workspaceRootKey(entry.workDir); + if (registeredKeys.has(rootKey) || derived.has(rootKey)) continue; + derived.set(rootKey, { id, workDir: entry.workDir }); } - for (const [id, workDir] of derived) { - // Skip archived-only buckets so they don't surface as empty groups. - const sessionCount = await countActiveSessions(join(this.sessionsDir, id)); + for (const { id, workDir } of derived.values()) { + // Skip archived-only buckets so they don't surface as empty groups. The + // count spans every alias bucket for the root, matching the registered + // entries (a derived root can also have split legacy spellings). + const sessionCount = await this.countAliasSessions(id); if (sessionCount === 0) continue; result.push( await this.hydrate( @@ -137,12 +181,19 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe // never resolves symlinks or 8.3 short names. Using `fsp.realpath` here // diverged from the session store on Windows and orphaned legacy sessions. const normalizedRoot = normalizeWorkDir(root); - const workspaceId = encodeWorkDirKey(normalizedRoot); - await fsp.mkdir(join(this.sessionsDir, workspaceId), { recursive: true, mode: 0o700 }); - const now = new Date().toISOString(); - const { entry, created } = await this.runExclusive(async () => { + const { workspaceId, entry, created } = await this.runExclusive(async () => { const file = await this.readRegistry(); + // Reuse an already-registered entry whose root names the same physical + // directory (identity-key match) instead of minting a second id: on + // Windows a case/slash variant of a registered root would otherwise + // create a duplicate registry entry — and with it a second session + // bucket — for one folder. The stored root/name stay as first + // registered; stored paths are never rewritten. + const mintedId = encodeWorkDirKey(normalizedRoot); + const workspaceId = + findRegisteredIdByRootKey(file.workspaces, workspaceRootKey(normalizedRoot), mintedId) ?? + mintedId; const existing = file.workspaces[workspaceId]; const next: WorkspaceRegistryEntry = existing !== undefined @@ -154,11 +205,12 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe last_opened_at: now, }; file.workspaces[workspaceId] = next; - // An explicit add clears any prior deletion tombstone. + // An explicit add clears any prior deletion tombstone for that id. file.deleted_workspace_ids = file.deleted_workspace_ids.filter((id) => id !== workspaceId); await this.writeRegistry(file); - return { entry: next, created: existing === undefined }; + return { workspaceId, entry: next, created: existing === undefined }; }); + await fsp.mkdir(join(this.sessionsDir, workspaceId), { recursive: true, mode: 0o700 }); const workspace = await this.hydrate(workspaceId, entry); if (created) { this.publishWorkspace({ type: 'event.workspace.created', workspace }); @@ -192,7 +244,6 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe const existing = file.workspaces[workspaceId]; let root: string; if (existing !== undefined) { - delete file.workspaces[workspaceId]; root = existing.root; } else { // Derived workspace: not in the file but a valid list result. @@ -201,9 +252,34 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe if (derived === undefined) throw new WorkspaceNotFoundError(workspaceId); root = derived; } - if (!file.deleted_workspace_ids.includes(workspaceId)) { - file.deleted_workspace_ids.push(workspaceId); + // Folded aliases must die together: a sibling spelling left registered + // (or resurrectable from the session index) would resurface as this + // directory's representative on the next list(). Remove every registered + // spelling and tombstone every id that could carry sessions for the + // directory — registered alias ids plus each spelling's own minted + // bucket (the derived-workspace loop reads tombstones by exact id). + const rootKey = workspaceRootKey(root); + const tombstones = new Set([workspaceId]); + const spellings = new Set([root]); + for (const [id, entry] of Object.entries(file.workspaces)) { + if (workspaceRootKey(entry.root) !== rootKey) continue; + delete file.workspaces[id]; + tombstones.add(id); + spellings.add(entry.root); + } + const index = await readSessionIndex(this.homeDir, this.sessionsDir); + for (const entry of index.values()) { + if (workspaceRootKey(entry.workDir) === rootKey) spellings.add(entry.workDir); } + for (const spelling of spellings) { + // Both mint forms: the derived-workspace loop keys itself on the raw + // workDir, registered ids and buckets on the normalized one. + tombstones.add(encodeWorkDirKey(spelling)); + tombstones.add(encodeWorkDirKey(normalizeWorkDir(spelling))); + } + file.deleted_workspace_ids = [ + ...new Set([...file.deleted_workspace_ids, ...tombstones]), + ]; await this.writeRegistry(file); return root; }); @@ -228,6 +304,83 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe throw new WorkspaceNotFoundError(workspaceId); } + async findWorkspaceIdByRoot(root: string): Promise { + return this.runExclusive(async () => { + const file = await this.readRegistry(); + // Prefer the id a fresh `encodeWorkDirKey(root)` would mint so callers + // (the session store's bucket derivation) stay on the canonical bucket + // when both a legacy alias and a canonical entry identity-match. + return findRegisteredIdByRootKey(file.workspaces, workspaceRootKey(root), encodeWorkDirKey(root)); + }); + } + + async resolveAliasWorkDirs(workspaceId: string): Promise { + return (await this.aliasLayout(workspaceId))?.aliases ?? []; + } + + /** + * Alias workDir spellings plus the session buckets that can hold sessions + * for the same physical root as `workspaceId` — or undefined when the id is + * unknown to both the registry and the session index. The bucket set is the + * union of both placement eras: every registered id for the root (sessions + * created with a wired bucket resolver land there, including legacy alias + * ids that no longer match a fresh `encodeWorkDirKey(root)` mint) and each + * spelling's own minted bucket (pre-resolver split, never rewritten). + */ + private async aliasLayout( + workspaceId: string, + ): Promise<{ aliases: readonly string[]; buckets: readonly string[] } | undefined> { + const [file, index] = await Promise.all([ + this.runExclusive(() => this.readRegistry()), + readSessionIndex(this.homeDir, this.sessionsDir), + ]); + // Resolve the id's root: the registered entry verbatim, else the derived + // bucket's recorded workDir (same rule as resolveRoot/findDerivedWorkDir). + let root = file.workspaces[workspaceId]?.root; + if (root === undefined) { + for (const entry of index.values()) { + if (encodeWorkDirKey(entry.workDir) === workspaceId) { + root = entry.workDir; + break; + } + } + if (root === undefined) return undefined; + } + const rootKey = workspaceRootKey(root); + const aliases = new Set([root]); + const buckets = new Set(); + for (const [id, entry] of Object.entries(file.workspaces)) { + if (workspaceRootKey(entry.root) !== rootKey) continue; + aliases.add(entry.root); + buckets.add(id); + } + for (const entry of index.values()) { + if (workspaceRootKey(entry.workDir) === rootKey) aliases.add(entry.workDir); + } + for (const dir of aliases) { + buckets.add(encodeWorkDirKey(normalizeWorkDir(dir))); + } + return { aliases: [...aliases].toSorted(), buckets: [...buckets] }; + } + + /** + * Active-session count across ALL alias buckets for the workspace's root, + * not just the id's own bucket: GET /sessions?workspace_id= pages the + * union of alias buckets, so the count aggregates the same set the list can + * actually retrieve. + */ + private async countAliasSessions(workspaceId: string): Promise { + const layout = await this.aliasLayout(workspaceId); + if (layout === undefined) { + return countActiveSessions(join(this.sessionsDir, workspaceId)); + } + let count = 0; + for (const bucket of layout.buckets) { + count += await countActiveSessions(join(this.sessionsDir, bucket)); + } + return count; + } + /** Look up a derived workspace's workDir from the session index, or undefined * if the id is not a known derived bucket. */ private async findDerivedWorkDir(workspaceId: string): Promise { @@ -243,8 +396,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe entry: WorkspaceRegistryEntry, sessionCount?: number, ): Promise { - const session_count = - sessionCount ?? (await countActiveSessions(join(this.sessionsDir, workspaceId))); + const session_count = sessionCount ?? (await this.countAliasSessions(workspaceId)); return { id: workspaceId, root: entry.root, diff --git a/packages/agent-core/src/session/store/index.ts b/packages/agent-core/src/session/store/index.ts index 54bba0465f..d485a42b8b 100644 --- a/packages/agent-core/src/session/store/index.ts +++ b/packages/agent-core/src/session/store/index.ts @@ -5,4 +5,4 @@ export type { SessionStoreOptions, } from '#/session/store/session-store'; export { sessionIndexPath } from '#/session/store/session-index'; -export { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; +export { encodeWorkDirKey, normalizeWorkDir, workspaceRootKey } from '#/session/store/workdir-key'; diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 6e5e2ec721..630153b9b9 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -52,16 +52,28 @@ export interface ForkSessionRecordInput { readonly turnIndex?: number; } -export type SessionStoreOptions = Record; +export type SessionStoreOptions = { + /** + * Optional identity hook (wired by the services layer from the workspace + * registry): the already-registered workspace id for the same physical root + * as `workDir`, or undefined when no entry matches. Bucket derivation + * prefers it over minting a fresh `encodeWorkDirKey` hash, so a session + * created from a case/slash variant of a registered Windows root lands in + * the registered bucket instead of splitting into a second one. + */ + readonly resolveWorkspaceId?: (workDir: string) => Promise; +}; export class SessionStore { readonly sessionsDir: string; + private readonly resolveWorkspaceId: SessionStoreOptions['resolveWorkspaceId']; constructor( readonly homeDir: string, - _options: SessionStoreOptions = {}, + options: SessionStoreOptions = {}, ) { this.sessionsDir = join(homeDir, 'sessions'); + this.resolveWorkspaceId = options.resolveWorkspaceId; } sessionDirFor(input: { readonly id: string; readonly workDir: string }): string { @@ -69,6 +81,40 @@ export class SessionStore { return join(this.sessionsDir, encodeWorkDirKey(normalizeWorkDir(input.workDir)), input.id); } + /** + * Bucket key for a workDir: asks the workspace registry (when wired) for the + * registered id of the same physical root — see SessionStoreOptions — and + * prefers it over the freshly minted `encodeWorkDirKey` hash. Falls back to + * minting when the resolver is absent, errors, or returns an id that is not + * a safe bucket name (registry contents are user-editable state; minted ids + * always pass `isSafeSessionId`). + */ + private async bucketKeyFor(workDir: string): Promise { + let resolved: string | undefined; + try { + resolved = await this.resolveWorkspaceId?.(workDir); + } catch { + resolved = undefined; + } + return resolved !== undefined && isSafeSessionId(resolved) + ? resolved + : encodeWorkDirKey(normalizeWorkDir(workDir)); + } + + /** Like `sessionDirFor`, but under the registry-resolved bucket. */ + private async resolvedSessionDirFor(input: { + readonly id: string; + readonly workDir: string; + }): Promise { + assertSafeSessionId(input.id); + return join(this.sessionsDir, await this.bucketKeyFor(input.workDir), input.id); + } + + /** Bucket directory for a workDir, registry-resolved when possible. */ + private async bucketDirFor(workDir: string): Promise { + return join(this.sessionsDir, await this.bucketKeyFor(workDir)); + } + async create(input: CreateSessionRecordInput): Promise { assertSafeSessionId(input.id); const workDir = normalizeWorkDir(input.workDir); @@ -77,7 +123,7 @@ export class SessionStore { throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`); } - const dir = this.sessionDirFor({ id: input.id, workDir }); + const dir = await this.resolvedSessionDirFor({ id: input.id, workDir }); if (await isDirectory(dir)) { throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`); } @@ -100,7 +146,7 @@ export class SessionStore { throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`); } - const targetDir = this.sessionDirFor({ id: input.targetId, workDir: source.workDir }); + const targetDir = await this.resolvedSessionDirFor({ id: input.targetId, workDir: source.workDir }); if (await isDirectory(targetDir)) { throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`); } @@ -274,8 +320,15 @@ export class SessionStore { continue; } // Refuse to index a session whose recorded workDir does not match the - // bucket it lives in (corrupt or foreign state). - if (!areSameFsPath(sessionDir, expectedDir)) continue; + // bucket it lives in (corrupt or foreign state). The registry-resolved + // bucket is accepted too: sessions created with a wired resolver live + // in the registered bucket even though their workDir mints elsewhere. + if ( + !areSameFsPath(sessionDir, expectedDir) && + !areSameFsPath(sessionDir, await this.resolvedSessionDirFor({ id, workDir })) + ) { + continue; + } const existing = index.get(id); if ( @@ -319,7 +372,7 @@ export class SessionStore { workDir: string, includeArchive: boolean, ): Promise { - const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir)); + const bucketDir = await this.bucketDirFor(workDir); let entries: Dirent[] = []; try { entries = await readdir(bucketDir, { withFileTypes: true }); @@ -393,7 +446,7 @@ export class SessionStore { includeArchive: boolean, ): Promise { if (!isSafeSessionId(sessionId)) return undefined; - const sessionDir = this.sessionDirFor({ id: sessionId, workDir }); + const sessionDir = await this.resolvedSessionDirFor({ id: sessionId, workDir }); if (!(await isDirectory(sessionDir))) return undefined; const summary = await this.summaryFromDir(sessionId, sessionDir, workDir); if (!includeArchive && summary.archived === true) return undefined; diff --git a/packages/agent-core/src/session/store/workdir-key.ts b/packages/agent-core/src/session/store/workdir-key.ts index 3e9d1ce195..b6b0064789 100644 --- a/packages/agent-core/src/session/store/workdir-key.ts +++ b/packages/agent-core/src/session/store/workdir-key.ts @@ -24,3 +24,29 @@ export function encodeWorkDirKey(workDir: string): string { function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); } + +// Windows-shaped: drive-letter (C:\, C:/) or UNC (\\host\share, //host/share). +// Shape-based detection (not process.platform): consistent folding in tests +// on any host OS. Looser than isWindowsAbsolutePath above (any leading `\\` +// or `//` counts): for case-folding, an over-broad UNC match only folds case +// on paths that already require shape detection, never on POSIX paths. +const WIN_SHAPED = /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/; + +/** + * Identity key for "same workspace directory?" comparisons: slash-normalize, + * strip trailing separators, case-fold Windows-shaped paths (NTFS lookups are + * case-insensitive by default). Pure string ops — deliberately NOT + * normalizeWorkDir/pathe.resolve, which join the process cwd into + * Windows-shaped strings on POSIX hosts. Comparison only; stored/displayed + * paths are never rewritten. Per-directory case sensitivity (fsutil) / WSL + * paths are a documented non-goal; POSIX paths never fold. + */ +export function workspaceRootKey(root: string): string { + const slashed = root.replaceAll('\\', '/'); + // Test the shape BEFORE stripping trailing separators: a drive root + // (`C:\`) loses its only separator to the strip (`C:`) and would no + // longer read as Windows-shaped, escaping the case-fold. + const shaped = WIN_SHAPED.test(slashed); + const normalized = slashed.replace(/\/+$/, ''); + return shaped ? normalized.toLowerCase() : normalized; +} diff --git a/packages/agent-core/src/session/store/workspace-registry-file.ts b/packages/agent-core/src/session/store/workspace-registry-file.ts index 8671b1c44d..707dff6917 100644 --- a/packages/agent-core/src/session/store/workspace-registry-file.ts +++ b/packages/agent-core/src/session/store/workspace-registry-file.ts @@ -16,7 +16,7 @@ import { promises as fsp } from 'node:fs'; import { dirname, join } from 'node:path'; import { basename as posixBasename } from 'pathe'; -import { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; +import { encodeWorkDirKey, normalizeWorkDir, workspaceRootKey } from '#/session/store/workdir-key'; const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; const WORKSPACE_REGISTRY_VERSION = 1; @@ -119,6 +119,12 @@ export async function writeWorkspaceRegistryFile( * non-fatal (the catalog is a hint, not session state). Concurrent writers in * other processes cannot corrupt the file (atomic rename), though a lost * update is possible — the next session-index merge heals missing entries. + * + * Identity folding matches the service: a spelling that only case/slash-differs + * from an existing entry's root (e.g. `c:\Foo` after `C:\Foo` was added) + * touches THAT entry instead of minting a duplicate. Without this the session + * store's `resolveWorkspaceId` would split buckets again — the minted alias id + * becomes the preferred id on the next create. */ export async function touchWorkspaceRegistry( homeDir: string, @@ -126,10 +132,21 @@ export async function touchWorkspaceRegistry( name?: string, ): Promise<{ workspaceId: string; created: boolean }> { const normalizedRoot = normalizeWorkDir(root); - const workspaceId = encodeWorkDirKey(normalizedRoot); + const mintedId = encodeWorkDirKey(normalizedRoot); const now = new Date().toISOString(); const file = await readWorkspaceRegistryFile(homeDir); - const existing = file.workspaces[workspaceId]; + let workspaceId = mintedId; + let existing = file.workspaces[mintedId]; + if (existing === undefined) { + const rootKey = workspaceRootKey(normalizedRoot); + const aliasId = Object.keys(file.workspaces).find( + (id) => workspaceRootKey(file.workspaces[id]!.root) === rootKey, + ); + if (aliasId !== undefined) { + workspaceId = aliasId; + existing = file.workspaces[aliasId]; + } + } file.workspaces[workspaceId] = existing !== undefined ? { ...existing, last_opened_at: now } diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index 9e329f9235..0c9241e4c8 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -25,6 +25,7 @@ import { ILogService, ICoreProcessService, IQuestionService, + IWorkspaceRegistry, } from '../../src/services'; class RecordingEventService implements IEventService { @@ -93,6 +94,33 @@ class NoopLogService implements ILogService { } } +class NoopWorkspaceRegistry implements IWorkspaceRegistry { + readonly _serviceBrand: undefined; + + async list(): ReturnType { + return []; + } + async get(): ReturnType { + throw new Error('not implemented'); + } + async createOrTouch(): ReturnType { + throw new Error('not implemented'); + } + async update(): ReturnType { + throw new Error('not implemented'); + } + async delete(): Promise {} + async resolveRoot(): Promise { + throw new Error('not implemented'); + } + async findWorkspaceIdByRoot(): Promise { + return undefined; + } + async resolveAliasWorkDirs(): Promise { + return []; + } +} + let tmpHome: string; let prevHome: string | undefined; @@ -120,6 +148,7 @@ function makePeers() { approvalService: new RecordingApprovalService(), questionService: new RecordingQuestionService(), logService: new NoopLogService(), + workspaceRegistry: new NoopWorkspaceRegistry(), }; } @@ -179,7 +208,7 @@ describe('BridgeClientAPI', () => { describe('CoreProcessService direct construction', () => { it('constructs, exposes a callable rpc proxy, and ready() resolves', async () => { - const { eventService, approvalService, questionService, logService } = makePeers(); + const { eventService, approvalService, questionService, logService, workspaceRegistry } = makePeers(); const core = new CoreProcessService( {}, makeEnv(tmpHome), @@ -187,6 +216,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + workspaceRegistry, ); try { await expect(core.ready()).resolves.toBeUndefined(); @@ -197,7 +227,7 @@ describe('CoreProcessService direct construction', () => { }); it('rpc round-trip through createRPC reaches KimiCore (getCoreInfo smoke)', async () => { - const { eventService, approvalService, questionService, logService } = makePeers(); + const { eventService, approvalService, questionService, logService, workspaceRegistry } = makePeers(); const core = new CoreProcessService( {}, makeEnv(tmpHome), @@ -205,6 +235,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + workspaceRegistry, ); try { await core.ready(); @@ -217,7 +248,7 @@ describe('CoreProcessService direct construction', () => { }); it('dispose is idempotent and short-circuits subsequent rpc calls', async () => { - const { eventService, approvalService, questionService, logService } = makePeers(); + const { eventService, approvalService, questionService, logService, workspaceRegistry } = makePeers(); const core = new CoreProcessService( {}, makeEnv(tmpHome), @@ -225,6 +256,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + workspaceRegistry, ); await core.ready(); core.dispose(); @@ -288,6 +320,7 @@ describe('singleton registry composition', () => { ix.stub(IQuestionService, questionService); ix.stub(IEnvironmentService, makeEnv(tmpHome)); ix.stub(ILogService, new NoopLogService()); + ix.stub(IWorkspaceRegistry, new NoopWorkspaceRegistry()); try { const core = ix.createInstance(CoreProcessService, {}); diff --git a/packages/agent-core/test/services/session-service.test.ts b/packages/agent-core/test/services/session-service.test.ts index 51704823e2..eebd0d7b24 100644 --- a/packages/agent-core/test/services/session-service.test.ts +++ b/packages/agent-core/test/services/session-service.test.ts @@ -25,6 +25,7 @@ import { IPromptService, IQuestionService, type ISessionService, + IWorkspaceRegistry, PromptService, SessionNotFoundError, SessionUndoUnavailableError, @@ -243,8 +244,30 @@ let promptStub: ReturnType; let approvalStub: ReturnType; let questionStub: ReturnType; let eventBus: ReturnType; +let workspaceRegistryStub: ReturnType; let instantiation: TestInstantiationService; +function makeWorkspaceRegistryStub(): { + workspaceRegistry: IWorkspaceRegistry; + findWorkspaceIdByRoot: ReturnType; + resolveAliasWorkDirs: ReturnType; +} { + const findWorkspaceIdByRoot = vi.fn(async (): Promise => undefined); + const resolveAliasWorkDirs = vi.fn(async (): Promise => []); + const workspaceRegistry: IWorkspaceRegistry = { + _serviceBrand: undefined, + list: vi.fn() as unknown as IWorkspaceRegistry['list'], + get: vi.fn() as unknown as IWorkspaceRegistry['get'], + createOrTouch: vi.fn() as unknown as IWorkspaceRegistry['createOrTouch'], + update: vi.fn() as unknown as IWorkspaceRegistry['update'], + delete: vi.fn() as unknown as IWorkspaceRegistry['delete'], + resolveRoot: vi.fn() as unknown as IWorkspaceRegistry['resolveRoot'], + findWorkspaceIdByRoot, + resolveAliasWorkDirs, + }; + return { workspaceRegistry, findWorkspaceIdByRoot, resolveAliasWorkDirs }; +} + function makeEventServiceStub(): { eventService: IEventService; events: unknown[]; @@ -346,6 +369,7 @@ beforeEach(() => { approvalStub = makeApprovalServiceStub(); questionStub = makeQuestionServiceStub(); eventBus = makeEventServiceStub(); + workspaceRegistryStub = makeWorkspaceRegistryStub(); instantiation = makeTestInstantiation({ promptService: promptStub.promptService, approvalService: approvalStub.approvalService, @@ -357,6 +381,7 @@ beforeEach(() => { instantiation, approvalStub.approvalService, questionStub.questionService, + workspaceRegistryStub.workspaceRegistry, ); }); @@ -502,6 +527,20 @@ describe('toProtocolSession adapter', () => { expect(proto.workspace_id).toBe(encodeWorkDirKey('/tmp/wd-ws')); expect(proto.workspace_id).toMatch(/^wd_[A-Za-z0-9._-]+_[0-9a-f]{12}$/); }); + + it('prefers the registry-resolved workspace id over the minted key', async () => { + const { encodeWorkDirKey } = await import('../../src/session/store'); + const summary: SessionSummary = { + id: 'sess_ws_alias', + workDir: '/tmp/wd-ws', + sessionDir: '/tmp/sd-ws', + createdAt: 0, + updatedAt: 0, + }; + const proto = toProtocolSession(summary, undefined, 'wd_registered_0123456789ab'); + expect(proto.workspace_id).toBe('wd_registered_0123456789ab'); + expect(proto.workspace_id).not.toBe(encodeWorkDirKey('/tmp/wd-ws')); + }); }); describe('SessionService.create', () => { @@ -517,6 +556,19 @@ describe('SessionService.create', () => { expect(session.created_at.endsWith('Z')).toBe(true); }); + it('projects the registered workspace id when one identity-matches the cwd', async () => { + workspaceRegistryStub.findWorkspaceIdByRoot.mockResolvedValue('wd_registered_0123456789ab'); + const session = await svc.create({ metadata: { cwd: '/tmp/foo' } }); + expect(workspaceRegistryStub.findWorkspaceIdByRoot).toHaveBeenCalledWith('/tmp/foo'); + expect(session.workspace_id).toBe('wd_registered_0123456789ab'); + }); + + it('falls back to the minted workspace id when the registry has no match', async () => { + const { encodeWorkDirKey } = await import('../../src/session/store'); + const session = await svc.create({ metadata: { cwd: '/tmp/foo' } }); + expect(session.workspace_id).toBe(encodeWorkDirKey('/tmp/foo')); + }); + it('passes model through to the agent_config when supplied', async () => { await svc.create({ metadata: { cwd: '/tmp/x' }, @@ -648,6 +700,54 @@ describe('SessionService.list', () => { expect(next.items.map((s) => s.id)).toEqual(['u2']); expect(next.has_more).toBe(false); }); + + it('workspaceId returns the union across alias workDirs, sorted by recency', async () => { + const ts = (n: number) => 1_000_000 + n * 1_000; + const summary = (id: string, workDir: string, updatedAt: number): SessionSummary => ({ + id, + workDir, + sessionDir: `/tmp/sessions/${id}`, + createdAt: updatedAt, + updatedAt, + metadata: { cwd: workDir }, + title: undefined, + }); + // Three spellings of one Windows root (the legacy-split setup) — the union + // — plus an unrelated session that must stay out of the page. A single + // workDir query could never see all three: variants of a registered root + // resolve back onto the registered bucket. + state.sessions = [ + summary('s1', 'C:\\Dev\\Project', ts(1)), + summary('s2', 'c:\\dev\\project', ts(3)), + summary('s3', 'C:/dev/project', ts(2)), + summary('s4', '/tmp/other', ts(4)), + ]; + workspaceRegistryStub.resolveAliasWorkDirs.mockResolvedValue([ + 'C:\\Dev\\Project', + 'C:/dev/project', + 'c:\\dev\\project', + ]); + + const page = await svc.list({ workspaceId: 'wd_alias_deadbeef0000' }); + + expect(workspaceRegistryStub.resolveAliasWorkDirs).toHaveBeenCalledWith( + 'wd_alias_deadbeef0000', + ); + expect(page.items.map((s) => s.id)).toEqual(['s2', 's3', 's1']); + expect(page.has_more).toBe(false); + }); + + it('workspaceId for an unknown workspace returns an empty page', async () => { + const page = await svc.list({ workspaceId: 'wd_unknown_deadbeef0000' }); + expect(page.items).toEqual([]); + expect(page.has_more).toBe(false); + }); + + it('does not consult workspace aliases when no workspaceId is given', async () => { + await svc.list({}); + await svc.list({ workDir: '/tmp/b' }); + expect(workspaceRegistryStub.resolveAliasWorkDirs).not.toHaveBeenCalled(); + }); }); describe('SessionService.get', () => { diff --git a/packages/agent-core/test/services/workspace-registry.test.ts b/packages/agent-core/test/services/workspace-registry.test.ts index f670d980f3..1069c839b2 100644 --- a/packages/agent-core/test/services/workspace-registry.test.ts +++ b/packages/agent-core/test/services/workspace-registry.test.ts @@ -9,10 +9,13 @@ import type { Event } from '@moonshot-ai/protocol'; import type { IEnvironmentService } from '../../src/services/environment/environment'; import type { IEventService } from '../../src/services/event/event'; import type { ILogService } from '../../src/services/logger/logger'; -import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService'; +import { + WorkspaceRegistryService, + findRegisteredIdByRootKey, +} from '../../src/services/workspace/workspaceRegistryService'; import { touchWorkspaceRegistry } from '../../src/session/store/workspace-registry-file'; import { appendSessionIndexEntry } from '../../src/session/store/session-index'; -import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; +import { encodeWorkDirKey, normalizeWorkDir, workspaceRootKey } from '../../src/session/store/workdir-key'; function makeLogger(): ILogService { const noop = (): void => {}; @@ -237,9 +240,9 @@ describe('WorkspaceRegistryService', () => { ); // One active session in the canonical bucket (via the index)... await seedSessionBucket(root, 'sess-canonical-1'); - // ...and one stranded in the legacy bucket. It is NOT counted: the returned - // workspace can only page the canonical bucket via GET /sessions, so the - // count stays consistent with what the list can retrieve. + // ...and one stranded in the legacy bucket. Both count: the session list + // for a workspace id pages the UNION of the root's alias buckets, so the + // count aggregates the same set the list can retrieve. const legacySessionDir = join(ctx.homeDir, 'sessions', legacyId, 'sess-legacy-1'); await mkdir(legacySessionDir, { recursive: true }); await writeFile( @@ -252,8 +255,330 @@ describe('WorkspaceRegistryService', () => { const matches = list.filter((w) => w.root === root); expect(matches).toHaveLength(1); expect(matches[0]?.id).toBe(canonicalId); - // Count is scoped to the representative's (canonical) bucket only. - expect(matches[0]?.session_count).toBe(1); + // Count spans every alias bucket for the root (canonical + legacy). + expect(matches[0]?.session_count).toBe(2); + }); + + it('merges registered entries whose roots differ only by drive-letter casing', async () => { + // Pure file state — Windows-shaped roots are never touched as fs paths, so + // this runs on any host. Two entries for one physical folder: the user's + // typed casing and the disk's real casing. + const typedId = 'wd_typed_deadbeef0000'; + const diskId = 'wd_disk_0123456789ab'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [typedId]: { root: 'C:\\Users\\Dev\\Project', name: 'typed', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + [diskId]: { root: 'c:\\users\\dev\\project', name: 'disk', created_at: '2026-01-02T00:00:00.000Z', last_opened_at: '2026-01-02T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + + const list = await ctx.registry.list(); + const matches = list.filter((w) => w.name === 'typed' || w.name === 'disk'); + expect(matches).toHaveLength(1); + // Neither fixture id is the canonical mint for these roots, so the first + // entry in file order stands as the representative. + expect(matches[0]?.id).toBe(typedId); + expect(matches[0]?.root).toBe('C:\\Users\\Dev\\Project'); + }); + + it('keeps POSIX registered entries with case-variant roots distinct', async () => { + const upperId = 'wd_upper_deadbeef0000'; + const lowerId = 'wd_lower_0123456789ab'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [upperId]: { root: '/Home/Dev/Project', name: 'upper', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + [lowerId]: { root: '/home/dev/project', name: 'lower', created_at: '2026-01-02T00:00:00.000Z', last_opened_at: '2026-01-02T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + + const list = await ctx.registry.list(); + const matches = list.filter((w) => w.name === 'upper' || w.name === 'lower'); + expect(matches).toHaveLength(2); + }); + + it('does not surface a derived workspace whose index workDir identity-matches a registered root', async () => { + // The registered root and the session's recorded workDir are case variants + // of one Windows directory; the bucket/index are keyed by hash strings, so + // no Windows-shaped string touches the fs. + const registeredId = 'wd_regalias_deadbeef0000'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [registeredId]: { root: 'C:\\Users\\Dev\\CaseProj', name: 'caseproj', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + // A session bucket + index entry keyed by the lowercase variant — without + // identity-aware skipping this resurfaces as a second, derived workspace. + await seedSessionBucket('c:\\users\\dev\\caseproj', 'sess-case-1'); + + const list = await ctx.registry.list(); + expect(list.filter((w) => w.id === registeredId)).toHaveLength(1); + expect(list.some((w) => w.root === 'c:\\users\\dev\\caseproj')).toBe(false); + }); + + it('findWorkspaceIdByRoot returns the registered id for a case variant of a Windows root', async () => { + const legacyId = 'wd_legacy_deadbeef0000'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [legacyId]: { root: 'C:\\Users\\Dev\\Project', name: 'legacy', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + + await expect( + ctx.registry.findWorkspaceIdByRoot('c:\\users\\dev\\project'), + ).resolves.toBe(legacyId); + await expect( + ctx.registry.findWorkspaceIdByRoot('/unrelated/path'), + ).resolves.toBeUndefined(); + }); + + it('resolveAliasWorkDirs folds registered pairs and index-only spellings of one Windows root', async () => { + // Two registered entries for the same folder (case variants) plus a third + // spelling that only exists in the session index — the split legacy bucket + // was never registered. + const typedId = 'wd_typed_deadbeef0000'; + const diskId = 'wd_disk_0123456789ab'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [typedId]: { root: 'C:\\Users\\Dev\\Project', name: 'typed', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + [diskId]: { root: 'c:\\users\\dev\\project', name: 'disk', created_at: '2026-01-02T00:00:00.000Z', last_opened_at: '2026-01-02T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + await seedSessionBucket('C:/users/dev/project', 'sess-alias-1'); + // An unrelated session/workDir must not leak into the alias set. + await seedSessionBucket('D:\\other\\place', 'sess-alias-other'); + + const expected = ['C:\\Users\\Dev\\Project', 'c:\\users\\dev\\project', 'C:/users/dev/project']; + for (const id of [typedId, diskId]) { + const aliases = await ctx.registry.resolveAliasWorkDirs(id); + expect(new Set(aliases)).toEqual(new Set(expected)); + // Deterministic order (sorted). + expect(aliases).toEqual([...expected].toSorted()); + } + + // A derived id (index-only bucket key) resolves its aliases the same way. + const derivedAliases = await ctx.registry.resolveAliasWorkDirs( + encodeWorkDirKey('C:/users/dev/project'), + ); + expect(new Set(derivedAliases)).toEqual(new Set(expected)); + }); + + it('resolveAliasWorkDirs returns [] for an id unknown to registry and index', async () => { + await expect(ctx.registry.resolveAliasWorkDirs('wd_unknown_deadbeef0000')).resolves.toEqual([]); + }); + + it('resolveAliasWorkDirs keeps POSIX case variants distinct (singleton)', async () => { + const upperId = 'wd_upper_deadbeef0000'; + const lowerId = 'wd_lower_0123456789ab'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [upperId]: { root: '/Home/Dev/Project', name: 'upper', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + [lowerId]: { root: '/home/dev/project', name: 'lower', created_at: '2026-01-02T00:00:00.000Z', last_opened_at: '2026-01-02T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + + // POSIX paths never fold: each id resolves to its own root only. + await expect(ctx.registry.resolveAliasWorkDirs(upperId)).resolves.toEqual(['/Home/Dev/Project']); + await expect(ctx.registry.resolveAliasWorkDirs(lowerId)).resolves.toEqual(['/home/dev/project']); + }); + + it('delete removes and tombstones every folded alias of a Windows root', async () => { + // Split legacy state: two registered spellings of one Windows root, plus a + // third spelling remembered only by the session index. + const typedRoot = 'C:\\Users\\Del\\Proj'; + const typedId = encodeWorkDirKey(normalizeWorkDir(typedRoot)); + const aliasRoot = 'c:\\users\\del\\proj'; + const aliasId = encodeWorkDirKey(normalizeWorkDir(aliasRoot)); + const indexOnlyRoot = 'C:/users/del/proj'; + await writeFile( + join(ctx.homeDir, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [typedId]: { root: typedRoot, name: 'proj', created_at: 'x', last_opened_at: 'x' }, + [aliasId]: { root: aliasRoot, name: 'proj', created_at: 'x', last_opened_at: 'x' }, + }, + deleted_workspace_ids: [], + }), + 'utf-8', + ); + await appendSessionIndexEntry(ctx.homeDir, { + sessionId: 's1', + sessionDir: join(ctx.homeDir, 'sessions', encodeWorkDirKey(indexOnlyRoot), 's1'), + workDir: indexOnlyRoot, + }); + + await ctx.registry.delete(typedId); + + await expect(ctx.registry.list()).resolves.toEqual([]); + const file = JSON.parse(await readFile(join(ctx.homeDir, 'workspaces.json'), 'utf-8')) as { + workspaces: Record; + deleted_workspace_ids: string[]; + }; + expect(Object.keys(file.workspaces)).toEqual([]); + const expectedTombstones = new Set([typedId, aliasId]); + expectedTombstones.add(encodeWorkDirKey(indexOnlyRoot)); + expectedTombstones.add(encodeWorkDirKey(normalizeWorkDir(indexOnlyRoot))); + expect(new Set(file.deleted_workspace_ids)).toEqual(expectedTombstones); + // Nothing left to resolve the directory through — no resurrection path. + await expect(ctx.registry.resolveRoot(typedId)).rejects.toThrow(); + }); + + it('session_count sums active sessions across alias buckets for one Windows root', async () => { + // One registered root; sessions are split between the registered id's own + // bucket (resolved-era placement) and a second bucket minted from a case + // variant of the root (pre-resolver legacy split, never registered). + const registeredId = 'wd_sum_deadbeef0000'; + const registryPath = join(ctx.homeDir, 'workspaces.json'); + await writeFile( + registryPath, + JSON.stringify( + { + version: 1, + workspaces: { + [registeredId]: { root: 'C:\\Users\\Dev\\SumProj', name: 'sumproj', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' }, + }, + deleted_workspace_ids: [], + }, + null, + 2, + ), + 'utf-8', + ); + // Active session in the registered bucket (not indexed — bucket counts do + // not consult the index). + const registeredSessionDir = join(ctx.homeDir, 'sessions', registeredId, 'sess-sum-1'); + await mkdir(registeredSessionDir, { recursive: true }); + await writeFile( + join(registeredSessionDir, 'state.json'), + JSON.stringify({ archived: false }), + 'utf-8', + ); + // One active + one archived session in the split, index-only bucket. + await seedSessionBucket('c:\\users\\dev\\sumproj', 'sess-sum-2'); + await seedSessionBucket('c:\\users\\dev\\sumproj', 'sess-sum-3', { archived: true }); + + expect((await ctx.registry.get(registeredId)).session_count).toBe(2); + const listed = (await ctx.registry.list()).find((w) => w.id === registeredId); + expect(listed?.session_count).toBe(2); + }); +}); + +describe('findRegisteredIdByRootKey', () => { + const entry = (root: string) => ({ + root, + name: 'x', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }); + + it('matches a case/slash variant of a registered Windows-shaped root', () => { + const hit = findRegisteredIdByRootKey( + { wd_a_deadbeef0000: entry('C:\\Users\\Dev\\Project') }, + workspaceRootKey('c:/users/dev/project'), + ); + expect(hit).toBe('wd_a_deadbeef0000'); + }); + + it('returns undefined when nothing identity-matches', () => { + expect( + findRegisteredIdByRootKey( + { wd_a_deadbeef0000: entry('/home/dev') }, + workspaceRootKey('/home/other'), + ), + ).toBeUndefined(); + }); + + it('keeps POSIX case variants distinct (POSIX paths never fold)', () => { + expect( + findRegisteredIdByRootKey( + { wd_a_deadbeef0000: entry('/Home/Dev/Project') }, + workspaceRootKey('/home/dev/project'), + ), + ).toBeUndefined(); + }); + + it('prefers preferredId over file order when several entries identity-match', () => { + const workspaces = { + wd_legacy_deadbeef0000: entry('C:\\Users\\Dev\\Project'), + wd_canonical_0123456789ab: entry('c:\\users\\dev\\project'), + }; + expect( + findRegisteredIdByRootKey( + workspaces, + workspaceRootKey('C:/Users/Dev/Project'), + 'wd_canonical_0123456789ab', + ), + ).toBe('wd_canonical_0123456789ab'); + // Without a preference the first entry in file order wins. + expect(findRegisteredIdByRootKey(workspaces, workspaceRootKey('C:/Users/Dev/Project'))).toBe( + 'wd_legacy_deadbeef0000', + ); }); }); @@ -349,4 +674,48 @@ describe('touchWorkspaceRegistry', () => { const file = await readRegistryFile(); expect(file.workspaces[result.workspaceId]?.root).toBe(root); }); + + it('folds a Windows case-variant spelling onto the existing entry instead of minting a duplicate', async () => { + // The runtime touch path must mirror the service's identity folding: + // minting the alias id here would make it the preferred id on the next + // `resolveWorkspaceId` call and split sessions into the duplicate bucket. + const diskSpelling = 'C:\\Users\\Foo\\Proj'; + const diskId = encodeWorkDirKey(normalizeWorkDir(diskSpelling)); + await writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [diskId]: { + root: diskSpelling, + name: 'Proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }, + deleted_workspace_ids: [], + }), + 'utf-8', + ); + + const result = await touchWorkspaceRegistry(homeDir, 'c:\\users\\foo\\proj'); + + expect(result.created).toBe(false); + expect(result.workspaceId).toBe(diskId); + const file = await readRegistryFile(); + expect(Object.keys(file.workspaces)).toEqual([diskId]); + expect(file.workspaces[diskId]?.root).toBe(diskSpelling); + expect(file.workspaces[diskId]?.name).toBe('Proj'); + expect(Date.parse(file.workspaces[diskId]?.last_opened_at ?? '')).toBeGreaterThan( + Date.parse('2026-01-01T00:00:00.000Z'), + ); + }); + + it('keeps case-distinct POSIX roots as separate entries', async () => { + const first = await touchWorkspaceRegistry(homeDir, '/tmp/AliasCheckFoo'); + const second = await touchWorkspaceRegistry(homeDir, '/tmp/aliascheckfoo'); + + expect(second.created).toBe(true); + expect(second.workspaceId).not.toBe(first.workspaceId); + }); }); diff --git a/packages/agent-core/test/session/store.test.ts b/packages/agent-core/test/session/store.test.ts index f2b776c11a..cb5991e57a 100644 --- a/packages/agent-core/test/session/store.test.ts +++ b/packages/agent-core/test/session/store.test.ts @@ -16,7 +16,7 @@ import { appendSessionIndexEntry, readSessionIndex, } from '../../src/session/store/session-index'; -import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; +import { encodeWorkDirKey, normalizeWorkDir, workspaceRootKey } from '../../src/session/store/workdir-key'; async function makeWorkDir(label: string): Promise { const root = await mkdtemp(join(tmpdir(), `kimi-store-wd-${label}-`)); @@ -274,3 +274,158 @@ describe('SessionStore', () => { }); }); }); + + +describe('workspaceRootKey', () => { + it('folds drive-letter Windows paths regardless of casing', () => { + expect(workspaceRootKey('C:\\Users\\Dev\\Project')).toBe('c:/users/dev/project'); + expect(workspaceRootKey('c:/Users/DEV/Project')).toBe('c:/users/dev/project'); + }); + + it('folds drive roots before separator stripping can mask the shape', () => { + // `C:\` would strip to `C:` and stop reading as Windows-shaped. + expect(workspaceRootKey('C:\\')).toBe('c:'); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:\\')); + expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:/')); + }); + + it('unifies slashes and strips trailing separators', () => { + expect(workspaceRootKey('C:\\Users\\Dev\\Project\\')).toBe('c:/users/dev/project'); + expect(workspaceRootKey('C:/Users/Dev/Project/')).toBe('c:/users/dev/project'); + }); + + it('folds UNC paths (both slash styles)', () => { + expect(workspaceRootKey('\\\\Host\\Share\\Dir')).toBe('//host/share/dir'); + expect(workspaceRootKey('//Host/Share/Dir')).toBe('//host/share/dir'); + }); + + it('never folds POSIX paths — case variants stay distinct', () => { + expect(workspaceRootKey('/Home/Dev/Project')).toBe('/Home/Dev/Project'); + expect(workspaceRootKey('/Home/Dev/Project')).not.toBe(workspaceRootKey('/home/dev/project')); + }); + + it('strips trailing separators on POSIX paths', () => { + expect(workspaceRootKey('/home/dev/project/')).toBe('/home/dev/project'); + }); +}); + +describe('SessionStore resolveWorkspaceId option', () => { + let homeDir: string; + let store: SessionStore; + const tempRoots: string[] = []; + const registeredId = 'wd_registered_0123456789ab'; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-store-home-')); + store = new SessionStore(homeDir); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }); + } + tempRoots.length = 0; + }); + + async function trackWorkDir(label: string): Promise { + const wd = await makeWorkDir(label); + tempRoots.push(wd); + return wd; + } + + function storeResolving(mapping: Record): SessionStore { + return new SessionStore(homeDir, { + resolveWorkspaceId: async (workDir) => mapping[workDir], + }); + } + + it('creates the session in the registry-resolved bucket instead of minting a new one', async () => { + const workDir = await trackWorkDir('resolved'); + const resolved = storeResolving({ [workDir]: registeredId }); + const summary = await resolved.create({ id: 'sess_resolved', workDir }); + + expect(summary.sessionDir).toBe(join(homeDir, 'sessions', registeredId, 'sess_resolved')); + // Listing by workDir reads the same resolved bucket, so the session is + // visible through the registered workspace's id. + const listed = await resolved.list({ workDir }); + expect(listed.map((s) => s.id)).toEqual(['sess_resolved']); + }); + + it('forks into the registry-resolved bucket of the source session', async () => { + const workDir = await trackWorkDir('resolved-fork'); + const resolved = storeResolving({ [workDir]: registeredId }); + await resolved.create({ id: 'sess_src', workDir }); + // store.create only mkdirs the session dir; fork copies state.json. + await writeFile( + join(homeDir, 'sessions', registeredId, 'sess_src', 'state.json'), + JSON.stringify({ workDir }), + 'utf-8', + ); + + const forked = await resolved.fork({ sourceId: 'sess_src', targetId: 'sess_fork' }); + + expect(forked.sessionDir).toBe(join(homeDir, 'sessions', registeredId, 'sess_fork')); + }); + + it('finds a session by (sessionId, workDir) inside the resolved bucket', async () => { + const workDir = await trackWorkDir('resolved-lookup'); + const resolved = storeResolving({ [workDir]: registeredId }); + await resolved.create({ id: 'sess_lookup', workDir }); + + const listed = await resolved.list({ workDir, sessionId: 'sess_lookup' }); + expect(listed.map((s) => s.id)).toEqual(['sess_lookup']); + }); + + it('mints the canonical bucket when the resolver has no match', async () => { + const workDir = await trackWorkDir('unresolved'); + const resolved = storeResolving({}); + const summary = await resolved.create({ id: 'sess_minted', workDir }); + + expect(summary.sessionDir).toBe( + join(homeDir, 'sessions', encodeWorkDirKey(workDir), 'sess_minted'), + ); + }); + + it('ignores a resolver id that is not a safe bucket name', async () => { + const workDir = await trackWorkDir('unsafe'); + const resolved = new SessionStore(homeDir, { + resolveWorkspaceId: async () => '../../outside', + }); + const summary = await resolved.create({ id: 'sess_unsafe', workDir }); + + expect(summary.sessionDir).toBe( + join(homeDir, 'sessions', encodeWorkDirKey(workDir), 'sess_unsafe'), + ); + }); + + it('falls back to minting when the resolver throws', async () => { + const workDir = await trackWorkDir('throwing'); + const resolved = new SessionStore(homeDir, { + resolveWorkspaceId: async () => { + throw new Error('registry unavailable'); + }, + }); + const summary = await resolved.create({ id: 'sess_throw', workDir }); + + expect(summary.sessionDir).toBe( + join(homeDir, 'sessions', encodeWorkDirKey(workDir), 'sess_throw'), + ); + }); + + it('reindex recovers a session that lives in the registry-resolved bucket', async () => { + const workDir = await trackWorkDir('reindex-resolved'); + const resolved = storeResolving({ [workDir]: registeredId }); + // Physically seeded in the registered bucket with no index entry — how + // sessions created through a wired resolver sit on disk. + const sessionId = 'sess_resolved_reindex'; + const dir = join(homeDir, 'sessions', registeredId, sessionId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'state.json'), JSON.stringify({ workDir }), 'utf-8'); + + const stats = await resolved.reindex(); + + expect(stats).toEqual({ scanned: 1, added: 1, repaired: 0 }); + expect((await resolved.list({})).map((s) => s.id)).toEqual([sessionId]); + }); +}); diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 03e4a3ce81..e47a0f7637 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -163,8 +163,10 @@ const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; // not implement `cursor`, so we page over its recency-sorted result); `status` // filters the projected page (post-page, matching v1). `include_archive` → // `includeArchived`; `archived_only` forces `includeArchived` and then keeps -// only archived sessions; `workspace_id` → `workspaceId`; `exclude_empty` drops -// sessions with no prompt. +// only archived sessions; `workspace_id` → `workspaceIds` after +// `resolveAliasIds` expands the alias set of the directory (legacy split +// buckets list as one workspace); `exclude_empty` drops sessions with no +// prompt. const sessionsListQueryCoercion = z .object({ before_id: z.string().min(1).optional(), @@ -360,8 +362,10 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const roots = new Map(workspaces.map((w) => [w.id, w.root])); // v1 resolves `workspace_id` to its root and 40410s when it is unknown; - // the index filters by `workspaceId` directly, so only the existence - // check is needed here (the root itself is not used by the query). + // the existence check stays on the listed (root-deduped) registry so an + // unknown id fails byte-identically, and only then is a known id + // expanded to every id spelling of the same directory — legacy split + // buckets (casing/slash variants) list as one workspace. if (raw.workspace_id !== undefined && !roots.has(raw.workspace_id)) { reply.send( errEnvelope( @@ -376,10 +380,14 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // `FileSessionIndex` does not implement `cursor` (gap G5 closed here), so // we fetch the full recency-sorted set (no `limit`) and apply the id // cursor in this handler. `list()` already orders by `updatedAt` desc and - // filters by workspace / archived. `archived_only` forces archived rows - // into the set, then the filter below keeps only them. + // filters across the workspace-id set / archived. `archived_only` forces + // archived rows into the set, then the filter below keeps only them. + const workspaceIds = + raw.workspace_id === undefined + ? undefined + : await core.accessor.get(IWorkspaceRegistry).resolveAliasIds(raw.workspace_id); const page = await core.accessor.get(ISessionIndex).list({ - workspaceId: raw.workspace_id, + workspaceIds, includeArchived: archivedOnly ? true : raw.include_archive, }); diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index 27b294a19b..dd8341cf82 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -16,7 +16,9 @@ * projects the v2 record onto the v1 shape, deriving the extra fields: * - `created_at` / `last_opened_at` — from the registry's in-memory * timestamps (reset on restart; the registry is still a skeleton). - * - `session_count` — count of persisted sessions for the workspace. + * - `session_count` — count of persisted sessions for the workspace, summed + * across every id spelling of the same root (`resolveAliasIds`) so legacy + * split buckets count once for the workspace, not per bucket. */ import { @@ -232,9 +234,12 @@ async function toWireWorkspace(core: Scope, ws: Workspace): Promise { + // One set-query over the alias set (legacy split buckets): a single merged + // listing cannot double-count, and a singleton set behaves exactly as before. + const workspaceIds = await core.accessor.get(IWorkspaceRegistry).resolveAliasIds(workspaceId); const page = await core.accessor .get(ISessionIndex) - .list({ workspaceId, includeArchived: true }); + .list({ workspaceIds, includeArchived: true }); return page.items.length; } diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index f91197a360..3e726de7d5 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -252,7 +252,7 @@ describe('server-v2 /api/v2 RPC', () => { const { body } = await call( 'POST', rpc('core', ISessionIndex, 'countActive'), - created.body.data.id, + [[created.body.data.id]], ); expect(body.code).toBe(0); expect(body.data).toBeGreaterThanOrEqual(1); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index f2ab43bdd2..2447ea611a 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -5,7 +5,7 @@ * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/sessions.test.ts`. */ import { randomBytes } from 'node:crypto'; -import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { inflateRawSync } from 'node:zlib'; @@ -24,6 +24,7 @@ import { type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { type RunningServer, startServer } from '../src/start'; import { authHeaders } from './helpers/auth'; @@ -821,6 +822,71 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.code).toBe(40410); }); + it('lists the union of legacy split buckets for one workspace, in recency order', async () => { + // Legacy pre-fold data: one physical directory registered under two + // spelling variants, with sessions bucketed per minted id. + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedId = encodeWorkDirKey(typedRoot); + const lowerId = encodeWorkDirKey(lowerRoot); + await writeFile( + join(home as string, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + [lowerId]: { + root: lowerRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + const seedBucket = async (wsId: string, sid: string, updatedAt: number): Promise => { + const dir = join(home as string, 'sessions', wsId, sid); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'state.json'), + JSON.stringify({ version: 2, cwd: typedRoot, createdAt: 1, updatedAt }), + 'utf8', + ); + }; + await seedBucket(typedId, 's-typed', 50); + await seedBucket(lowerId, 's-lower', 60); + + // The registry merges the two entries; whichever id survives is the + // representative the client lists by. + const workspaces = await getJson<{ items: { id: string }[] }>('/api/v1/workspaces'); + const rep = workspaces.body.data.items[0]?.id as string; + expect([typedId, lowerId]).toContain(rep); + + const listed = await getJson( + `/api/v1/sessions?workspace_id=${encodeURIComponent(rep)}`, + ); + expect(listed.body.code).toBe(0); + expect(listed.body.data.items.map((s) => s.id)).toEqual(['s-lower', 's-typed']); + + // Id-cursor pagination spans the bucket boundary without repeats. + const page1 = await getJson( + `/api/v1/sessions?workspace_id=${encodeURIComponent(rep)}&page_size=1`, + ); + expect(page1.body.data.items.map((s) => s.id)).toEqual(['s-lower']); + expect(page1.body.data.has_more).toBe(true); + const page2 = await getJson( + `/api/v1/sessions?workspace_id=${encodeURIComponent(rep)}&page_size=1&before_id=s-lower`, + ); + expect(page2.body.data.items.map((s) => s.id)).toEqual(['s-typed']); + expect(page2.body.data.has_more).toBe(false); + }); + it('filters listed sessions by the busy query (post-page, like v1)', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/workspaces.test.ts b/packages/kap-server/test/workspaces.test.ts index 5cc6026fb7..bbaf4eca68 100644 --- a/packages/kap-server/test/workspaces.test.ts +++ b/packages/kap-server/test/workspaces.test.ts @@ -1,9 +1,11 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; + import { type RunningServer, startServer } from '../src/start'; import { authHeaders } from './helpers/auth'; @@ -192,4 +194,57 @@ describe('server-v2 /api/v1/workspaces', () => { const ws = body.data.items.find((w) => w.id === created.body.data.id); expect(ws?.session_count).toBe(1); }); + + it('sums session_count across legacy split buckets of one root', async () => { + // Legacy pre-fold data: one physical directory registered under two + // spelling variants, with sessions bucketed per minted id. + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedId = encodeWorkDirKey(typedRoot); + const lowerId = encodeWorkDirKey(lowerRoot); + await writeFile( + join(home as string, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + [lowerId]: { + root: lowerRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + const seedBucket = async ( + wsId: string, + sid: string, + meta: Record, + ): Promise => { + const dir = join(home as string, 'sessions', wsId, sid); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'state.json'), + JSON.stringify({ version: 2, cwd: typedRoot, createdAt: 1, updatedAt: 1, ...meta }), + 'utf8', + ); + }; + await seedBucket(typedId, 's-typed', {}); + // Archived sessions count too (the wire counts every persisted session). + await seedBucket(lowerId, 's-lower', { archived: true, updatedAt: 2 }); + + // The catalog dedupes to one workspace whose count covers both buckets. + const { body } = await getJson('/api/v1/workspaces'); + expect(body.code).toBe(0); + expect(body.data.items).toHaveLength(1); + expect([typedId, lowerId]).toContain(body.data.items[0]?.id); + expect(body.data.items[0]?.session_count).toBe(2); + }); }); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 3e389bdafe..27b6b6cca7 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -21,7 +21,7 @@ export const sessionSummarySchema = z.object({ }); export const sessionListQuerySchema = z.object({ - workspaceId: z.string().optional(), + workspaceIds: z.array(z.string()).optional(), sessionId: z.string().optional(), includeArchived: z.boolean().optional(), cursor: z.string().optional(), @@ -32,5 +32,5 @@ export const sessionListQuerySchema = z.object({ export const sessionsContract = { list: { input: z.tuple([sessionListQuerySchema]), output: pageOf(sessionSummarySchema) }, get: { input: z.tuple([z.string()]), output: maybe(sessionSummarySchema) }, - countActive: { input: z.tuple([z.string()]), output: z.number() }, + countActive: { input: z.tuple([z.array(z.string())]), output: z.number() }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 6c29bb0f8a..8c39886119 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -88,7 +88,7 @@ export type ConfigTargetLiteral = `${ConfigTarget}`; export interface GlobalSessionsFacade { list(query: SessionListQuery): Promise>; get(id: string): Promise; - countActive(workspaceId: string): Promise; + countActive(workspaceIds: readonly string[]): Promise; /** * Create a session rooted at `workDir` (the workspace is registered * implicitly), optionally titled. Returns the persisted metadata. No agent @@ -256,8 +256,8 @@ export function createGlobalFacade(scoped: ScopedCaller): GlobalFacade { sessions: { list: (query) => call('sessionIndex', 'list', [query]) as Promise>, get: (id) => call('sessionIndex', 'get', [id]) as Promise, - countActive: (workspaceId) => - call('sessionIndex', 'countActive', [workspaceId]) as Promise, + countActive: (workspaceIds) => + call('sessionIndex', 'countActive', [workspaceIds]) as Promise, create: async ({ workDir, additionalDirs, title }) => { const handle = (await scoped({}, 'sessionLifecycleService', 'create', [ { workDir, additionalDirs }, diff --git a/packages/klient/test/e2e/dual/05-workspace.test.ts b/packages/klient/test/e2e/dual/05-workspace.test.ts index 5343622555..26ea47b968 100644 --- a/packages/klient/test/e2e/dual/05-workspace.test.ts +++ b/packages/klient/test/e2e/dual/05-workspace.test.ts @@ -26,7 +26,7 @@ defineDualSuite('workspace', {}, ({ klient }) => { const session = await k.global.sessions.create({ workDir: ws.root }); expect(session.cwd).toBe(ws.root); - const listed = await k.global.sessions.list({ workspaceId: ws.id }); + const listed = await k.global.sessions.list({ workspaceIds: [ws.id] }); expect(listed.items.some((s) => s.id === session.id)).toBe(true); await k.global.workspaces.delete(ws.id); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index a2d8d8d737..718a80f27b 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -68,7 +68,7 @@ export function defineKlientConformance( it('sessions index responds with a page shape', async () => { const page = await target.klient.global.sessions.list({}); expect(Array.isArray(page.items)).toBe(true); - const count = await target.klient.global.sessions.countActive('no-such-workspace'); + const count = await target.klient.global.sessions.countActive(['no-such-workspace']); expect(typeof count).toBe('number'); }); diff --git a/packages/klient/test/wsSocket.test.ts b/packages/klient/test/wsSocket.test.ts index 3de5f980a6..382074905c 100644 --- a/packages/klient/test/wsSocket.test.ts +++ b/packages/klient/test/wsSocket.test.ts @@ -166,7 +166,7 @@ describe('WsSocket', () => { const server = new FakeServer(); const socket = await openSocket(server); - const core = await socket.call('core', 'sessionIndex', 'list', [{ workspaceId: 'w1' }]); + const core = await socket.call('core', 'sessionIndex', 'list', [{ workspaceIds: ['w1'] }]); const agent = await socket.call('agent', 'sessionMetadata', 'read', undefined, { sessionId: 's1', agentId: 'a1', @@ -292,7 +292,7 @@ describe('WsSocket', () => { socket.listen('agent', 'events', { sessionId: 's1', agentId: 'a1' }, (data) => seen.push(data)); await tick(5); - const inFlight = socket.call('core', 'sessionIndex', 'countActive', ['w1']); + const inFlight = socket.call('core', 'sessionIndex', 'countActive', [['w1']]); server.drop(); await expect(inFlight).rejects.toThrow('ws closed'); expect(socket.currentState).toBe('connecting'); @@ -307,7 +307,7 @@ describe('WsSocket', () => { await tick(5); expect(seen).toEqual([{ type: 'turn.started' }]); - const data = await socket.call('core', 'sessionIndex', 'countActive', ['w1']); + const data = await socket.call('core', 'sessionIndex', 'countActive', [['w1']]); expect(data).toMatchObject({ method: 'countActive' }); expect(states).toContain('connecting'); socket.close();