mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(server): extract workspace provisioning into a deep module (#1712)
* 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.
This commit is contained in:
@@ -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<PersistedWorkspaceRecord | null> {
|
||||
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<string> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<string> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<PersistedProjectRecord> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
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<void> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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,
|
||||
|
||||
@@ -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<string>;
|
||||
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);
|
||||
});
|
||||
@@ -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<PersistedWorkspaceRecord>;
|
||||
resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>;
|
||||
createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||
ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
}
|
||||
|
||||
export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { workspaceRegistry, projectRegistry, workspaceGitService } = deps;
|
||||
|
||||
async function resolveWorkspaceDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<string> {
|
||||
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<PersistedWorkspaceRecord | null> {
|
||||
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<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<string> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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<PersistedProjectRecord> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user