diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b974559cb..b86b3434b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -838,6 +838,7 @@ export class Session { emit: (msg) => this.emit(msg), }, projectRegistry: this.projectRegistry, + workspaceRegistry: this.workspaceRegistry, logger: this.sessionLogger, }); this.daemonSession = new DaemonSession({ diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 0faa95404..7fa2630d3 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import pino from "pino"; import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js"; -import type { PersistedProjectRecord } from "../../workspace-registry.js"; +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; import type { SessionOutboundMessage } from "../../messages.js"; const tempDirs: string[] = []; @@ -33,12 +33,37 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers }; } -function makeSubsystem(records: PersistedProjectRecord[]) { +function workspaceRecord( + projectId: string, + cwd: string, + archivedAt: string | null = null, +): PersistedWorkspaceRecord { + return { + workspaceId: `workspace:${cwd}`, + projectId, + cwd, + kind: "worktree", + displayName: "Workspace", + branch: "feature", + worktreeRoot: cwd, + isPaseoOwnedWorktree: true, + mainRepoRoot: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt, + }; +} + +function makeSubsystem( + records: PersistedProjectRecord[], + workspaces: PersistedWorkspaceRecord[] = [], +) { const emitted: SessionOutboundMessage[] = []; const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) }; const subsystem = new ProjectConfigSession({ host, projectRegistry: { list: async () => records }, + workspaceRegistry: { list: async () => workspaces }, logger: pino({ level: "silent" }), }); return { subsystem, emitted }; @@ -179,6 +204,92 @@ describe("ProjectConfigSession", () => { ]); }); + test("read and write accept an active workspace belonging to an active project", async () => { + const repoRoot = makeRoot(); + const workspaceRoot = makeRoot(); + const project = projectRecord(repoRoot); + const { subsystem, emitted } = makeSubsystem( + [project], + [workspaceRecord(project.projectId, workspaceRoot)], + ); + + await subsystem.handleWriteProjectConfigRequest({ + type: "write_project_config_request", + requestId: "workspace-write-1", + repoRoot: workspaceRoot, + config: { worktree: { setup: "npm ci" } }, + expectedRevision: null, + }); + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "workspace-read-1", + repoRoot: workspaceRoot, + }); + + expect(emitted).toEqual([ + expect.objectContaining({ + type: "write_project_config_response", + payload: expect.objectContaining({ ok: true, repoRoot: workspaceRoot }), + }), + expect.objectContaining({ + type: "read_project_config_response", + payload: expect.objectContaining({ + ok: true, + repoRoot: workspaceRoot, + config: { worktree: { setup: "npm ci" } }, + }), + }), + ]); + }); + + test("read rejects archived workspaces and workspaces owned by archived projects", async () => { + const activeRepoRoot = makeRoot(); + const archivedRepoRoot = makeRoot(); + const archivedWorkspaceRoot = makeRoot(); + const orphanedWorkspaceRoot = makeRoot(); + const activeProject = projectRecord(activeRepoRoot); + const archivedProject = projectRecord(archivedRepoRoot, "2026-01-02T00:00:00.000Z"); + const { subsystem, emitted } = makeSubsystem( + [activeProject, archivedProject], + [ + workspaceRecord(activeProject.projectId, archivedWorkspaceRoot, "2026-01-02T00:00:00.000Z"), + workspaceRecord(archivedProject.projectId, orphanedWorkspaceRoot), + ], + ); + + for (const [requestId, repoRoot] of [ + ["archived-workspace", archivedWorkspaceRoot], + ["archived-owner", orphanedWorkspaceRoot], + ] as const) { + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId, + repoRoot, + }); + } + + expect(emitted).toEqual([ + { + type: "read_project_config_response", + payload: { + requestId: "archived-workspace", + repoRoot: archivedWorkspaceRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + { + type: "read_project_config_response", + payload: { + requestId: "archived-owner", + repoRoot: orphanedWorkspaceRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + ]); + }); + test("write rejects a stale revision and an unknown root with their inline domain failures", async () => { const staleRoot = makeRoot(); writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index ae62bd3d2..c5197ebcd 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -2,7 +2,7 @@ import { realpathSync } from "node:fs"; import { resolve, sep } from "path"; import type pino from "pino"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; -import type { ProjectRegistry } from "../../workspace-registry.js"; +import type { ProjectRegistry, WorkspaceRegistry } from "../../workspace-registry.js"; import { readPaseoConfigForEdit, writePaseoConfigForEdit, @@ -16,24 +16,27 @@ export interface ProjectConfigSessionHost { export interface ProjectConfigSessionOptions { host: ProjectConfigSessionHost; projectRegistry: Pick; + workspaceRegistry: Pick; logger: pino.Logger; } /** * A client's read/write surface for a project's on-disk paseo.json. Resolves the - * request's repoRoot against the known (non-archived) project roots — accepting a - * trailing slash or a symlink via realpath — then reads or writes the config - * substrate and emits the matching response. Reaches no state beyond the injected - * project registry and the outbound channel. + * request's repoRoot against known active project and workspace roots — accepting + * a trailing slash or a symlink via realpath — then reads or writes the config + * substrate and emits the matching response. Workspace roots remain authorized + * only while both the workspace and its owning project are active. */ export class ProjectConfigSession { private readonly host: ProjectConfigSessionHost; private readonly projectRegistry: Pick; + private readonly workspaceRegistry: Pick; private readonly logger: pino.Logger; constructor(options: ProjectConfigSessionOptions) { this.host = options.host; this.projectRegistry = options.projectRegistry; + this.workspaceRegistry = options.workspaceRegistry; this.logger = options.logger; } @@ -153,15 +156,27 @@ export class ProjectConfigSession { private async resolveKnownProjectRoot(repoRoot: string): Promise { const requestedRoot = canonicalizeConfigRoot(repoRoot); const projects = await this.projectRegistry.list(); + const activeProjectIds = new Set(); for (const project of projects) { if (project.archivedAt !== null) { continue; } + activeProjectIds.add(project.projectId); const projectRoot = canonicalizeConfigRoot(project.rootPath); if (requestedRoot === projectRoot) { return projectRoot; } } + const workspaces = await this.workspaceRegistry.list(); + for (const workspace of workspaces) { + if (workspace.archivedAt !== null || !activeProjectIds.has(workspace.projectId)) { + continue; + } + const workspaceRoot = canonicalizeConfigRoot(workspace.cwd); + if (requestedRoot === workspaceRoot) { + return workspaceRoot; + } + } return null; } }