diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3c5f1f04ed..ef5beeb5e4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; tts: Resolvable; @@ -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 { - 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> { - const changedWorkspaceIds = new Set(); - 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 { @@ -5837,69 +5704,11 @@ export class Session { worktreePath: string; branchName: string; }): Promise { - 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 { - 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( diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 4a13cfd92e..ad001e2a6b 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -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", diff --git a/packages/server/src/server/session.workspace-reconciliation-ownership.test.ts b/packages/server/src/server/session.workspace-reconciliation-ownership.test.ts new file mode 100644 index 0000000000..d8bde67c00 --- /dev/null +++ b/packages/server/src/server/session.workspace-reconciliation-ownership.test.ts @@ -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(), + 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", + ]); + }); +}); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 4c2b4fadb5..6570982c22 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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 () => [ { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts new file mode 100644 index 0000000000..1dea12a103 --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -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>(); + const workspaces = new Map>(); + + 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) => { + 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) => { + 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); + }); +}); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts new file mode 100644 index 0000000000..41b812e359 --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -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 | Promise; + +export type WorkspaceReconciliationServiceOptions = { + projectRegistry: ProjectRegistry; + workspaceRegistry: WorkspaceRegistry; + agentStorage: Pick; + buildProjectPlacement: (cwd: string) => Promise; + syncWorkspaceGitWatchTarget?: ( + cwd: string, + options: { isGit: boolean }, + ) => Awaitable; + removeWorkspaceGitWatchTarget?: (cwd: string) => Awaitable; + checkDirectoryExists?: (cwd: string) => Promise; + now?: () => string; +}; + +export type ReconcileWorkspaceRecordResult = { + workspace: PersistedWorkspaceRecord; + changed: boolean; +}; + +export class WorkspaceReconciliationService { + private readonly projectRegistry: ProjectRegistry; + private readonly workspaceRegistry: WorkspaceRegistry; + private readonly agentStorage: Pick; + private readonly buildProjectPlacement: (cwd: string) => Promise; + private readonly syncWorkspaceGitWatchTarget: ( + cwd: string, + options: { isGit: boolean }, + ) => Promise; + private readonly removeWorkspaceGitWatchTarget: (cwd: string) => Promise; + private readonly checkDirectoryExists: (cwd: string) => Promise; + 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 { + 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> { + const changedWorkspaceIds = new Set(); + 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 { + 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 { + 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 { + const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === projectId && !workspace.archivedAt, + ); + if (siblingWorkspaces.length === 0) { + await this.projectRegistry.archive(projectId, archivedAt); + } + } +}