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
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
/**
* `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation.
*
* 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:
* Process-wide catalog of known workspaces, durable in
* `<homeDir>/workspaces.json` (the v1-compatible file shared with
* agent-core). The service keeps NO in-memory write cache: every operation
* is a fresh read-modify-write against the file, serialized through a
* promise-chain mutex. This is required, not just tidy — the same file is
* written concurrently by other processes (the v1 TUI registers session cwds
* via `touchWorkspaceRegistry`, which also re-reads the file on every call),
* so a write-through cache would clobber external additions and tombstones
* with stale state. Atomic renames at the persistence layer plus fresh
* read-modify-write on both engines shrink the lost-update window to a
* single read-modify-write, and the next session-index merge heals anything
* still lost there.
*
* 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.
* Once per process, the first operation triggers the startup sync with the
* legacy `<homeDir>/session_index.jsonl`:
*
* 1. No usable catalog file → one-shot rebuild (one workspace per distinct
* absolute `workDir`), persisted.
* 2. Catalog loaded → only workDirs the file does not know about yet are
* added (e.g. sessions created by the v1 TUI since the last sync),
* 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
Expand All @@ -39,7 +46,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IFileSystemStorageService } from '#/persistence/interface/storage';

import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry';
import { IWorkspacePersistence } from './workspacePersistence';
import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersistence';

const SESSION_INDEX_SCOPE = '';
const SESSION_INDEX_KEY = 'session_index.jsonl';
Expand All @@ -55,8 +62,8 @@ interface SessionIndexLine {
export class WorkspaceRegistryService implements IWorkspaceRegistry {
declare readonly _serviceBrand: undefined;

private cache: Map<string, Workspace> | undefined;
private deletedIds: Set<string> | undefined;
/** Whether the once-per-process session-index sync already ran. */

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 Keep v2 comments in the file header

packages/agent-core-v2/AGENTS.md requires comments in this package to live solely in the top-of-file /** */ block and never beside functions, methods, or statements. This new inline field comment violates that convention (as do the new helper docblocks below), so please move any necessary rationale into the file header or let the merged name carry it.

Useful? React with 👍 / 👎.

private merged = false;
private opQueue: Promise<unknown> = Promise.resolve();

constructor(
Expand All @@ -67,21 +74,23 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {

list(): Promise<readonly Workspace[]> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
return dedupeByRoot(cache);
await this.ensureMerged();
const catalog = await this.loadCatalog();
const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws]));
return dedupeByRoot(byId);
});
}

get(id: string): Promise<Workspace | undefined> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
return cache.get(id);
await this.ensureMerged();
const catalog = await this.loadCatalog();
return catalog.workspaces.find((ws) => ws.id === id);
});
}

createOrTouch(root: string, name?: string): Promise<Workspace> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
let stat;
try {
stat = await this.hostFs.stat(root);
Expand All @@ -95,8 +104,12 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
if (!stat.isDirectory) {
throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} is not a directory`);
}
await this.ensureMerged();
const catalog = await this.loadCatalog();
const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws]));
const deletedIds = new Set(catalog.deletedIds);
const id = encodeWorkDirKey(root);
const existing = cache.get(id);
const existing = byId.get(id);
const now = Date.now();
const ws: Workspace =
existing !== undefined
Expand All @@ -108,73 +121,84 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
createdAt: now,
lastOpenedAt: now,
};
cache.set(id, ws);
byId.set(id, ws);
// An explicit add clears any prior deletion tombstone.
this.deletedIds?.delete(id);
await this.persist();
deletedIds.delete(id);
await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] });
return ws;
});
}

update(id: string, patch: WorkspaceUpdate): Promise<Workspace | undefined> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
const existing = cache.get(id);
await this.ensureMerged();
const catalog = await this.loadCatalog();
const existing = catalog.workspaces.find((ws) => ws.id === id);
if (existing === undefined) return undefined;
const updated: Workspace = {
...existing,
...(patch.name !== undefined ? { name: patch.name } : {}),
};
cache.set(id, updated);
await this.persist();
await this.store.save({
workspaces: catalog.workspaces.map((ws) => (ws.id === id ? updated : ws)),
deletedIds: catalog.deletedIds,
});
return updated;
});
}

delete(id: string): Promise<void> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
cache.delete(id);
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.
this.deletedIds?.add(id);
await this.persist();
await this.store.save({
workspaces: catalog.workspaces.filter((ws) => ws.id !== id),
deletedIds: [...new Set([...catalog.deletedIds, id])],
});
});
}

private async ensureLoaded(): Promise<Map<string, Workspace>> {
if (this.cache !== undefined) return this.cache;
/** Once-per-process startup sync with the legacy session index (see the
* file header). Runs inside the op mutex, so it cannot interleave with a
* mutation's read-modify-write. */
private async ensureMerged(): Promise<void> {
if (this.merged) return;
const loaded = await this.store.load();
if (loaded === undefined) {
const rebuilt = await this.rebuildFromSessionIndex();
this.cache = rebuilt;
this.deletedIds = new Set();
await this.persist();
return rebuilt;
await this.store.save({ workspaces: [...rebuilt.values()], deletedIds: [] });
this.merged = true;
return;
}
const cache = new Map(loaded.workspaces.map((ws) => [ws.id, ws]));
const byId = 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();
if (await this.mergeFromSessionIndex(byId, deletedIds)) {
await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] });
}
return cache;
this.merged = true;
}

/** Read the current catalog; a missing or malformed file is an empty
* catalog (mirrors v1's tolerant read). */
private async loadCatalog(): Promise<WorkspaceCatalog> {
return (await this.store.load()) ?? { workspaces: [], deletedIds: [] };
}

/** 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>,
byId: 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, {
if (byId.has(id) || deletedIds.has(id)) continue;
byId.set(id, {
id,
root: workDir,
name: basename(workDir),
Expand Down Expand Up @@ -218,18 +242,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
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],
});
}

private runExclusive<T>(op: () => Promise<T>): Promise<T> {
const next = this.opQueue.then(op, op);
this.opQueue = next.then(
Expand Down Expand Up @@ -262,9 +274,9 @@ function parseSessionIndexLine(line: string): SessionIndexLine | undefined {
}
}

function dedupeByRoot(cache: ReadonlyMap<string, Workspace>): Workspace[] {
function dedupeByRoot(byId: ReadonlyMap<string, Workspace>): Workspace[] {
const byRoot = new Map<string, Workspace>();
for (const ws of cache.values()) {
for (const ws of byId.values()) {
const existing = byRoot.get(ws.root);
if (existing === undefined) {
byRoot.set(ws.root, ws);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,101 @@ describe('WorkspaceRegistryService (file-backed)', () => {
expect((await restart().list()).map((w) => w.id)).toEqual([a.id]);
});

it('createOrTouch preserves external additions and tombstones written after load', async () => {
const dirA = join(homeDir, 'dir-a');
const dirB = join(homeDir, 'dir-b');
const dirC = join(homeDir, 'dir-c');
await fsp.mkdir(dirA);
await fsp.mkdir(dirC);
const registry = build();
await registry.createOrTouch(dirA);

// Simulate a v1 writer touching the file after the v2 registry already
// ran an operation: a new workspace entry plus an unrelated tombstone.
const onDisk = await readWorkspacesJson();
onDisk.workspaces[encodeWorkDirKey(dirB)] = {
root: dirB,
name: 'dir-b',
created_at: '2024-01-01T00:00:00.000Z',
last_opened_at: '2024-01-01T00:00:00.000Z',
};
await fsp.writeFile(
join(homeDir, 'workspaces.json'),
JSON.stringify({
version: 1,
workspaces: onDisk.workspaces,
deleted_workspace_ids: ['wd_external_tombstone'],
}),
'utf8',
);

await registry.createOrTouch(dirC);

const after = await readWorkspacesJson();
expect(Object.keys(after.workspaces).toSorted()).toEqual(
[encodeWorkDirKey(dirA), encodeWorkDirKey(dirB), encodeWorkDirKey(dirC)].toSorted(),
);
expect(after.deleted_workspace_ids).toEqual(['wd_external_tombstone']);
// Reads also see the external entry without a restart.
expect((await registry.list()).map((w) => w.id)).toContain(encodeWorkDirKey(dirB));
});

it('delete adds its tombstone on top of the current file state', async () => {
const dirA = join(homeDir, 'dir-a');
await fsp.mkdir(dirA);
const registry = build();
const a = await registry.createOrTouch(dirA);

const onDisk = await readWorkspacesJson();
await fsp.writeFile(
join(homeDir, 'workspaces.json'),
JSON.stringify({
version: 1,
workspaces: onDisk.workspaces,
deleted_workspace_ids: ['wd_external_tombstone'],
}),
'utf8',
);

await registry.delete(a.id);

const after = await readWorkspacesJson();
expect(after.workspaces[a.id]).toBeUndefined();
expect((after.deleted_workspace_ids as string[]).toSorted()).toEqual(
['wd_external_tombstone', a.id].toSorted(),
);
});

it('update renames the current file entry and misses externally removed ids', async () => {
const dirA = join(homeDir, 'dir-a');
await fsp.mkdir(dirA);
const registry = build();
const a = await registry.createOrTouch(dirA);

// External rename on disk: the update must start from it, not stale state.
const onDisk = await readWorkspacesJson();
const entry = onDisk.workspaces[a.id];
if (entry === undefined) throw new Error('seed entry missing');
onDisk.workspaces[a.id] = { ...entry, name: 'external-name' };
await fsp.writeFile(
join(homeDir, 'workspaces.json'),
JSON.stringify({ version: 1, workspaces: onDisk.workspaces, deleted_workspace_ids: [] }),
'utf8',
);

const renamed = await registry.update(a.id, { name: 'local-name' });
expect(renamed?.name).toBe('local-name');
expect(renamed?.lastOpenedAt).toBe(Date.parse(entry.last_opened_at));

// External removal: update reports the id as gone instead of resurrecting.
await fsp.writeFile(
join(homeDir, 'workspaces.json'),
JSON.stringify({ version: 1, workspaces: {}, deleted_workspace_ids: [] }),
'utf8',
);
expect(await registry.update(a.id, { name: 'whatever' })).toBeUndefined();
});

it('writes through on update and delete', async () => {
const created = await build().createOrTouch(homeDir, 'proj');
await build().update(created.id, { name: 'renamed' });
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +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 { touchWorkspaceRegistry } from '../session/store/workspace-registry-file';
import {
noopTelemetryClient,
withTelemetryContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/services/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `workspaceRegistryFile.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` |
| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` |
| `config/` | `config.ts` | `configService.ts` | `IConfigService` |
| `session/` | `session.ts` | `sessionService.ts` | `ISessionService` |
| `message/` | `message.ts` | `messageService.ts` | `IMessageService` |
Expand Down
1 change: 0 additions & 1 deletion packages/agent-core/src/services/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export {
type WorkspacePatch,
} from './workspaceRegistry';
export { WorkspaceRegistryService, detectGit } from './workspaceRegistryService';
export { touchWorkspaceRegistry } from './workspaceRegistryFile';
export {
IWorkspaceFsService,
WorkspaceFsNotAbsoluteError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
writeWorkspaceRegistryFile,
type WorkspaceRegistryEntry,
type WorkspaceRegistryFile,
} from './workspaceRegistryFile';
} from '../../session/store/workspace-registry-file';

type WorkspaceRegistryEvent =
| { type: 'event.workspace.created'; workspace: Workspace }
Expand Down
Loading
Loading