From 73ad361e91948b03697b152c6bb79e9dcbc34cc8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 17:24:55 +0800 Subject: [PATCH] fix(workspace): sync workspace catalog across v1/v2 and soft-delete - register the cwd in workspaces.json when a v1 (TUI/SDK) session is created, via a shared workspaceRegistryFile read/write/touch module - merge session_index.jsonl into the catalog once at v2 startup (awaited during kap-server boot), adding only paths absent from workspaces.json - make v2 workspace delete a soft delete through the v1-compatible deleted_workspace_ids field: the session-index merge never resurrects tombstoned ids, while an explicit createOrTouch clears the tombstone --- .changeset/workspace-catalog-sync.md | 5 + .../fileWorkspacePersistence.ts | 27 ++- .../workspaceRegistry/workspacePersistence.ts | 27 ++- .../workspaceRegistryService.ts | 129 ++++++++++---- .../workspaceRegistryService.test.ts | 112 +++++++++++- packages/agent-core/src/rpc/core-impl.ts | 7 + packages/agent-core/src/services/AGENTS.md | 2 +- .../src/services/workspace/index.ts | 1 + .../workspace/workspaceRegistryFile.ts | 161 ++++++++++++++++++ .../workspace/workspaceRegistryService.ts | 103 ++--------- .../test/services/workspace-registry.test.ts | 97 ++++++++++- packages/kap-server/src/start.ts | 15 ++ 12 files changed, 539 insertions(+), 147 deletions(-) create mode 100644 .changeset/workspace-catalog-sync.md create mode 100644 packages/agent-core/src/services/workspace/workspaceRegistryFile.ts diff --git a/.changeset/workspace-catalog-sync.md b/.changeset/workspace-catalog-sync.md new file mode 100644 index 0000000000..eac3819a8c --- /dev/null +++ b/.changeset/workspace-catalog-sync.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart. diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts index dfa05670b9..7f04827d06 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts @@ -4,7 +4,10 @@ * File backend of `IWorkspacePersistence`. Persists the catalog as a single * v1-compatible `workspaces.json` document at the storage root * (`/workspaces.json`, via `scope = ''`) through the - * `IAtomicDocumentStore` access-pattern Store. Bound at App scope. + * `IAtomicDocumentStore` access-pattern Store. The `deleted_workspace_ids` + * tombstone list round-trips with the catalog so soft deletions survive + * regardless of which engine (v1 or v2) last wrote the file. Bound at App + * scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -16,6 +19,7 @@ import { IWorkspacePersistence, type PersistedWorkspaceEntry, type PersistedWorkspaceFile, + type WorkspaceCatalog, } from './workspacePersistence'; const WORKSPACE_REGISTRY_VERSION = 1; @@ -27,7 +31,7 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {} - async load(): Promise { + async load(): Promise { const file = await this.docs.get( WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, @@ -42,11 +46,11 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { return undefined; } const now = Date.now(); - const result: Workspace[] = []; + const workspaces: Workspace[] = []; for (const [id, raw] of Object.entries(file.workspaces)) { - const entry = sanitizeEntry(raw, now); + const entry = sanitizeEntry(raw); if (entry === null) continue; - result.push({ + workspaces.push({ id, root: entry.root, name: entry.name, @@ -54,12 +58,16 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { lastOpenedAt: parseTime(entry.last_opened_at, now), }); } - return result; + const rawDeleted = (file as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const deletedIds = Array.isArray(rawDeleted) + ? rawDeleted.filter((id): id is string => typeof id === 'string') + : []; + return { workspaces, deletedIds }; } - async save(workspaces: readonly Workspace[]): Promise { + async save(catalog: WorkspaceCatalog): Promise { const record: Record = {}; - for (const ws of workspaces) { + for (const ws of catalog.workspaces) { record[ws.id] = { root: ws.root, name: ws.name, @@ -70,12 +78,13 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { const file: PersistedWorkspaceFile = { version: WORKSPACE_REGISTRY_VERSION, workspaces: record, + deleted_workspace_ids: [...catalog.deletedIds], }; await this.docs.set(WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, file); } } -function sanitizeEntry(value: unknown, _now: number): PersistedWorkspaceEntry | null { +function sanitizeEntry(value: unknown): PersistedWorkspaceEntry | null { if (typeof value !== 'object' || value === null) return null; const v = value as Partial; if ( diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 8a06729a54..46ae19c201 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -3,13 +3,20 @@ * * Domain-specific persistence Store for the known-workspaces catalog. It hides * the on-disk document layout (`/workspaces.json`, the v1-compatible - * `{ version, workspaces: { [id]: entry } }` shape) and its serialization - * concerns (ISO ↔ epoch-ms, record ↔ array) from the registry. The generic - * `IAtomicDocumentStore` it builds on stays schema-agnostic. + * `{ version, workspaces: { [id]: entry }, deleted_workspace_ids: string[] }` + * shape — shared with agent-core, which reads and writes the same file) and + * its serialization concerns (ISO ↔ epoch-ms, record ↔ array) from the + * registry. The generic `IAtomicDocumentStore` it builds on stays + * schema-agnostic. + * + * `deleted_workspace_ids` is the soft-delete tombstone list: ids the user + * explicitly removed. Tombstoned entries are absent from `workspaces`, but + * their ids must survive load/save round-trips so the session-index merge + * never resurrects them. * * `load()` returns `undefined` to mean "no usable catalog" so the registry can - * trigger a one-shot rebuild from the legacy session index; an empty array is - * a valid, already-materialized catalog and must NOT trigger a rebuild. + * trigger a one-shot rebuild from the legacy session index; an empty catalog + * is a valid, already-materialized state and must NOT trigger a rebuild. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -26,13 +33,19 @@ export interface PersistedWorkspaceEntry { export interface PersistedWorkspaceFile { readonly version: number; readonly workspaces: Record; + readonly deleted_workspace_ids: string[]; +} + +export interface WorkspaceCatalog { + readonly workspaces: readonly Workspace[]; + readonly deletedIds: readonly string[]; } export interface IWorkspacePersistence { readonly _serviceBrand: undefined; - load(): Promise; - save(workspaces: readonly Workspace[]): Promise; + load(): Promise; + save(catalog: WorkspaceCatalog): Promise; } export const IWorkspacePersistence: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 3b45b82e7b..9baaebdb98 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -1,20 +1,32 @@ /** * `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation. * - * Process-wide catalog of known workspaces, now durable: an in-memory cache - * is loaded once from `IWorkspacePersistence` (`/workspaces.json`, v1 - * compatible) and every mutation writes back through it. When the catalog is - * absent or malformed, it is rebuilt once from the legacy - * `/session_index.jsonl` (one workspace per distinct absolute - * `workDir`) and then persisted. All access is serialized through a - * promise-chain mutex so load/rebuild/mutations never race. + * Process-wide catalog of known workspaces, durable: an in-memory cache is + * loaded once from `IWorkspacePersistence` (`/workspaces.json`, the + * v1-compatible file shared with agent-core) and every mutation writes back + * through it. Loading has two paths: + * + * 1. No usable catalog file → one-shot rebuild from the legacy + * `/session_index.jsonl` (one workspace per distinct absolute + * `workDir`), then persisted. + * 2. Catalog loaded → a one-time merge from the same session index adds every + * workDir the file does not know about yet (e.g. sessions created by the + * v1 TUI since the last merge), then persisted if anything changed. + * + * Deletion is soft: `delete` drops the entry but records the id in + * `deleted_workspace_ids`, and the merge never resurrects a tombstoned id. + * An explicit `createOrTouch` clears the tombstone — the user opening the + * folder again is a stronger signal than the historical index. + * + * All access is serialized through a promise-chain mutex so + * load/rebuild/merge/mutations never race. * * `createOrTouch` is the single choke point every workspace/session creation * funnels through, so it owns the root-existence contract: the root must be * an existing directory on the host filesystem, otherwise it throws * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The rebuild - * path bypasses the check on purpose — it catalogs where sessions *were*, not - * where new ones may open. Bound at App scope. + * and merge paths bypass the check on purpose — they catalog where sessions + * *were*, not where new ones may open. Bound at App scope. */ import { basename, isAbsolute } from 'pathe'; @@ -44,6 +56,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { declare readonly _serviceBrand: undefined; private cache: Map | undefined; + private deletedIds: Set | undefined; private opQueue: Promise = Promise.resolve(); constructor( @@ -96,7 +109,9 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { lastOpenedAt: now, }; cache.set(id, ws); - await this.store.save([...cache.values()]); + // An explicit add clears any prior deletion tombstone. + this.deletedIds?.delete(id); + await this.persist(); return ws; }); } @@ -111,7 +126,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { ...(patch.name !== undefined ? { name: patch.name } : {}), }; cache.set(id, updated); - await this.store.save([...cache.values()]); + await this.persist(); return updated; }); } @@ -120,40 +135,67 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return this.runExclusive(async () => { const cache = await this.ensureLoaded(); cache.delete(id); - await this.store.save([...cache.values()]); + // Soft delete: tombstone the id so the session-index merge cannot + // resurrect it, even if sessions still reference the workDir. + this.deletedIds?.add(id); + await this.persist(); }); } private async ensureLoaded(): Promise> { if (this.cache !== undefined) return this.cache; const loaded = await this.store.load(); - if (loaded !== undefined) { - this.cache = new Map(loaded.map((ws) => [ws.id, ws])); - return this.cache; + if (loaded === undefined) { + const rebuilt = await this.rebuildFromSessionIndex(); + this.cache = rebuilt; + this.deletedIds = new Set(); + await this.persist(); + return rebuilt; } - const rebuilt = await this.rebuildFromSessionIndex(); - this.cache = rebuilt; - await this.store.save([...rebuilt.values()]); - return this.cache; + const cache = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); + const deletedIds = new Set(loaded.deletedIds); + this.cache = cache; + this.deletedIds = deletedIds; + if (await this.mergeFromSessionIndex(cache, deletedIds)) { + await this.persist(); + } + return cache; + } + + /** Add every distinct workDir from the legacy session index that the + * catalog does not know about yet. Tombstoned ids are skipped, so a + * soft-deleted workspace stays deleted. Returns whether anything changed. */ + private async mergeFromSessionIndex( + cache: Map, + deletedIds: ReadonlySet, + ): Promise { + let changed = false; + const now = Date.now(); + for (const workDir of await this.readSessionIndexWorkDirs()) { + const id = encodeWorkDirKey(workDir); + if (cache.has(id) || deletedIds.has(id)) continue; + cache.set(id, { + id, + root: workDir, + name: basename(workDir), + createdAt: now, + lastOpenedAt: now, + }); + changed = true; + } + return changed; } private async rebuildFromSessionIndex(): Promise> { const result = new Map(); - const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); - if (bytes === undefined) return result; const now = Date.now(); - 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; - if (!isAbsolute(entry.workDir)) continue; - const id = encodeWorkDirKey(entry.workDir); + for (const workDir of await this.readSessionIndexWorkDirs()) { + const id = encodeWorkDirKey(workDir); if (result.has(id)) continue; result.set(id, { id, - root: entry.workDir, - name: basename(entry.workDir), + root: workDir, + name: basename(workDir), createdAt: now, lastOpenedAt: now, }); @@ -161,6 +203,33 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return result; } + private async readSessionIndexWorkDirs(): Promise { + const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + if (bytes === undefined) return []; + const workDirs: string[] = []; + 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; + if (!isAbsolute(entry.workDir)) continue; + workDirs.push(entry.workDir); + } + return workDirs; + } + + private async persist(): Promise { + const cache = this.cache; + const deletedIds = this.deletedIds; + if (cache === undefined || deletedIds === undefined) { + throw new Error('workspace registry mutated before load completed'); + } + await this.store.save({ + workspaces: [...cache.values()], + deletedIds: [...deletedIds], + }); + } + private runExclusive(op: () => Promise): Promise { const next = this.opQueue.then(op, op); this.opQueue = next.then( 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 57dbbe0c1f..8ca191850b 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -83,14 +83,25 @@ describe('WorkspaceRegistryService (file-backed)', () => { async function writeWorkspacesJson( workspaces: Record, + extra?: { readonly deleted_workspace_ids?: unknown }, ): Promise { await fsp.writeFile( join(homeDir, 'workspaces.json'), - JSON.stringify({ version: 1, workspaces }), + JSON.stringify({ version: 1, workspaces, ...extra }), 'utf8', ); } + async function readWorkspacesJson(): Promise<{ + workspaces: Record; + deleted_workspace_ids?: unknown; + }> { + return JSON.parse(await fsp.readFile(join(homeDir, 'workspaces.json'), 'utf8')) as { + workspaces: Record; + deleted_workspace_ids?: unknown; + }; + } + it('persists the catalog across registry instances', async () => { const created = await build().createOrTouch(homeDir, 'proj'); @@ -137,8 +148,9 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(await build().list()).toEqual([]); }); - it('prefers an existing workspaces.json over the session index', async () => { + it('merges session-index workDirs into an existing catalog on load', async () => { const work = join(homeDir, 'existing'); + const fromIndex = join(homeDir, 'from-index'); await writeWorkspacesJson({ [encodeWorkDirKey(work)]: { root: work, @@ -150,14 +162,102 @@ describe('WorkspaceRegistryService (file-backed)', () => { await seedSessionIndex([ { sessionId: 's9', - sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(join(homeDir, 'from-index')), 's9'), - workDir: join(homeDir, 'from-index'), + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(fromIndex), 's9'), + workDir: fromIndex, }, ]); const list = await build().list(); - expect(list.map((w) => w.id)).toEqual([encodeWorkDirKey(work)]); - expect(list[0]?.name).toBe('existing'); + expect(list.map((w) => w.id).toSorted()).toEqual( + [encodeWorkDirKey(work), encodeWorkDirKey(fromIndex)].toSorted(), + ); + const existing = list.find((w) => w.id === encodeWorkDirKey(work)); + // The registered entry keeps its persisted data; the merged entry only + // gets a basename-derived name. + expect(existing?.name).toBe('existing'); + expect(existing?.lastOpenedAt).toBe(Date.parse('2024-01-02T00:00:00.000Z')); + expect(list.find((w) => w.id === encodeWorkDirKey(fromIndex))?.name).toBe('from-index'); + + // The merge is persisted, so a restart sees the same catalog. + expect((await restart().list()).map((w) => w.id).toSorted()).toEqual( + list.map((w) => w.id).toSorted(), + ); + }); + + it('merge skips tombstoned ids and tolerates a dirty deleted_workspace_ids field', async () => { + const work = join(homeDir, 'existing'); + const deleted = join(homeDir, 'deleted'); + const fresh = join(homeDir, 'fresh'); + await writeWorkspacesJson( + { + [encodeWorkDirKey(work)]: { + root: work, + name: 'existing', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-02T00:00:00.000Z', + }, + }, + { deleted_workspace_ids: [encodeWorkDirKey(deleted), 42, null] }, + ); + await seedSessionIndex([ + { + sessionId: 's1', + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(deleted), 's1'), + workDir: deleted, + }, + { + sessionId: 's2', + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(fresh), 's2'), + workDir: fresh, + }, + ]); + + const list = await build().list(); + expect(list.map((w) => w.id).toSorted()).toEqual( + [encodeWorkDirKey(work), encodeWorkDirKey(fresh)].toSorted(), + ); + }); + + it('delete tombstones the id and the merge never resurrects it', async () => { + const dirA = join(homeDir, 'dir-a'); + const dirB = join(homeDir, 'dir-b'); + await fsp.mkdir(dirA); + await fsp.mkdir(dirB); + const registry = build(); + const a = await registry.createOrTouch(dirA); + await registry.createOrTouch(dirB); + + await registry.delete(a.id); + expect((await registry.list()).map((w) => w.id)).toEqual([encodeWorkDirKey(dirB)]); + + // The tombstone is on disk in the v1-compatible field. + const onDisk = await readWorkspacesJson(); + expect(onDisk.deleted_workspace_ids).toEqual([a.id]); + expect(onDisk.workspaces[a.id]).toBeUndefined(); + + // Sessions referencing the deleted workDir must not resurrect it. + await seedSessionIndex([ + { + sessionId: 's1', + sessionDir: join(homeDir, 'sessions', a.id, 's1'), + workDir: dirA, + }, + ]); + expect((await restart().list()).map((w) => w.id)).toEqual([encodeWorkDirKey(dirB)]); + }); + + it('createOrTouch clears the deletion tombstone', async () => { + const dirA = join(homeDir, 'dir-a'); + await fsp.mkdir(dirA); + const registry = build(); + const a = await registry.createOrTouch(dirA); + await registry.delete(a.id); + + await registry.createOrTouch(dirA); + expect((await registry.list()).map((w) => w.id)).toEqual([a.id]); + expect(await readWorkspacesJson().then((f) => f.deleted_workspace_ids)).toEqual([]); + + expect((await restart().list()).map((w) => w.id)).toEqual([a.id]); }); it('writes through on update and delete', async () => { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 2b12ad013d..36b67239a8 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -48,6 +48,7 @@ import { } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; +import { touchWorkspaceRegistry } from '../services/workspace/workspaceRegistryFile'; import { noopTelemetryClient, withTelemetryContext, @@ -268,6 +269,12 @@ export class KimiCore implements PromisableMethods { id, workDir, }); + // Register the cwd in the shared workspaces catalog (`/workspaces.json`, + // also read by the agent-core-v2 server) so TUI-created sessions surface as + // workspaces. Best-effort: the catalog is a hint, never session state. + await touchWorkspaceRegistry(this.homeDir, workDir).catch((error: unknown) => { + log.warn('workspace registry touch failed', { workDir, error: String(error) }); + }); const result: SessionSummary = { ...summary, metadata: options.metadata, diff --git a/packages/agent-core/src/services/AGENTS.md b/packages/agent-core/src/services/AGENTS.md index f6e9730d2d..8f6d7c79ff 100644 --- a/packages/agent-core/src/services/AGENTS.md +++ b/packages/agent-core/src/services/AGENTS.md @@ -96,7 +96,7 @@ no new suffixes get reintroduced. | `logger/` | `logger.ts` | (adapter lives in server) | `ILogService` | | `fileStore/` | `fileStore.ts` | `fileStoreService.ts` | `IFileStore` | | `fs/` | `fs.ts`, `fsSearch.ts`, `fsGit.ts`, `fsWatcher.ts`, `fsPathSafety.ts` | `fsService.ts`, `fsSearchService.ts`, `fsGitService.ts`, `fsWatcherService.ts` | `IFsService`, `IFsSearchService`, `IFsGitService`, `IFsWatcher` | -| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | +| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceRegistryFile.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | | `config/` | `config.ts` | `configService.ts` | `IConfigService` | | `session/` | `session.ts` | `sessionService.ts` | `ISessionService` | | `message/` | `message.ts` | `messageService.ts` | `IMessageService` | diff --git a/packages/agent-core/src/services/workspace/index.ts b/packages/agent-core/src/services/workspace/index.ts index 0da7cfa797..39575e67a4 100644 --- a/packages/agent-core/src/services/workspace/index.ts +++ b/packages/agent-core/src/services/workspace/index.ts @@ -5,6 +5,7 @@ export { type WorkspacePatch, } from './workspaceRegistry'; export { WorkspaceRegistryService, detectGit } from './workspaceRegistryService'; +export { touchWorkspaceRegistry } from './workspaceRegistryFile'; export { IWorkspaceFsService, WorkspaceFsNotAbsoluteError, diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts b/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts new file mode 100644 index 0000000000..7b7164598a --- /dev/null +++ b/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts @@ -0,0 +1,161 @@ +/** + * `workspaces.json` file format and atomic access — the on-disk contract of + * the known-workspaces catalog, shared by `WorkspaceRegistryService` (which + * adds locking and events on top) and by in-process callers that only need a + * best-effort touch (e.g. `KimiCore` registering the cwd on session creation). + * + * The layout is the v1-compatible `{ version, workspaces, deleted_workspace_ids }` + * document at `/workspaces.json`; agent-core-v2 reads and writes the + * same file, so both engines must agree on this shape. + */ + +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'; + +const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; +const WORKSPACE_REGISTRY_VERSION = 1; + +export interface WorkspaceRegistryEntry { + root: string; + name: string; + created_at: string; + last_opened_at: string; +} + +export interface WorkspaceRegistryFile { + version: number; + workspaces: Record; + /** Workspace ids the user explicitly removed. Their session buckets stay on + * disk, so derived workspaces (computed from the session index) must skip + * them to keep deletion durable. */ + deleted_workspace_ids: string[]; +} + +/** Diagnostic hook for malformed-content warnings; `(context, message)`. */ +export type WorkspaceRegistryWarn = (context: object, message: string) => void; + +function emptyRegistryFile(): WorkspaceRegistryFile { + return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; +} + +/** Read `/workspaces.json`, tolerating a missing or malformed file + * (both yield an empty catalog). Unknown fields are ignored; entries failing + * sanitization are dropped. */ +export async function readWorkspaceRegistryFile( + homeDir: string, + warn?: WorkspaceRegistryWarn, +): Promise { + const registryPath = join(homeDir, WORKSPACE_REGISTRY_FILE); + let raw: string; + try { + raw = await fsp.readFile(registryPath, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return emptyRegistryFile(); + } + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + warn?.({ path: registryPath, err: String(err) }, 'workspaces.json malformed; treating as empty'); + return emptyRegistryFile(); + } + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' || + (parsed as { workspaces?: unknown }).workspaces === null + ) { + warn?.({ path: registryPath }, 'workspaces.json missing required keys; treating as empty'); + return emptyRegistryFile(); + } + const rawWorkspaces = (parsed as { workspaces: Record }).workspaces; + const workspaces: Record = {}; + for (const [id, value] of Object.entries(rawWorkspaces)) { + const entry = sanitizeWorkspaceRegistryEntry(value); + if (entry !== null) { + workspaces[id] = entry; + } + } + const version = + typeof (parsed as { version?: unknown }).version === 'number' + ? (parsed as { version: number }).version + : WORKSPACE_REGISTRY_VERSION; + const rawDeleted = (parsed as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const deleted_workspace_ids = Array.isArray(rawDeleted) + ? rawDeleted.filter((id): id is string => typeof id === 'string') + : []; + return { version, workspaces, deleted_workspace_ids }; +} + +/** Atomically write `/workspaces.json` (tmp file + rename). */ +export async function writeWorkspaceRegistryFile( + homeDir: string, + file: WorkspaceRegistryFile, +): Promise { + const registryPath = join(homeDir, WORKSPACE_REGISTRY_FILE); + await fsp.mkdir(dirname(registryPath), { recursive: true, mode: 0o700 }); + const tmp = `${registryPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8'); + await fsp.rename(tmp, registryPath); +} + +/** + * Best-effort read-modify-write: register `root` in `/workspaces.json` + * (or bump its `last_opened_at` when already present). An explicit touch clears + * any prior deletion tombstone for the workspace id. + * + * Unlike `WorkspaceRegistryService.createOrTouch` this performs no + * root-existence check and publishes no events; callers must treat failures as + * 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. + */ +export async function touchWorkspaceRegistry( + homeDir: string, + root: string, + name?: string, +): Promise<{ workspaceId: string; created: boolean }> { + const normalizedRoot = normalizeWorkDir(root); + const workspaceId = encodeWorkDirKey(normalizedRoot); + const now = new Date().toISOString(); + const file = await readWorkspaceRegistryFile(homeDir); + const existing = file.workspaces[workspaceId]; + file.workspaces[workspaceId] = + existing !== undefined + ? { ...existing, last_opened_at: now } + : { + root: normalizedRoot, + name: name ?? posixBasename(normalizedRoot), + created_at: now, + last_opened_at: now, + }; + file.deleted_workspace_ids = file.deleted_workspace_ids.filter((id) => id !== workspaceId); + await writeWorkspaceRegistryFile(homeDir, file); + return { workspaceId, created: existing === undefined }; +} + +function sanitizeWorkspaceRegistryEntry(value: unknown): WorkspaceRegistryEntry | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Partial; + if ( + typeof v.root !== 'string' || + typeof v.name !== 'string' || + typeof v.created_at !== 'string' || + typeof v.last_opened_at !== 'string' + ) { + return null; + } + return { + root: v.root, + name: v.name, + created_at: v.created_at, + last_opened_at: v.last_opened_at, + }; +} diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index aa5886c2a9..8913eb61d2 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -19,25 +19,12 @@ import { WorkspaceRootNotFoundError, type WorkspacePatch, } from './workspaceRegistry'; - -const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; -const WORKSPACE_REGISTRY_VERSION = 1; - -interface WorkspaceRegistryEntry { - root: string; - name: string; - created_at: string; - last_opened_at: string; -} - -interface WorkspaceRegistryFile { - version: number; - workspaces: Record; - /** Workspace ids the user explicitly removed. Their session buckets stay on - * disk, so derived workspaces (computed from the session index) must skip - * them to keep deletion durable. */ - deleted_workspace_ids: string[]; -} +import { + readWorkspaceRegistryFile, + writeWorkspaceRegistryFile, + type WorkspaceRegistryEntry, + type WorkspaceRegistryFile, +} from './workspaceRegistryFile'; type WorkspaceRegistryEvent = | { type: 'event.workspace.created'; workspace: Workspace } @@ -49,7 +36,6 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe private readonly homeDir: string; private readonly sessionsDir: string; - private readonly registryPath: string; private opQueue: Promise = Promise.resolve(); constructor( @@ -60,7 +46,6 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe super(); this.homeDir = env.homeDir; this.sessionsDir = join(env.homeDir, 'sessions'); - this.registryPath = join(env.homeDir, WORKSPACE_REGISTRY_FILE); } async list(): Promise { @@ -298,81 +283,13 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe } private async readRegistry(): Promise { - let raw: string; - try { - raw = await fsp.readFile(this.registryPath, 'utf8'); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - throw err; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - this.logger.warn( - { path: this.registryPath, err: String(err) }, - 'workspaces.json malformed; treating as empty', - ); - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - if ( - typeof parsed !== 'object' || - parsed === null || - typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' || - (parsed as { workspaces?: unknown }).workspaces === null - ) { - this.logger.warn( - { path: this.registryPath }, - 'workspaces.json missing required keys; treating as empty', - ); - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - const rawWorkspaces = (parsed as { workspaces: Record }).workspaces; - const workspaces: Record = {}; - for (const [id, value] of Object.entries(rawWorkspaces)) { - const entry = this.sanitizeEntry(value); - if (entry !== null) { - workspaces[id] = entry; - } - } - const version = - typeof (parsed as { version?: unknown }).version === 'number' - ? (parsed as { version: number }).version - : WORKSPACE_REGISTRY_VERSION; - const rawDeleted = (parsed as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; - const deleted_workspace_ids = Array.isArray(rawDeleted) - ? rawDeleted.filter((id): id is string => typeof id === 'string') - : []; - return { version, workspaces, deleted_workspace_ids }; - } - - private sanitizeEntry(value: unknown): WorkspaceRegistryEntry | null { - if (typeof value !== 'object' || value === null) return null; - const v = value as Partial; - if ( - typeof v.root !== 'string' || - typeof v.name !== 'string' || - typeof v.created_at !== 'string' || - typeof v.last_opened_at !== 'string' - ) { - return null; - } - return { - root: v.root, - name: v.name, - created_at: v.created_at, - last_opened_at: v.last_opened_at, - }; + return readWorkspaceRegistryFile(this.homeDir, (context, message) => + this.logger.warn(context, message), + ); } private async writeRegistry(file: WorkspaceRegistryFile): Promise { - await fsp.mkdir(dirname(this.registryPath), { recursive: true, mode: 0o700 }); - const tmp = `${this.registryPath}.tmp`; - await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8'); - await fsp.rename(tmp, this.registryPath); + await writeWorkspaceRegistryFile(this.homeDir, file); } private runExclusive(op: () => Promise): Promise { diff --git a/packages/agent-core/test/services/workspace-registry.test.ts b/packages/agent-core/test/services/workspace-registry.test.ts index ca618489ba..e2d974b09f 100644 --- a/packages/agent-core/test/services/workspace-registry.test.ts +++ b/packages/agent-core/test/services/workspace-registry.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +10,7 @@ import type { IEnvironmentService } from '../../src/services/environment/environ import type { IEventService } from '../../src/services/event/event'; import type { ILogService } from '../../src/services/logger/logger'; import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService'; +import { touchWorkspaceRegistry } from '../../src/services/workspace/workspaceRegistryFile'; import { appendSessionIndexEntry } from '../../src/session/store/session-index'; import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; @@ -255,3 +256,97 @@ describe('WorkspaceRegistryService', () => { expect(matches[0]?.session_count).toBe(1); }); }); + +describe('touchWorkspaceRegistry', () => { + let homeDir: string; + let tempRoots: string[] = []; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-ws-touch-home-')); + tempRoots = []; + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }); + } + }); + + async function makeProjectRoot(label: string): Promise { + const root = await mkdtemp(join(tmpdir(), `kimi-ws-touch-${label}-`)); + tempRoots.push(root); + return normalizeWorkDir(await realpath(root)); + } + + async function readRegistryFile(): Promise<{ + version: number; + workspaces: Record< + string, + { root: string; name: string; created_at: string; last_opened_at: string } + >; + deleted_workspace_ids: string[]; + }> { + return JSON.parse(await readFile(join(homeDir, 'workspaces.json'), 'utf-8')) as never; + } + + it('creates a new entry in workspaces.json', async () => { + const root = await makeProjectRoot('new'); + + const result = await touchWorkspaceRegistry(homeDir, root); + + expect(result.created).toBe(true); + expect(result.workspaceId).toBe(encodeWorkDirKey(root)); + const file = await readRegistryFile(); + const entry = file.workspaces[result.workspaceId]; + expect(entry).toBeDefined(); + expect(entry?.root).toBe(root); + expect(entry?.name).toBe(root.split('/').pop()); + expect(entry?.created_at).not.toBe(''); + expect(file.deleted_workspace_ids).toEqual([]); + }); + + it('touches an existing entry without resetting its name or created_at', async () => { + const root = await makeProjectRoot('touch'); + const first = await touchWorkspaceRegistry(homeDir, root, 'custom-name'); + const before = (await readRegistryFile()).workspaces[first.workspaceId]; + expect(before?.name).toBe('custom-name'); + + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = await touchWorkspaceRegistry(homeDir, root); + + expect(second.created).toBe(false); + const after = (await readRegistryFile()).workspaces[first.workspaceId]; + expect(after?.name).toBe('custom-name'); + expect(after?.created_at).toBe(before?.created_at); + expect(Date.parse(after?.last_opened_at ?? '')).toBeGreaterThan( + Date.parse(before?.last_opened_at ?? ''), + ); + }); + + it('clears the deletion tombstone for the touched workspace', async () => { + const root = await makeProjectRoot('tombstone'); + const workspaceId = encodeWorkDirKey(root); + await writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ version: 1, workspaces: {}, deleted_workspace_ids: [workspaceId] }), + 'utf-8', + ); + + await touchWorkspaceRegistry(homeDir, root); + + const file = await readRegistryFile(); + expect(file.deleted_workspace_ids).toEqual([]); + expect(file.workspaces[workspaceId]).toBeDefined(); + }); + + it('recovers from a malformed workspaces.json', async () => { + await writeFile(join(homeDir, 'workspaces.json'), '{ not json', 'utf-8'); + const root = await makeProjectRoot('malformed'); + + const result = await touchWorkspaceRegistry(homeDir, root); + + const file = await readRegistryFile(); + expect(file.workspaces[result.workspaceId]?.root).toBe(root); + }); +}); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 1284d0fa20..3fb940d8e2 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -12,6 +12,7 @@ import { hostRequestHeadersSeed, IConfigService, IModelCatalogService, + IWorkspaceRegistry, logSeed, MULTI_SERVER_FLAG_ENV, resolveConfigPath, @@ -255,6 +256,20 @@ export async function startServer(opts: ServerStartOptions = {}): Promise