From 1b9543023ed03367f4bf3a44bf7296301eb50fd8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 25 Jun 2026 03:36:14 +0800 Subject: [PATCH] refactor(server): extract workspace provisioning into a deep module (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(server): extract workspace provisioning into workspace-provisioning module Move the "find-or-create a workspace & project for a directory" cluster out of the 6.4k-line Session god class into session/workspace-provisioning/ behind an injected createWorkspaceProvisioningService(deps) port. Nine methods (~215 LOC) — findOrCreateWorkspaceForDirectory, resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory, findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, and the private helpers resolveWorkspaceDirectory / findExactWorkspaceByDirectory / reclassifyOrUnarchiveWorkspaceForDirectory / resolveProjectRecordForPlacement — now live in one deep module with a 5-method interface. The session shed four pure-function imports it no longer needs and delegates from five call sites. The module's only dependencies are the workspace/project registries and the git-service checkout port — no emit, clientActivity, or observer coupling — so it is now unit-testable with real file-backed registries and a fake git port (workspace-provisioning-service.test.ts), instead of only through a booted Session. Behavior is unchanged: the existing open_project / import / create-agent characterization suites stay green. * refactor(server): address review — drop redundant spread property, test always-create - Remove the dead `workspaceId: input.workspace.workspaceId` in reclassifyOrUnarchiveWorkspaceForDirectory (already spread via ...input.workspace; behavior-identical). - Add a direct test pinning createWorkspaceForDirectory's always-mint-fresh contract (call twice for the same cwd → two distinct workspace ids), the property that distinguishes it from findOrCreateWorkspaceForDirectory. --- packages/server/src/server/session.ts | 262 ++-------------- .../workspace-provisioning-service.test.ts | 216 +++++++++++++ .../workspace-provisioning-service.ts | 284 ++++++++++++++++++ 3 files changed, 524 insertions(+), 238 deletions(-) create mode 100644 packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts create mode 100644 packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3a1dc8d6f..2c35ed14a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -121,14 +121,10 @@ import { } from "./agent/import-sessions.js"; import { checkoutLiteFromGitSnapshot, - classifyDirectoryForProjectMembership, deriveWorkspaceDisplayName, - generateWorkspaceId, } from "./workspace-registry-model.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, resolveProjectDisplayName, resolveWorkspaceDisplayName, resolveWorkspaceName, @@ -164,6 +160,10 @@ import { createGitMutationService, type GitMutationService, } from "./session/git-mutation/git-mutation-service.js"; +import { + createWorkspaceProvisioningService, + type WorkspaceProvisioningService, +} from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; @@ -564,6 +564,7 @@ export class Session { private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; private readonly workspaceGitService: WorkspaceGitService; private readonly gitMutation: GitMutationService; + private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; private readonly pushTokenStore: PushTokenStore; @@ -700,6 +701,11 @@ export class Session { github: this.github, logger: this.sessionLogger, }); + this.workspaceProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry: this.workspaceRegistry, + projectRegistry: this.projectRegistry, + workspaceGitService: this.workspaceGitService, + }); this.checkoutSession = new CheckoutSession({ host: { emit: (msg) => this.emit(msg), @@ -1459,29 +1465,6 @@ export class Session { } } - private async findExactWorkspaceByDirectory( - cwd: string, - options?: { refreshGit?: boolean }, - ): Promise { - const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); - const workspaces = await this.workspaceRegistry.list(); - return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; - } - - private async resolveWorkspaceDirectory( - cwd: string, - options?: { refreshGit?: boolean }, - ): Promise { - const normalizedCwd = resolve(cwd); - if (options?.refreshGit === false) { - const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd); - return resolve(snapshot?.git.repoRoot ?? normalizedCwd); - } - - const checkout = await this.workspaceGitService.getCheckout(normalizedCwd); - return resolve(checkout.worktreeRoot ?? normalizedCwd); - } - private async buildProjectPlacementForWorkspace( workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, @@ -2663,12 +2646,14 @@ export class Session { const createAgentConfig: AgentSessionConfig = createdWorktree ? { ...config, cwd: createdWorktree.worktree.worktreePath } : config; - const workspaceId = await this.resolveOrCreateWorkspaceIdForCreateAgent({ - createdWorktree, - requestedWorkspaceId: msg.workspaceId, - cwd: createAgentConfig.cwd, - initialTitle: workspacePromptTitle, - }); + const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent( + { + createdWorktree, + requestedWorkspaceId: msg.workspaceId, + cwd: createAgentConfig.cwd, + initialTitle: workspacePromptTitle, + }, + ); const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -2867,7 +2852,9 @@ export class Session { } // An imported agent mints its own workspace; ownership is its workspaceId, // never an existing same-cwd workspace resolved by path. - const workspace = await this.createWorkspaceForDirectory(normalized.cwd); + const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory( + normalized.cwd, + ); const { snapshot, timelineSize } = await importProviderSession({ request: normalized, workspaceId: workspace.workspaceId, @@ -4314,108 +4301,6 @@ export class Session { } } - private async findOrCreateWorkspaceForDirectory(cwd: string): Promise { - const inputCwd = resolve(cwd); - const normalizedCwd = await this.resolveWorkspaceDirectory(cwd); - const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, { - refreshGit: false, - }); - if (existingWorkspace) { - if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) { - const timestamp = new Date().toISOString(); - const checkout = checkoutLiteFromGitSnapshot(inputCwd, { - isGit: false, - currentBranch: null, - remoteUrl: null, - repoRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }); - const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout }); - const projectRecord = await this.resolveProjectRecordForPlacement({ - membership, - timestamp, - }); - await this.projectRegistry.upsert(projectRecord); - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), - projectId: projectRecord.projectId, - cwd: inputCwd, - kind: membership.workspaceKind, - displayName: membership.workspaceDisplayName, - createdAt: timestamp, - updatedAt: timestamp, - }); - await this.workspaceRegistry.upsert(workspaceRecord); - return workspaceRecord; - } - return this.reclassifyOrUnarchiveWorkspaceForDirectory({ - workspace: existingWorkspace, - project: await this.projectRegistry.get(existingWorkspace.projectId), - cwd: normalizedCwd, - }); - } - - return this.createWorkspaceForDirectory(normalizedCwd); - } - - private async resolveOrCreateWorkspaceIdForCreateAgent(input: { - createdWorktree: CreatePaseoWorktreeWorkflowResult | null; - requestedWorkspaceId?: string; - cwd: string; - initialTitle: string | null; - }): Promise { - if (input.createdWorktree) { - return input.createdWorktree.workspace.workspaceId; - } - - if (input.requestedWorkspaceId) { - return input.requestedWorkspaceId; - } - - return (await this.createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId; - } - - private async createWorkspaceForDirectory( - cwd: string, - title?: string | null, - ): Promise { - const checkout = await this.workspaceGitService.getCheckout(cwd); - const membership = classifyDirectoryForProjectMembership({ cwd, checkout }); - const timestamp = new Date().toISOString(); - - const projectRecord = await this.resolveProjectRecordForPlacement({ - membership, - timestamp, - }); - await this.projectRegistry.upsert(projectRecord); - - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), - projectId: projectRecord.projectId, - cwd, - kind: membership.workspaceKind, - displayName: membership.workspaceDisplayName, - title: title ?? null, - createdAt: timestamp, - updatedAt: timestamp, - }); - await this.workspaceRegistry.upsert(workspaceRecord); - return workspaceRecord; - } - - private async findOrCreateProjectForDirectory(cwd: string): Promise { - const normalizedCwd = resolve(cwd); - const checkout = await this.workspaceGitService.getCheckout(normalizedCwd); - const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout }); - const projectRecord = await this.resolveProjectRecordForPlacement({ - membership, - timestamp: new Date().toISOString(), - }); - await this.projectRegistry.upsert(projectRecord); - return projectRecord; - } - private buildProjectDescriptor( project: PersistedProjectRecord, ): WorkspaceProjectDescriptorPayload { @@ -4428,81 +4313,6 @@ export class Session { }; } - private async reclassifyOrUnarchiveWorkspaceForDirectory(input: { - workspace: PersistedWorkspaceRecord; - project: PersistedProjectRecord | null; - cwd: string; - }): Promise { - const checkout = await this.workspaceGitService.getCheckout(input.cwd); - const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout }); - const timestamp = new Date().toISOString(); - const projectRecord = await this.resolveProjectRecordForPlacement({ - membership, - timestamp, - }); - const projectId = projectRecord.projectId; - const kind = membership.workspaceKind; - const displayName = membership.workspaceDisplayName; - - if ( - input.workspace.projectId === projectId && - input.workspace.kind === kind && - input.workspace.displayName === displayName - ) { - if (!input.project) { - await this.projectRegistry.upsert(projectRecord); - } - return this.ensureWorkspaceRecordUnarchived(input.workspace); - } - - await this.projectRegistry.upsert(projectRecord); - - const nextWorkspace = { - ...input.workspace, - workspaceId: input.workspace.workspaceId, - projectId, - cwd: input.cwd, - kind, - displayName, - archivedAt: null, - updatedAt: timestamp, - }; - await this.workspaceRegistry.upsert(nextWorkspace); - return nextWorkspace; - } - - private async resolveProjectRecordForPlacement(input: { - membership: ReturnType; - timestamp: string; - }): Promise { - const rootPath = input.membership.projectRootPath; - const kind = input.membership.projectKind; - const projects = await this.projectRegistry.list(); - const existingProject = - projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? - projects.find((project) => project.rootPath === rootPath) ?? - null; - - if (!existingProject) { - return createPersistedProjectRecord({ - projectId: input.membership.projectKey, - rootPath, - kind, - displayName: input.membership.projectName, - createdAt: input.timestamp, - updatedAt: input.timestamp, - }); - } - - return { - ...existingProject, - rootPath, - kind, - archivedAt: null, - updatedAt: input.timestamp, - }; - } - private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise { const record = await this.agentStorage.get(agentId); if (!record?.workspaceId) { @@ -4524,7 +4334,7 @@ export class Session { await this.recreateOwningWorktreeForRestore(workspace, workspace.branch); } - await this.ensureWorkspaceRecordUnarchived(workspace); + await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); } @@ -4583,30 +4393,6 @@ export class Session { } } - private async ensureWorkspaceRecordUnarchived( - workspace: PersistedWorkspaceRecord, - ): Promise { - const project = await this.projectRegistry.get(workspace.projectId); - if (!workspace.archivedAt && (!project || !project.archivedAt)) { - return workspace; - } - - const timestamp = new Date().toISOString(); - let unarchivedWorkspace = workspace; - if (workspace.archivedAt) { - unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp }; - await this.workspaceRegistry.upsert(unarchivedWorkspace); - } - if (project?.archivedAt) { - await this.projectRegistry.upsert({ - ...project, - archivedAt: null, - updatedAt: timestamp, - }); - } - return unarchivedWorkspace; - } - private async createPaseoWorktree( input: CreatePaseoWorktreeInput, options?: { @@ -5356,7 +5142,7 @@ export class Session { for (const workspaceRecord of await this.workspaceRegistry.list()) { workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord); } - const workspace = await this.findOrCreateWorkspaceForDirectory(cwd); + const workspace = await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(cwd); const project = await this.projectRegistry.get(workspace.projectId); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); @@ -5441,7 +5227,7 @@ export class Session { for (const project of await this.projectRegistry.list()) { projectsBefore.set(project.projectId, project); } - const project = await this.findOrCreateProjectForDirectory(cwd); + const project = await this.workspaceProvisioning.findOrCreateProjectForDirectory(cwd); this.sessionLogger.info( { requestedCwd, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts new file mode 100644 index 000000000..039bf73b9 --- /dev/null +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -0,0 +1,216 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-service-stub.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../../workspace-registry.js"; +import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; +import { + createWorkspaceProvisioningService, + type WorkspaceProvisioningService, +} from "./workspace-provisioning-service.js"; + +// Real file-backed registries + a fake git-service port (the only dependency that +// shells out to git in production). No module mocks — the service is exercised +// through the same interface its callers in session.ts use. + +const logger = createTestLogger(); +const ARCHIVED_AT = "2026-01-01T00:00:00.000Z"; + +let tmpDir: string; +let gitRoots: Set; +let workspaceRegistry: FileBackedWorkspaceRegistry; +let projectRegistry: FileBackedProjectRegistry; +let provisioning: WorkspaceProvisioningService; + +function gitService() { + return createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async (cwd: string) => { + let worktreeRoot: string | null = null; + for (const root of gitRoots) { + if ( + (cwd === root || cwd.startsWith(`${root}${path.sep}`)) && + root.length > (worktreeRoot?.length ?? -1) + ) { + worktreeRoot = root; + } + } + return { + cwd, + isGit: worktreeRoot !== null, + currentBranch: worktreeRoot ? "main" : null, + remoteUrl: null, + worktreeRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + }, + }); +} + +beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-")); + gitRoots = new Set(); + workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + projectRegistry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "projects.json"), + logger, + ); + await workspaceRegistry.initialize(); + await projectRegistry.initialize(); + provisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: gitService(), + }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("fresh git repo creates a workspace at the canonical worktree root", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const workspace = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(workspace.cwd).toBe(repo); + expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await projectRegistry.list()).toHaveLength(1); +}); + +test("fresh non-git directory creates a directory workspace at the exact path", async () => { + const dir = path.join(tmpDir, "plain"); + + const workspace = await provisioning.findOrCreateWorkspaceForDirectory(dir); + + expect(workspace.cwd).toBe(dir); +}); + +test("re-opening an active workspace by exact path returns the same record without duplicating", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); + const second = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(second.workspaceId).toBe(first.workspaceId); + expect(await workspaceRegistry.list()).toHaveLength(1); +}); + +test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + + const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened.workspaceId).toBe(created.workspaceId); + expect(reopened.archivedAt).toBeNull(); +}); + +test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const canonical = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(canonical.workspaceId, ARCHIVED_AT); + const sub = path.join(repo, "packages", "app"); + + const fresh = await provisioning.findOrCreateWorkspaceForDirectory(sub); + + expect(fresh.cwd).toBe(sub); + expect(fresh.workspaceId).not.toBe(canonical.workspaceId); + expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT); +}); + +test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await projectRegistry.archive(created.projectId, ARCHIVED_AT); + + const unarchived = await provisioning.ensureWorkspaceRecordUnarchived({ + ...created, + archivedAt: ARCHIVED_AT, + }); + + expect(unarchived.archivedAt).toBeNull(); + expect((await workspaceRegistry.get(created.workspaceId))?.archivedAt).toBeNull(); + expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull(); +}); + +test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => { + // The branch only reads workspace.workspaceId off the worktree result. + const createdWorktree = { + workspace: { workspaceId: "ws-from-worktree" }, + } as unknown as CreatePaseoWorktreeWorkflowResult; + + const id = await provisioning.resolveOrCreateWorkspaceIdForCreateAgent({ + createdWorktree, + cwd: path.join(tmpDir, "x"), + initialTitle: null, + }); + + expect(id).toBe("ws-from-worktree"); + expect(await workspaceRegistry.list()).toHaveLength(0); +}); + +test("resolveOrCreateWorkspaceIdForCreateAgent honors an explicitly requested workspace id", async () => { + const id = await provisioning.resolveOrCreateWorkspaceIdForCreateAgent({ + createdWorktree: null, + requestedWorkspaceId: "ws-requested", + cwd: path.join(tmpDir, "x"), + initialTitle: null, + }); + + expect(id).toBe("ws-requested"); + expect(await workspaceRegistry.list()).toHaveLength(0); +}); + +test("resolveOrCreateWorkspaceIdForCreateAgent creates a titled workspace when nothing is provided", async () => { + const dir = path.join(tmpDir, "plain"); + + const id = await provisioning.resolveOrCreateWorkspaceIdForCreateAgent({ + createdWorktree: null, + cwd: dir, + initialTitle: "My Title", + }); + + const created = await workspaceRegistry.get(id); + expect(created?.cwd).toBe(dir); + expect(created?.title).toBe("My Title"); +}); + +test("createWorkspaceForDirectory always mints a fresh workspace even when one already occupies the cwd", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const first = await provisioning.createWorkspaceForDirectory(repo); + const second = await provisioning.createWorkspaceForDirectory(repo); + + expect(second.workspaceId).not.toBe(first.workspaceId); + expect(await workspaceRegistry.list()).toHaveLength(2); +}); + +test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const first = await provisioning.findOrCreateProjectForDirectory(repo); + const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub")); + + expect(second.projectId).toBe(first.projectId); + expect(await projectRegistry.list()).toHaveLength(1); +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts new file mode 100644 index 000000000..219da22ea --- /dev/null +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -0,0 +1,284 @@ +import { resolve } from "node:path"; +import { + checkoutLiteFromGitSnapshot, + classifyDirectoryForProjectMembership, + generateWorkspaceId, +} from "../../workspace-registry-model.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from "../../workspace-registry.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; + +/** + * Resolves which workspace and project records a directory belongs to, creating, + * reclassifying, or unarchiving them as needed. Every path that needs a workspace + * for a cwd — opening a project, importing an agent, creating an agent, restoring + * an archived worktree — funnels through this one module, so the + * classify → resolve-project → persist → unarchive sequence (and the + * archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases) + * lives in a single place instead of being smeared across the session. + * + * Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts; + * this module owns the create-and-persist side. + */ +export interface ResolveOrCreateWorkspaceIdInput { + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + requestedWorkspaceId?: string; + cwd: string; + initialTitle: string | null; +} + +export interface WorkspaceProvisioningService { + findOrCreateWorkspaceForDirectory(cwd: string): Promise; + resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise; + createWorkspaceForDirectory( + cwd: string, + title?: string | null, + ): Promise; + findOrCreateProjectForDirectory(cwd: string): Promise; + ensureWorkspaceRecordUnarchived( + workspace: PersistedWorkspaceRecord, + ): Promise; +} + +export function createWorkspaceProvisioningService(deps: { + workspaceRegistry: WorkspaceRegistry; + projectRegistry: ProjectRegistry; + workspaceGitService: Pick; +}): WorkspaceProvisioningService { + const { workspaceRegistry, projectRegistry, workspaceGitService } = deps; + + async function resolveWorkspaceDirectory( + cwd: string, + options?: { refreshGit?: boolean }, + ): Promise { + const normalizedCwd = resolve(cwd); + if (options?.refreshGit === false) { + const snapshot = workspaceGitService.peekSnapshot(normalizedCwd); + return resolve(snapshot?.git.repoRoot ?? normalizedCwd); + } + + const checkout = await workspaceGitService.getCheckout(normalizedCwd); + return resolve(checkout.worktreeRoot ?? normalizedCwd); + } + + async function findExactWorkspaceByDirectory( + cwd: string, + options?: { refreshGit?: boolean }, + ): Promise { + const normalizedCwd = await resolveWorkspaceDirectory(cwd, options); + const workspaces = await workspaceRegistry.list(); + return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; + } + + async function resolveProjectRecordForPlacement(input: { + membership: ReturnType; + timestamp: string; + }): Promise { + const rootPath = input.membership.projectRootPath; + const kind = input.membership.projectKind; + const projects = await projectRegistry.list(); + const existingProject = + projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? + projects.find((project) => project.rootPath === rootPath) ?? + null; + + if (!existingProject) { + return createPersistedProjectRecord({ + projectId: input.membership.projectKey, + rootPath, + kind, + displayName: input.membership.projectName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + } + + return { + ...existingProject, + rootPath, + kind, + archivedAt: null, + updatedAt: input.timestamp, + }; + } + + async function reclassifyOrUnarchiveWorkspaceForDirectory(input: { + workspace: PersistedWorkspaceRecord; + project: PersistedProjectRecord | null; + cwd: string; + }): Promise { + const checkout = await workspaceGitService.getCheckout(input.cwd); + const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout }); + const timestamp = new Date().toISOString(); + const projectRecord = await resolveProjectRecordForPlacement({ + membership, + timestamp, + }); + const projectId = projectRecord.projectId; + const kind = membership.workspaceKind; + const displayName = membership.workspaceDisplayName; + + if ( + input.workspace.projectId === projectId && + input.workspace.kind === kind && + input.workspace.displayName === displayName + ) { + if (!input.project) { + await projectRegistry.upsert(projectRecord); + } + return ensureWorkspaceRecordUnarchived(input.workspace); + } + + await projectRegistry.upsert(projectRecord); + + const nextWorkspace = { + ...input.workspace, + projectId, + cwd: input.cwd, + kind, + displayName, + archivedAt: null, + updatedAt: timestamp, + }; + await workspaceRegistry.upsert(nextWorkspace); + return nextWorkspace; + } + + async function findOrCreateWorkspaceForDirectory(cwd: string): Promise { + const inputCwd = resolve(cwd); + const normalizedCwd = await resolveWorkspaceDirectory(cwd); + const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, { + refreshGit: false, + }); + if (existingWorkspace) { + if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) { + const timestamp = new Date().toISOString(); + const checkout = checkoutLiteFromGitSnapshot(inputCwd, { + isGit: false, + currentBranch: null, + remoteUrl: null, + repoRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout }); + const projectRecord = await resolveProjectRecordForPlacement({ + membership, + timestamp, + }); + await projectRegistry.upsert(projectRecord); + const workspaceRecord = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: projectRecord.projectId, + cwd: inputCwd, + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspaceRecord); + return workspaceRecord; + } + return reclassifyOrUnarchiveWorkspaceForDirectory({ + workspace: existingWorkspace, + project: await projectRegistry.get(existingWorkspace.projectId), + cwd: normalizedCwd, + }); + } + + return createWorkspaceForDirectory(normalizedCwd); + } + + async function resolveOrCreateWorkspaceIdForCreateAgent( + input: ResolveOrCreateWorkspaceIdInput, + ): Promise { + if (input.createdWorktree) { + return input.createdWorktree.workspace.workspaceId; + } + + if (input.requestedWorkspaceId) { + return input.requestedWorkspaceId; + } + + return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId; + } + + async function createWorkspaceForDirectory( + cwd: string, + title?: string | null, + ): Promise { + const checkout = await workspaceGitService.getCheckout(cwd); + const membership = classifyDirectoryForProjectMembership({ cwd, checkout }); + const timestamp = new Date().toISOString(); + + const projectRecord = await resolveProjectRecordForPlacement({ + membership, + timestamp, + }); + await projectRegistry.upsert(projectRecord); + + const workspaceRecord = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: projectRecord.projectId, + cwd, + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, + title: title ?? null, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspaceRecord); + return workspaceRecord; + } + + async function findOrCreateProjectForDirectory(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + const checkout = await workspaceGitService.getCheckout(normalizedCwd); + const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout }); + const projectRecord = await resolveProjectRecordForPlacement({ + membership, + timestamp: new Date().toISOString(), + }); + await projectRegistry.upsert(projectRecord); + return projectRecord; + } + + async function ensureWorkspaceRecordUnarchived( + workspace: PersistedWorkspaceRecord, + ): Promise { + const project = await projectRegistry.get(workspace.projectId); + if (!workspace.archivedAt && (!project || !project.archivedAt)) { + return workspace; + } + + const timestamp = new Date().toISOString(); + let unarchivedWorkspace = workspace; + if (workspace.archivedAt) { + unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp }; + await workspaceRegistry.upsert(unarchivedWorkspace); + } + if (project?.archivedAt) { + await projectRegistry.upsert({ + ...project, + archivedAt: null, + updatedAt: timestamp, + }); + } + return unarchivedWorkspace; + } + + return { + findOrCreateWorkspaceForDirectory, + resolveOrCreateWorkspaceIdForCreateAgent, + createWorkspaceForDirectory, + findOrCreateProjectForDirectory, + ensureWorkspaceRecordUnarchived, + }; +}