Phase 1b: extract workspace reconciliation service

This commit is contained in:
Mohamed Boudra
2026-03-25 20:39:07 +07:00
parent 6eb6be77df
commit b3cdfb203b
6 changed files with 933 additions and 213 deletions
+19 -210
View File
@@ -99,11 +99,7 @@ import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
buildProjectPlacementForCwd,
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
@@ -112,10 +108,7 @@ import type {
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
@@ -400,6 +393,7 @@ export type SessionOptions = {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
workspaceReconciliationService?: WorkspaceReconciliationService;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -583,6 +577,7 @@ export class Session {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly workspaceReconciliationService: WorkspaceReconciliationService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -643,6 +638,7 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceReconciliationService,
createAgentMcpTransport,
stt,
tts,
@@ -665,6 +661,17 @@ export class Session {
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.workspaceReconciliationService =
workspaceReconciliationService ??
new WorkspaceReconciliationService({
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
agentStorage: this.agentStorage,
buildProjectPlacement: async (cwd) => this.buildProjectPlacement(cwd),
syncWorkspaceGitWatchTarget: async (cwd, options) =>
this.syncWorkspaceGitWatchTarget(cwd, options),
removeWorkspaceGitWatchTarget: async (cwd) => this.removeWorkspaceGitWatchTarget(cwd),
});
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
if (this.terminalManager) {
@@ -1260,155 +1267,15 @@ export class Session {
});
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
workspace: PersistedWorkspaceRecord;
changed: boolean;
}> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = new Date().toISOString();
const nextProjectCreatedAt = existing?.createdAt ?? now;
const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existing ||
existing.archivedAt ||
existing.projectId !== nextWorkspaceRecord.projectId ||
existing.kind !== nextWorkspaceRecord.kind ||
existing.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existing!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
await this.archiveProjectRecordIfEmpty(existing.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
return this.workspaceReconciliationService.reconcileWorkspaceRecord(workspaceId);
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
},
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
return this.workspaceReconciliationService.reconcileActiveWorkspaceRecords();
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
@@ -5837,69 +5704,11 @@ export class Session {
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = new Date().toISOString();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
return this.workspaceReconciliationService.registerPendingWorktreeWorkspace(options);
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existing = await this.workspaceRegistry.get(workspaceId);
if (!existing || existing.archivedAt) {
this.removeWorkspaceGitWatchTarget(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
this.removeWorkspaceGitWatchTarget(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existing.projectId, nextArchivedAt);
}
await this.workspaceReconciliationService.archiveWorkspaceRecord(workspaceId, archivedAt);
}
private async emitWorkspaceUpdateForCwd(
@@ -170,7 +170,8 @@ describe("workspace git watch targets", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set();
let descriptor = {
id: "/tmp/repo",
@@ -0,0 +1,201 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createSessionForOwnershipTests(options?: { workspaceReconciliationService?: any }) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async (projectId: string) =>
createPersistedProjectRecord({
projectId,
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
workspaceReconciliationService: options?.workspaceReconciliationService,
}) as any;
return { session, emitted };
}
describe("workspace reconciliation ownership", () => {
test("open_project_request delegates registration to WorkspaceReconciliationService", async () => {
const reconcileWorkspaceRecord = vi.fn(async () => ({
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
changed: true,
}));
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord,
reconcileActiveWorkspaceRecords: async () => new Set<string>(),
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
session.emitWorkspaceUpdateForCwd = vi.fn(async () => {});
await session.handleMessage({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(reconcileWorkspaceRecord).toHaveBeenCalledWith("/tmp/repo");
expect(emitted).toContainEqual({
type: "open_project_response",
payload: expect.objectContaining({
requestId: "req-open",
error: null,
workspace: expect.objectContaining({
id: "/tmp/repo",
}),
}),
});
});
test("workspace update fanout delegates sweep to WorkspaceReconciliationService", async () => {
const reconcileActiveWorkspaceRecords = vi.fn(
async () => new Set(["/tmp/repo", "/tmp/repo/worktree"]),
);
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord: async () => {
throw new Error("not used");
},
reconcileActiveWorkspaceRecords,
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-ownership",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
},
{
id: "/tmp/repo/worktree",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature-a",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
},
];
await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree");
expect(reconcileActiveWorkspaceRecords).toHaveBeenCalledTimes(1);
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update") as
| Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/worktree",
]);
});
});
@@ -292,7 +292,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () => new Set();
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -411,7 +411,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () =>
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set(["/tmp/repo", "/tmp/repo/worktree"]);
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -0,0 +1,413 @@
import { describe, expect, test, vi } from "vitest";
import type { ProjectPlacementPayload } from "./messages.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createProjectPlacement(input: {
cwd: string;
projectId: string;
projectName: string;
isGit: boolean;
branchName?: string | null;
remoteUrl?: string | null;
isPaseoOwnedWorktree?: boolean;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
return {
projectKey: input.projectId,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: input.isGit,
currentBranch: input.branchName ?? null,
remoteUrl: input.remoteUrl ?? null,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
};
}
function createInMemoryRegistries() {
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
return {
projects,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId: string) => projects.get(projectId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedProjectRecord>) => {
projects.set(record.projectId, record);
},
archive: async (projectId: string, archivedAt: string) => {
const existing = projects.get(projectId);
if (!existing) {
return;
}
projects.set(projectId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (projectId: string) => {
projects.delete(projectId);
},
},
workspaces,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedWorkspaceRecord>) => {
workspaces.set(record.workspaceId, record);
},
archive: async (workspaceId: string, archivedAt: string) => {
const existing = workspaces.get(workspaceId);
if (!existing) {
return;
}
workspaces.set(workspaceId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (workspaceId: string) => {
workspaces.delete(workspaceId);
},
},
};
}
describe("WorkspaceReconciliationService", () => {
test("reconcileWorkspaceRecord registers a new workspace and project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: "repo",
isGit: false,
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T00:00:00.000Z",
});
const result = await service.reconcileWorkspaceRecord("/tmp/repo");
expect(result).toEqual({
changed: true,
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
});
expect(projects.get("/tmp/repo")).toEqual(
createPersistedProjectRecord({
projectId: "/tmp/repo",
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo")).toEqual(result.workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith("/tmp/repo", {
isGit: false,
});
});
test("registerPendingWorktreeWorkspace preregisters a git worktree under the repo project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: "remote:github.com/acme/repo",
projectName: "acme/repo",
isGit: true,
branchName: "main",
remoteUrl: "https://github.com/acme/repo.git",
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T01:00:00.000Z",
});
const workspace = await service.registerPendingWorktreeWorkspace({
repoRoot: "/tmp/repo",
worktreePath: "/tmp/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
});
expect(workspace).toEqual(
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo/.paseo/worktrees/feature-a",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo/.paseo/worktrees/feature-a",
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(projects.get("remote:github.com/acme/repo")).toEqual(
createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo/.paseo/worktrees/feature-a")).toEqual(workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith(
"/tmp/repo/.paseo/worktrees/feature-a",
{
isGit: true,
},
);
});
test("reconcileActiveWorkspaceRecords reassigns drifted workspaces and archives the old empty project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const localProjectId = "/tmp/repo";
const remoteProjectId = "remote:github.com/acme/repo";
const mainWorkspaceId = "/tmp/repo";
const worktreeWorkspaceId = "/tmp/repo/.paseo/worktrees/feature-a";
projects.set(
localProjectId,
createPersistedProjectRecord({
projectId: localProjectId,
rootPath: mainWorkspaceId,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
mainWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: mainWorkspaceId,
projectId: localProjectId,
cwd: mainWorkspaceId,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
worktreeWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: worktreeWorkspaceId,
projectId: localProjectId,
cwd: worktreeWorkspaceId,
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: remoteProjectId,
projectName: "acme/repo",
isGit: true,
branchName: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async () => true,
now: () => "2026-03-25T02:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
mainWorkspaceId,
worktreeWorkspaceId,
]);
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(projects.get(localProjectId)?.archivedAt).toBe("2026-03-25T02:00:00.000Z");
});
test("reconcileActiveWorkspaceRecords archives missing-dir and all-archived-agent workspaces but keeps no-agent and active-agent cases", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const activeWorkspaceId = "/tmp/active";
const noAgentWorkspaceId = "/tmp/no-agents";
const missingWorkspaceId = "/tmp/missing";
const archivedAgentsWorkspaceId = "/tmp/all-archived";
for (const workspaceId of [
activeWorkspaceId,
noAgentWorkspaceId,
missingWorkspaceId,
archivedAgentsWorkspaceId,
]) {
projects.set(
workspaceId,
createPersistedProjectRecord({
projectId: workspaceId,
rootPath: workspaceId,
kind: "non_git",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [
{
id: "archived-agent",
cwd: archivedAgentsWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "inactive-agent",
cwd: activeWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "active-agent",
cwd: activeWorkspaceId,
archivedAt: null,
},
],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: cwd.split("/").at(-1) ?? cwd,
isGit: false,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async (cwd: string) => cwd !== missingWorkspaceId,
now: () => "2026-03-25T03:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
archivedAgentsWorkspaceId,
missingWorkspaceId,
]);
expect(workspaces.get(missingWorkspaceId)?.archivedAt).toBe("2026-03-25T03:00:00.000Z");
expect(workspaces.get(archivedAgentsWorkspaceId)?.archivedAt).toBe(
"2026-03-25T03:00:00.000Z",
);
expect(workspaces.get(noAgentWorkspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(activeWorkspaceId)?.archivedAt).toBeNull();
});
test("archiveWorkspaceRecord archives the last active workspace and its project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const removeWorkspaceGitWatchTarget = vi.fn();
const projectId = "/tmp/repo";
const workspaceId = "/tmp/repo";
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: workspaceId,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: workspaceId,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: vi.fn(),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget,
now: () => "2026-03-25T04:00:00.000Z",
});
await service.archiveWorkspaceRecord(workspaceId);
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(projects.get(projectId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(removeWorkspaceGitWatchTarget).toHaveBeenCalledWith(workspaceId);
});
});
@@ -0,0 +1,296 @@
import { stat } from "node:fs/promises";
import type { ProjectPlacementPayload } from "./messages.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
type Awaitable<T> = T | Promise<T>;
export type WorkspaceReconciliationServiceOptions = {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
agentStorage: Pick<AgentStorage, "list">;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
syncWorkspaceGitWatchTarget?: (
cwd: string,
options: { isGit: boolean },
) => Awaitable<void>;
removeWorkspaceGitWatchTarget?: (cwd: string) => Awaitable<void>;
checkDirectoryExists?: (cwd: string) => Promise<boolean>;
now?: () => string;
};
export type ReconcileWorkspaceRecordResult = {
workspace: PersistedWorkspaceRecord;
changed: boolean;
};
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly agentStorage: Pick<AgentStorage, "list">;
private readonly buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
private readonly syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
private readonly removeWorkspaceGitWatchTarget: (cwd: string) => Promise<void>;
private readonly checkDirectoryExists: (cwd: string) => Promise<boolean>;
private readonly now: () => string;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.agentStorage = options.agentStorage;
this.buildProjectPlacement = options.buildProjectPlacement;
this.syncWorkspaceGitWatchTarget = async (cwd, syncOptions) => {
await options.syncWorkspaceGitWatchTarget?.(cwd, syncOptions);
};
this.removeWorkspaceGitWatchTarget = async (cwd) => {
await options.removeWorkspaceGitWatchTarget?.(cwd);
};
this.checkDirectoryExists =
options.checkDirectoryExists ??
(async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
});
this.now = options.now ?? (() => new Date().toISOString());
}
async reconcileWorkspaceRecord(workspaceId: string): Promise<ReconcileWorkspaceRecordResult> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = this.now();
const nextWorkspaceCreatedAt = existingWorkspace?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextWorkspaceCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existingWorkspace ||
existingWorkspace.archivedAt ||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId ||
existingWorkspace.kind !== nextWorkspaceRecord.kind ||
existingWorkspace.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existingWorkspace!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
}
async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: this.checkDirectoryExists,
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
}
async registerPendingWorktreeWorkspace(options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = this.now();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
}
async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
return;
}
const nextArchivedAt = archivedAt ?? this.now();
await this.workspaceRegistry.archive(normalizedWorkspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
}
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
}