Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workspace-catalog-sync.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
* File backend of `IWorkspacePersistence`. Persists the catalog as a single
* v1-compatible `workspaces.json` document at the storage root
* (`<homeDir>/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';
Expand All @@ -16,6 +19,7 @@ import {
IWorkspacePersistence,
type PersistedWorkspaceEntry,
type PersistedWorkspaceFile,
type WorkspaceCatalog,
} from './workspacePersistence';

const WORKSPACE_REGISTRY_VERSION = 1;
Expand All @@ -27,7 +31,7 @@ export class FileWorkspacePersistence implements IWorkspacePersistence {

constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {}

async load(): Promise<Workspace[] | undefined> {
async load(): Promise<WorkspaceCatalog | undefined> {
const file = await this.docs.get<PersistedWorkspaceFile>(
WORKSPACE_REGISTRY_SCOPE,
WORKSPACE_REGISTRY_KEY,
Expand All @@ -42,24 +46,28 @@ 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,
createdAt: parseTime(entry.created_at, now),
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<void> {
async save(catalog: WorkspaceCatalog): Promise<void> {
const record: Record<string, PersistedWorkspaceEntry> = {};
for (const ws of workspaces) {
for (const ws of catalog.workspaces) {
record[ws.id] = {
root: ws.root,
name: ws.name,
Expand All @@ -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<PersistedWorkspaceEntry>;
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
*
* Domain-specific persistence Store for the known-workspaces catalog. It hides
* the on-disk document layout (`<homeDir>/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';
Expand All @@ -26,13 +33,19 @@ export interface PersistedWorkspaceEntry {
export interface PersistedWorkspaceFile {
readonly version: number;
readonly workspaces: Record<string, PersistedWorkspaceEntry>;
readonly deleted_workspace_ids: string[];
}

export interface WorkspaceCatalog {
readonly workspaces: readonly Workspace[];
readonly deletedIds: readonly string[];
}

export interface IWorkspacePersistence {
readonly _serviceBrand: undefined;

load(): Promise<Workspace[] | undefined>;
save(workspaces: readonly Workspace[]): Promise<void>;
load(): Promise<WorkspaceCatalog | undefined>;
save(catalog: WorkspaceCatalog): Promise<void>;
}

export const IWorkspacePersistence: ServiceIdentifier<IWorkspacePersistence> =
Expand Down
Original file line number Diff line number Diff line change
@@ -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` (`<homeDir>/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
* `<homeDir>/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` (`<homeDir>/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
* `<homeDir>/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';
Expand Down Expand Up @@ -44,6 +56,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
declare readonly _serviceBrand: undefined;

private cache: Map<string, Workspace> | undefined;
private deletedIds: Set<string> | undefined;
private opQueue: Promise<unknown> = Promise.resolve();

constructor(
Expand Down Expand Up @@ -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;
});
}
Expand All @@ -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;
});
}
Expand All @@ -120,47 +135,101 @@ 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<Map<string, Workspace>> {
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<string, Workspace>,
deletedIds: ReadonlySet<string>,
): Promise<boolean> {
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<Map<string, Workspace>> {
const result = new Map<string, Workspace>();
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,
});
}
return result;
}

private async readSessionIndexWorkDirs(): Promise<readonly string[]> {
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<void> {
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],
});
Comment on lines +227 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge disk state before saving the cached catalog

When the v2 server has already loaded the registry, a v1 process can now update the same workspaces.json via the new touchWorkspaceRegistry path, but this save writes only the stale in-memory cache. In the scenario where v2 loads the catalog, the v1 TUI creates or reopens another workspace, and then v2 handles any create/update/delete, this overwrite drops the v1-added workspace or tombstone and breaks the cross-engine sync this change is meant to provide. Re-read and merge the current file (or otherwise coordinate writes) before persisting.

Useful? React with 👍 / 👎.

}

private runExclusive<T>(op: () => Promise<T>): Promise<T> {
const next = this.opQueue.then(op, op);
this.opQueue = next.then(
Expand Down
Loading
Loading