From cbf59f4c9655106d576eb2f5bb91a441f0ee43fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 16:49:27 +0200 Subject: [PATCH] feat(workspaces): own lifecycle through runtimes Reconcile runtime placement and lifecycle by workspace identity, preserve state through archive and recovery, and make deletion, observation replay, scripts, and resource cleanup convergent across local, worktree, and Docker runtimes. --- .oxlintrc.json | 9 + docs/data-model.md | 41 +- docs/development.md | 2 +- .../src/components/sidebar-workspace-list.tsx | 4 +- .../sidebar/sidebar-status-list.tsx | 2 +- .../sidebar/sidebar-workspace-row.tsx | 2 +- .../hooks/sidebar-workspaces-view-model.ts | 2 + .../workspace-open-in-editor-button.tsx | 10 +- .../screens/workspace/workspace-screen.tsx | 1 + packages/app/src/stores/session-store.ts | 2 + .../src/workspace/open-target-planner.test.ts | 17 + .../app/src/workspace/open-target-planner.ts | 10 +- .../docker-workspace-runtime/src/index.ts | 148 +++++- packages/protocol/src/messages.ts | 3 + .../server/src/server/agent/agent-manager.ts | 22 +- .../acp-workspace-runtime.posix.test.ts | 11 + .../workspace/workspace.posix.test.ts | 13 + packages/server/src/server/bootstrap.ts | 83 +++- ...space-runtime-characterization.e2e.test.ts | 90 +++- .../src/server/paseo-worktree-service.test.ts | 35 +- .../src/server/paseo-worktree-service.ts | 147 +++--- .../resolve-workspace-id-for-path.test.ts | 27 +- .../server/resolve-workspace-id-for-path.ts | 25 +- .../server/script-status-projection.test.ts | 14 +- .../src/server/script-status-projection.ts | 21 +- packages/server/src/server/session.ts | 150 ++++-- .../src/server/session.workspaces.test.ts | 83 +++- .../files/workspace-files-session.test.ts | 70 +++ .../session/files/workspace-files-session.ts | 21 +- .../workspace-provisioning-service.ts | 46 ++ .../workspace-recovery-service.ts | 22 + .../workspace-scripts-service.test.ts | 13 +- .../workspace-scripts-service.ts | 80 ++-- .../workspace-helper-rebind-fixture.mjs | 50 ++ ...workspace-helper-shared-rebind-fixture.mjs | 54 +++ .../workspace-runtime-command-fixture.mjs | 32 +- .../server/src/server/websocket-server.ts | 1 + ...orkspace-archive-record-scoped.e2e.test.ts | 57 +-- .../server/workspace-archive-service.test.ts | 55 ++- .../src/server/workspace-archive-service.ts | 40 +- .../src/server/workspace-git-observation.ts | 263 +--------- ...ce-git-service.runtime.integration.test.ts | 355 +++++++++++--- .../workspace-helper/internal/client.ts | 156 ++++-- .../workspace-helper.posix.test.ts | 76 ++- .../workspace-reconciliation-service.test.ts | 47 ++ .../workspace-reconciliation-service.ts | 11 +- .../src/server/workspace-registry-model.ts | 19 + .../src/server/workspace-registry.test.ts | 62 ++- .../server/src/server/workspace-registry.ts | 128 +++-- .../command/internal/command-runtime.ts | 34 +- .../command/internal/protocol.ts | 23 +- .../server/workspace-runtime/drivers/index.ts | 26 +- .../git-observation/index.ts | 10 + .../git-observation/integration.ts | 445 +++++++++++++++++ .../git-observation/internal/capability.ts | 22 + .../src/server/workspace-runtime/index.ts | 65 ++- .../internal/local-runtime.ts | 33 +- .../workspace-runtime/internal/service.ts | 449 +++++++++++++++++- .../internal/worktree-runtime.ts | 51 +- .../workspace-runtime.command.test.ts | 73 ++- .../workspace-runtime.docker.e2e.test.ts | 191 +++++++- .../workspace-runtime.posix.test.ts | 312 +++++++++++- .../workspace-service-port-allocator.test.ts | 4 +- .../workspace-service-port-allocator.ts | 87 +++- .../server/src/server/worktree-bootstrap.ts | 39 +- packages/server/src/server/worktree-core.ts | 61 ++- .../server/src/server/worktree-session.ts | 14 + .../terminal-session-controller.test.ts | 55 +++ .../terminal/terminal-session-controller.ts | 5 +- 69 files changed, 3766 insertions(+), 865 deletions(-) create mode 100644 packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs create mode 100644 packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs create mode 100644 packages/server/src/server/workspace-runtime/git-observation/index.ts create mode 100644 packages/server/src/server/workspace-runtime/git-observation/integration.ts create mode 100644 packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 0ce51bfa06..e7e9951d3d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -94,6 +94,14 @@ { "group": ["**/workspace-helper/integration/**", "**/workspace-helper/internal/**"], "message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients." + }, + { + "group": [ + "**/workspace-runtime/integration/**", + "**/workspace-runtime/git-observation/integration*", + "**/workspace-runtime/git-observation/internal/**" + ], + "message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle." } ] } @@ -103,6 +111,7 @@ { "files": [ "packages/server/src/server/workspace-helper/**/*.{ts,tsx}", + "packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}", "packages/server/src/server/workspace-runtime/internal/service.ts" ], "rules": { diff --git a/docs/data-model.md b/docs/data-model.md index fc22175ede..fefe08990f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects are observed too. -The workspace registry model defines placement once: initial directory/worktree construction, -mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy -preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding -registry writes, so directory opens, agent imports, and worktree creation all enter through that -service instead of constructing records independently. The workspace record is then the durable -placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing -checkout root. They intentionally differ for an exact subproject inside a worktree. Archive, -restore, branch auto-name, and descriptor flows consume those persisted facts rather than -rediscovering ownership from a directory that may already be gone. Reconciliation may refresh -mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. -Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing -`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing -checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. +The workspace registry model defines public placement once: initial directory/worktree +construction, mutable reconciliation fields, and the persisted-to-wire projection. +`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent +imports, and worktree creation do not construct records independently. + +For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd` +is required compatibility and presentation data: an adopted local directory, the actual host +worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and +never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and +file-manager actions. Private roots, revisions, execution domains, container and volume IDs, +labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)` +and are never written to the workspace record. + +Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`, +and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected +worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are +owned by the runtime driver. Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. @@ -529,7 +533,8 @@ Array of workspace records. A workspace is a specific working directory within a | ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | | `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | -| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | +| `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. | +| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. | | `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | | `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | @@ -538,13 +543,19 @@ Array of workspace records. A workspace is a specific working directory within a | `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | +| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. | | `createdAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | -> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. +> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. + +For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove +the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project +removal destroys each selected runtime before removing its workspace record. Records without +`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created. `projectId` is still a real FK: workspace records should have a matching project record. Read-only history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot diff --git a/docs/development.md b/docs/development.md index 3fd7e4ff3c..b4c0d3ce1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -298,7 +298,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array of commands. Both run sequentially. -Lifecycle commands run in the worktree through a stable script shell: `bash` +For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash` resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on Windows. They inherit the daemon environment plus Paseo's lifecycle variables; login and interactive shell startup files are not loaded, and Bash's `BASH_ENV` diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 124783733f..a6f0904191 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -651,7 +651,7 @@ function WorkspaceRowRightGroup({ isPinned?: boolean; onTogglePin?: () => void; }) { - const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; + const workspacePath = workspace.hostVisiblePath; const { t } = useTranslation(); const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; @@ -1149,7 +1149,7 @@ function WorkspaceRowInner({ archiveShortcutKeys={archiveShortcutKeys} isPinned={isPinned} onTogglePin={onTogglePin} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} aria-selected={selected} accessibilityRole="button" diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 3bb406cf54..c1152ddc65 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -742,7 +742,7 @@ function StatusWorkspaceRowInner({ archiveShortcutKeys={archiveShortcutKeys} isPinned={isPinned} onTogglePin={onTogglePin} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} accessibilityRole="button" accessibilityState={accessibilityState} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index e7a34a5f32..44b49a8608 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -336,7 +336,7 @@ function WorkspaceRowBody({ archiveStatus={archiveStatus} archivePendingLabel={archivePendingLabel} archiveShortcutKeys={archiveShortcutKeys} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} aria-selected={selected} accessibilityRole="button" diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index b31129cea7..841a4b9f72 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -25,6 +25,7 @@ export interface SidebarWorkspacePlacement { projectName: string; projectRootPath?: string; workspaceDirectory?: string; + hostVisiblePath?: string; projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; @@ -159,6 +160,7 @@ export function createSidebarWorkspaceEntry(input: { projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, + hostVisiblePath: input.workspace.hostVisiblePath, workspaceDirectoryLabel: input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 0042e89aa1..a3231b4839 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -31,6 +31,7 @@ interface WorkspaceOpenInEditorButtonProps { serverId: string; workspaceId: string; cwd: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -81,6 +82,7 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP export function WorkspaceOpenInEditorButton({ serverId, cwd, + hostVisiblePath, activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { @@ -95,8 +97,10 @@ export function WorkspaceOpenInEditorButton({ const resolvedFile = useMemo( () => - activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null, - [activeFile, cwd], + activeFile && hostVisiblePath + ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: hostVisiblePath }) + : null, + [activeFile, hostVisiblePath], ); const activeFileName = useMemo( () => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null, @@ -115,6 +119,7 @@ export function WorkspaceOpenInEditorButton({ () => planWorkspaceOpenTargets({ workspaceDirectory: cwd, + hostVisiblePath, activeFile, resolvedActiveFile: resolvedFile, desktopTargets: desktopOpenTargets, @@ -146,6 +151,7 @@ export function WorkspaceOpenInEditorButton({ checkoutStatus, cwd, desktopOpenTargets, + hostVisiblePath, resolvedForge, isDesktopOpenAvailable, isLocalDaemon, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 60d98e1628..88154f7d34 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3496,6 +3496,7 @@ function WorkspaceScreenContent({ serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} cwd={workspaceDirectory} + hostVisiblePath={workspaceDescriptor?.hostVisiblePath} activeFile={activeFileLocation} hideLabels /> diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 544507dfd7..57a6f61d5c 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -152,6 +152,7 @@ export interface WorkspaceDescriptor { projectCustomIconRevision?: string | null; projectRootPath: string; workspaceDirectory: string; + hostVisiblePath?: string; worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; @@ -188,6 +189,7 @@ export function normalizeWorkspaceDescriptor( // consumer can read workspace.workspaceDirectory directly. Empty means "no // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", + hostVisiblePath: normalizeWorkspacePath(payload.hostVisiblePath) ?? undefined, worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-target-planner.test.ts index 471b594cef..2c26a8b7b3 100644 --- a/packages/app/src/workspace/open-target-planner.test.ts +++ b/packages/app/src/workspace/open-target-planner.test.ts @@ -26,6 +26,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans editor targets with active-file absolute path and cwd", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, desktopTargets, canUseDesktopBridge: true, @@ -47,6 +48,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans file-manager targets with active-file absolute path and reveal mode", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts" }, desktopTargets, canUseDesktopBridge: true, @@ -67,6 +69,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans no active file as opening the workspace folder", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", desktopTargets, canUseDesktopBridge: true, isLocalExecution: true, @@ -87,6 +90,7 @@ describe("planWorkspaceOpenTargets", () => { it("passes custom target ids through as strings", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts" }, desktopTargets: [ { @@ -202,4 +206,17 @@ describe("planWorkspaceOpenTargets", () => { expect(targets.map((target) => target.id)).toEqual(["github"]); }); + + it("does not treat a runtime-local compatibility cwd as a host-visible path", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/workspace", + hostVisiblePath: null, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); }); diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-target-planner.ts index ca1e786ecb..c716fb2792 100644 --- a/packages/app/src/workspace/open-target-planner.ts +++ b/packages/app/src/workspace/open-target-planner.ts @@ -33,6 +33,7 @@ export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedForge export interface PlanWorkspaceOpenTargetsInput { workspaceDirectory: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedActiveFile?: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; @@ -60,16 +61,17 @@ function resolveActiveFileForOpenTargets( } function planDesktopOpenTargets(input: { - workspaceDirectory: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedFile: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; canUseDesktopBridge: boolean; isLocalExecution: boolean; }): PlannedDesktopOpenTarget[] { - if (!input.canUseDesktopBridge || !input.isLocalExecution) { + if (!input.canUseDesktopBridge || !input.isLocalExecution || !input.hostVisiblePath) { return []; } + const hostVisiblePath = input.hostVisiblePath; return input.desktopTargets.map((target) => { if (!input.resolvedFile) { @@ -79,7 +81,7 @@ function planDesktopOpenTargets(input: { label: target.label, editorId: target.id, icon: target.icon, - openInput: { editorId: target.id, workspacePath: input.workspaceDirectory }, + openInput: { editorId: target.id, workspacePath: hostVisiblePath }, }; } return { @@ -90,7 +92,7 @@ function planDesktopOpenTargets(input: { icon: target.icon, openInput: { editorId: target.id, - workspacePath: input.workspaceDirectory, + workspacePath: hostVisiblePath, filePath: input.resolvedFile.absolutePath, ...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}), }, diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts index 08d100b409..c8b04afe94 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -15,6 +15,7 @@ interface RuntimeState { revision: string; executionDomainId: string; lifecycle: "ready" | "paused"; + lifecycleEnvironment: Readonly>; } interface LifecycleRequest { @@ -29,6 +30,7 @@ interface LifecycleRequest { placement: { relativeCwd?: string }; }; options: { image?: unknown }; + workspaceIds?: string[]; } interface ExecRequest { @@ -63,7 +65,12 @@ const requestedWorkspaceId = argument("--workspace-id"); try { if (operation === "describe") { - writeJson({ protocolVersion: 1, modes: ["pipes", "pty"] }); + writeJson({ protocolVersion: 1, modes: ["pipes", "pty"], reconcile: true }); + } else if (operation === "reconcile") { + const request = JSON.parse(await readStdin()) as LifecycleRequest; + assertProtocol(request.protocolVersion); + await reconcile(request.workspaceIds ?? []); + writeJson({ protocolVersion: 1, type: "ok" }); } else if (!requestedWorkspaceId) { throw new Error("--workspace-id is required"); } else if (operation === "exec") { @@ -80,11 +87,15 @@ try { assertProtocol(request.protocolVersion); switch (operation) { case "create": - writeJson({ - protocolVersion: 1, - type: "state", - state: await create(requestedWorkspaceId, request), - }); + { + const state = await create(requestedWorkspaceId, request); + writeJson({ + protocolVersion: 1, + type: "state", + state, + placement: runtimePlacement(state), + }); + } break; case "inspect": writeJson({ @@ -98,11 +109,15 @@ try { writeJson({ protocolVersion: 1, type: "ok" }); break; case "resume": - writeJson({ - protocolVersion: 1, - type: "state", - state: await resume(requestedWorkspaceId), - }); + { + const state = await resume(requestedWorkspaceId); + writeJson({ + protocolVersion: 1, + type: "state", + state, + placement: runtimePlacement(state), + }); + } break; case "destroy": await destroy(requestedWorkspaceId); @@ -131,7 +146,7 @@ async function create(workspaceId: string, request: LifecycleRequest): Promise { +async function inspect(workspaceId: string): Promise< + | { status: "missing" } + | { + status: "ready" | "paused"; + state: RuntimeState; + placement: { cwd: string }; + } +> { const names = resourceNames(workspaceId); const inspection = await docker( [ @@ -243,15 +263,17 @@ async function inspect( if (owner !== "workspace" || inspectedWorkspaceId !== workspaceId || !root || !revision) { throw new Error(`Docker resource ownership mismatch: ${names.container}`); } + const state = stateFor( + workspaceId, + root, + revision, + names.container, + running === "true" ? "ready" : "paused", + ); return { status: running === "true" ? "ready" : "paused", - state: stateFor( - workspaceId, - root, - revision, - names.container, - running === "true" ? "ready" : "paused", - ), + state, + placement: runtimePlacement(state), }; } @@ -275,18 +297,76 @@ async function destroy(workspaceId: string): Promise { const names = resourceNames(workspaceId); const current = await inspect(workspaceId); const volume = await inspectVolume(names.volume); - if (volume && volume.owner !== workspaceId) { + if (volume && (volume.runtime !== "workspace" || volume.owner !== workspaceId)) { throw new Error(`Docker volume ownership mismatch: ${names.volume}`); } if (current.status !== "missing") await docker(["rm", "-f", names.container]); if (volume) await docker(["volume", "rm", names.volume]); } -async function inspectVolume(name: string): Promise<{ owner: string | null } | null> { +async function inspectVolume( + name: string, +): Promise<{ owner: string | null; runtime: string | null } | null> { const output = await docker(["volume", "inspect", name], true); if (!output) return null; const inspection = JSON.parse(output) as Array<{ Labels?: Record | null }>; - return { owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null }; + return { + owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null, + runtime: inspection[0]?.Labels?.["sh.paseo.runtime"] ?? null, + }; +} + +async function reconcile(workspaceIds: readonly string[]): Promise { + const known = new Set(workspaceIds); + const containers = await labeledResources("ps"); + const volumes = await labeledResources("volume"); + const candidateWorkspaceIds = new Set([...containers.values(), ...volumes.values()]); + for (const workspaceId of candidateWorkspaceIds) { + if (!workspaceId || known.has(workspaceId)) continue; + const names = resourceNames(workspaceId); + const containerProven = containers.get(names.container) === workspaceId; + const volumeProven = volumes.get(names.volume) === workspaceId; + if (!containerProven && !volumeProven) continue; + if (containerProven) { + const inspection = await inspect(workspaceId); + if (inspection.status === "missing") continue; + } + const volume = await inspectVolume(names.volume); + if (volume && (volume.runtime !== "workspace" || volume.owner !== workspaceId)) continue; + if (containerProven) await docker(["rm", "-f", names.container]); + if (volumeProven && volume) await docker(["volume", "rm", names.volume]); + } +} + +async function labeledResources(kind: "ps" | "volume"): Promise> { + const output = + kind === "ps" + ? await docker([ + "ps", + "-a", + "--filter", + "label=sh.paseo.runtime=workspace", + "--format", + '{{.Names}}|{{.Label "sh.paseo.workspace-id"}}', + ]) + : await docker([ + "volume", + "ls", + "--filter", + "label=sh.paseo.runtime=workspace", + "--format", + '{{.Name}}|{{.Label "sh.paseo.workspace-id"}}', + ]); + return new Map( + output + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const separator = line.indexOf("|"); + return separator < 0 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); } async function execute(workspaceId: string): Promise { @@ -647,7 +727,23 @@ function stateFor( container: string, lifecycle: "ready" | "paused" = "ready", ): RuntimeState { - return { workspaceId, root, revision, executionDomainId: `docker:${container}`, lifecycle }; + return { + workspaceId, + root, + revision, + executionDomainId: `docker:${container}`, + lifecycle, + lifecycleEnvironment: { + PASEO_SOURCE_CHECKOUT_PATH: "/workspace", + PASEO_ROOT_PATH: "/workspace", + PASEO_WORKTREE_PATH: "/workspace", + PASEO_BRANCH_NAME: "", + }, + }; +} + +function runtimePlacement(state: RuntimeState): { cwd: string } { + return { cwd: state.root }; } async function waitUntilReady(container: string): Promise { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a5769c2d68..2b1c24547c 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3314,6 +3314,9 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // Explicit capability for host editor/file-manager integrations. Runtime-local cwd is not enough. + // COMPAT(host-visible-path): added in v0.3.1; older daemons omit this capability. + hostVisiblePath: z.string().optional(), // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. // Present only for Paseo-owned worktrees; this is the basename of their root directory. worktreeSlug: z.string().optional(), diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 8a4c4ca9ed..47a20d2fd0 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -130,6 +130,7 @@ interface PreparedSessionConfig { interface NormalizeConfigOptions { resolveDefaultModel?: boolean; env?: Record; + validateHostCwd?: boolean; } interface TimeoutOptions { @@ -1113,6 +1114,7 @@ export class AgentManager { config, resolvedAgentId, options?.env, + options.workspaceId, ); this.requireEnabledProvider(storedConfig.provider); const client = options.workspaceId @@ -1201,6 +1203,8 @@ export class AgentManager { const { storedConfig, launchConfig } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, + undefined, + options?.workspaceId, ); const client = this.requireClient(handle.provider); @@ -1269,6 +1273,8 @@ export class AgentManager { cwd: input.cwd, }, resolvedAgentId, + undefined, + input.workspaceId, ); const launchContext = await this.buildLaunchContext( resolvedAgentId, @@ -1296,6 +1302,7 @@ export class AgentManager { try { const importedConfig = await this.normalizeConfig( stripInternalPaseoMcpServer(imported.config), + { validateHostCwd: false }, ); const timelineRows = buildImportedTimelineRows(imported.timeline); const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); @@ -1363,7 +1370,12 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); + const { storedConfig, launchConfig } = await this.prepareSessionConfig( + refreshConfig, + agentId, + undefined, + existing.workspaceId, + ); const launchContext = await this.buildLaunchContext( agentId, client, @@ -4423,7 +4435,7 @@ export class AgentManager { const normalized: AgentSessionConfig = { ...config }; // Always resolve cwd to absolute path for consistent history file lookup - if (normalized.cwd) { + if (normalized.cwd && options.validateHostCwd !== false) { normalized.cwd = resolve(normalized.cwd); try { const cwdStats = await stat(normalized.cwd); @@ -4515,8 +4527,12 @@ export class AgentManager { config: AgentSessionConfig, agentId: string, env?: Record, + workspaceId?: string, ): Promise { - const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env, + validateHostCwd: !workspaceId, + }); const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, diff --git a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts index b3ba943161..5d3e1e7d24 100644 --- a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts +++ b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts @@ -30,6 +30,7 @@ posixDescribe("ACP workspace terminal execution", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + ...lifecycleRecords(runtimeIds), }); await runtime.create({ workspaceId: "acp-workspace", @@ -107,6 +108,7 @@ posixDescribe("ACP workspace terminal execution", () => { resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, persistedRuntimeId) => runtimeIds.set(workspaceId, persistedRuntimeId), + ...lifecycleRecords(runtimeIds), }); await service.create({ workspaceId: "provider-workspace", @@ -178,3 +180,12 @@ posixDescribe("ACP workspace terminal execution", () => { 15_000, ); }); + +function lifecycleRecords(runtimeIds: Map) { + return { + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId: string) => { + runtimeIds.delete(workspaceId); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts index ff40dbaa56..dc58e7af34 100644 --- a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts +++ b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts @@ -21,6 +21,7 @@ posixDescribe("provider workspace placement capability", () => { paseoHome: path.join(root, "paseo-home"), resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), }); await service.create({ workspaceId: "provider-state", @@ -77,6 +78,7 @@ posixDescribe("provider workspace placement capability", () => { paseoHome: path.join(root, "paseo-home"), resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), }); await service.create({ workspaceId: "provider-remove", @@ -134,6 +136,7 @@ posixDescribe("provider workspace placement capability", () => { paseoHome: path.join(root, "paseo-home"), resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), }); await service.create({ workspaceId: "provider-abort", @@ -192,6 +195,7 @@ posixDescribe("provider workspace placement capability", () => { paseoHome: path.join(root, "paseo-home"), resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), }); await service.create({ workspaceId: "provider-state-error", @@ -225,3 +229,12 @@ posixDescribe("provider workspace placement capability", () => { } }); }); + +function lifecycleRecords(runtimeIds: Map) { + return { + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId: string) => { + runtimeIds.delete(workspaceId); + }, + }; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 540d51e84d..4bf0209093 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -193,7 +193,10 @@ import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-regist import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js"; +import { + createWorkspaceScriptsService, + readWorkspacePaseoConfig, +} from "./session/workspace-scripts/workspace-scripts-service.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { createManagedProcessRegistry, @@ -665,8 +668,14 @@ export async function createPaseoDaemon( serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - resolveWorkspaceDirectory: async (workspaceId) => - (await workspaceRegistry?.get(workspaceId))?.cwd ?? null, + resolveWorkspaceProjection: async (workspaceId) => { + const workspace = await workspaceRegistry?.get(workspaceId); + if (!workspace) return null; + return { + workspaceDirectory: workspace.cwd, + paseoConfig: await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }), + }; + }, logger, serviceProxyPublicBaseUrl, }), @@ -872,15 +881,54 @@ export async function createPaseoDaemon( const workspace = await workspaceRegistry?.get(workspaceId); return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null; }, - persistRuntimeId: async (workspaceId, runtimeId) => { + persistRuntimeId: async (workspaceId, runtimeId, placement) => { const existing = await workspaceRegistry?.get(workspaceId); - if (existing?.runtime?.runtimeId === runtimeId) return; + if ( + existing?.runtime?.runtimeId === runtimeId && + existing.cwd === placement.cwd && + existing.hostVisiblePath === (placement.hostVisiblePath ?? null) + ) { + return; + } const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, runtime: { runtimeId }, })); if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); }, + archiveWorkspaceRecord: async (workspaceId) => { + await workspaceRegistry?.archive(workspaceId, new Date().toISOString()); + }, + restoreWorkspaceRecord: async (workspaceId) => { + const restoredAt = new Date().toISOString(); + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + archivedAt: null, + updatedAt: restoredAt, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + }, + beginWorkspaceDeletion: async (workspaceId) => { + await workspaceRegistry?.requestDeletion(workspaceId, new Date().toISOString()); + }, + removeWorkspaceRecord: async (workspaceId) => { + await workspaceRegistry?.remove(workspaceId); + }, + listRuntimeRecords: async () => + ((await workspaceRegistry?.list()) ?? []).flatMap((workspace) => + workspace.runtime + ? [ + { + workspaceId: workspace.workspaceId, + runtimeId: workspace.runtime.runtimeId, + archived: workspace.archivedAt !== null, + deleting: workspace.deletionRequestedAt !== null, + }, + ] + : [], + ), }); const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, @@ -955,6 +1003,13 @@ export async function createPaseoDaemon( ); await agentStorage.initialize(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); + await Promise.all([projectRegistry.initialize(), workspaceRegistry.initialize()]); + try { + await workspaceRuntime.reconcile(); + logger.info({ elapsed: elapsed() }, "Workspace runtimes reconciled"); + } catch (error) { + logger.warn({ err: error }, "Workspace runtime reconciliation failed"); + } await bootstrapWorkspaceRegistries({ serverId, paseoHome: config.paseoHome, @@ -1000,6 +1055,19 @@ export async function createPaseoDaemon( workspaceId: string, context?: WorkspaceArchiveContext, ) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (workspace?.runtime) { + if (!workspaceRuntime) throw new Error(`Workspace runtime is not available: ${workspaceId}`); + await workspaceRuntime.archive(workspaceId); + if (context?.autoArchivedChangeRequestUrl) { + await workspaceRegistry.update(workspaceId, (archived) => ({ + ...archived, + autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl ?? null, + })); + } + teardownArchivedWorkspaceRuntime(workspaceId); + return; + } const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, workspaceRegistry, @@ -1041,6 +1109,7 @@ export async function createPaseoDaemon( worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, mainRepoRoot: workspace.mainRepoRoot, + runtimeId: workspace.runtime?.runtimeId ?? null, })); }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { @@ -1133,6 +1202,8 @@ export async function createPaseoDaemon( : {}), workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }); }, warmWorkspaceGitData: async (workspace) => { @@ -1160,6 +1231,7 @@ export async function createPaseoDaemon( getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, onScriptsChanged: null, + runWorkspaceSetup: (workspaceId) => workspaceRuntime.runSetup(workspaceId), }, input, serviceOptions, @@ -1382,6 +1454,7 @@ export async function createPaseoDaemon( workspaceRegistry, projectRegistry, workspaceGitDirectory, + workspaceRuntime, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 30fa83ff3e..9687df4c10 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -36,6 +36,7 @@ const fixtureAgentPath = fileURLToPath( interface CharacterizedWorkspace { cwd: string; id: string; + projectId: string; kind: "local" | "worktree"; } @@ -126,6 +127,7 @@ async function createCharacterizedWorkspace(kind: "local" | "worktree") { return { cwd: workspace.workspaceDirectory, id: workspace.id, + projectId: workspace.projectId, kind, } satisfies CharacterizedWorkspace; } @@ -298,12 +300,21 @@ async function expectProviderDiscoveryAndAgentExecution( await client.sendMessage(agent.id, `characterize ${workspace.kind}`); const finished = await client.waitForFinish(agent.id, 30_000); expect(finished.status).toBe("idle"); - const agentRead = await client.readFile(workspace.cwd, "stdio-agent-output.txt"); + const agentRead = await client.readFile( + workspace.cwd, + "stdio-agent-output.txt", + undefined, + workspace.id, + ); expect(new TextDecoder().decode(agentRead.bytes)).toBe(`characterize ${workspace.kind}\n`); } -async function readWorkspaceTextFile(cwd: string, filePath: string): Promise { - const file = await client.readFile(cwd, filePath); +async function readWorkspaceTextFile( + workspaceId: string, + cwd: string, + filePath: string, +): Promise { + const file = await client.readFile(cwd, filePath, undefined, workspaceId); return new TextDecoder().decode(file.bytes); } @@ -325,8 +336,12 @@ describe("current workspace runtime journeys", () => { expect(archive.error).toBeNull(); expect(existsSync(workspace.cwd)).toBe(true); expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe("after\n"); + await client.restoreWorkspace(workspace.id); + const removal = await client.removeProject(workspace.projectId); + expect(removal.removedWorkspaceIds).toContain(workspace.id); + expect(existsSync(workspace.cwd)).toBe(true); } finally { - await client.archiveWorkspace(workspace.id).catch(() => undefined); + await client.removeProject(workspace.projectId).catch(() => undefined); } }, 120_000); @@ -336,32 +351,62 @@ describe("current workspace runtime journeys", () => { const records = JSON.parse( readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; - expect( - records.find((record) => record.workspaceId === workspace.id)?.runtime, - ).toBeUndefined(); + expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({ + runtimeId: "worktree", + }); await expect .poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 30_000 }) .toMatchObject({ snapshot: { status: "completed", error: null } }); - await expect(readWorkspaceTextFile(workspace.cwd, "setup-output.txt")).resolves.toBe( - "setup complete\n", - ); + await expect( + readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-output.txt"), + ).resolves.toBe("setup complete\n"); await expectFileEditAndWatch(workspace); await expectGitObservation(workspace); await expectTerminalCommand(workspace); await expectProviderDiscoveryAndAgentExecution(workspace); - const removal = await client.archivePaseoWorktree({ - worktreePath: workspace.cwd, + let resolveRestoredObservation!: () => void; + const restoredObservation = new Promise((resolve) => { + resolveRestoredObservation = resolve; + }); + const restoredSubscription = await client.subscribeFile( + { cwd: workspace.cwd, path: "characterized.txt", workspaceId: workspace.id }, + (version) => { + if (version.status === "ready") resolveRestoredObservation(); + }, + ); + if (restoredSubscription.initial.status !== "ready") { + throw new Error("Expected characterized.txt before archive"); + } + + const archive = await client.archiveWorkspace(workspace.id); + expect(archive.error).toBeNull(); + expect(existsSync(workspace.cwd)).toBe(true); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe("after\n"); + + await client.restoreWorkspace(workspace.id); + const restoredWrite = await client.writeFile({ + cwd: workspace.cwd, + path: "characterized.txt", + content: "restored\n", + expectedModifiedAt: restoredSubscription.initial.modifiedAt, + expectedRevision: restoredSubscription.initial.revision, workspaceId: workspace.id, }); - expect(removal).toMatchObject({ success: true, error: null }); - await expect.poll(() => existsSync(workspace.cwd), { timeout: 15_000 }).toBe(false); + expect(restoredWrite.status).toBe("written"); + await expect(restoredObservation).resolves.toBeUndefined(); + restoredSubscription.unsubscribe(); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe( + "restored\n", + ); + + const removal = await client.removeProject(workspace.projectId); + expect(removal.removedWorkspaceIds).toContain(workspace.id); + expect(existsSync(workspace.cwd)).toBe(false); } finally { if (existsSync(workspace.cwd)) { - await client - .archivePaseoWorktree({ worktreePath: workspace.cwd, workspaceId: workspace.id }) - .catch(() => undefined); + await client.removeProject(workspace.projectId).catch(() => undefined); } } }, 120_000); @@ -385,11 +430,18 @@ test("selected worktree provider journey stays behind the public daemon and clie const paseoHome = path.join(paseoHomeRoot, ".paseo"); mkdirSync(paseoHome, { recursive: true }); const workspaceId = `selected-worktree-${Date.now()}`; + let runtimeSelected = true; const seedRuntime = createWorkspaceRuntimeService({ paseoHome, worktreesRoot: path.join(root, "worktrees"), - resolveRuntimeId: async (id) => (id === workspaceId ? "worktree" : null), - persistRuntimeId: async () => undefined, + resolveRuntimeId: async (id) => (id === workspaceId && runtimeSelected ? "worktree" : null), + persistRuntimeId: async () => { + runtimeSelected = true; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async () => { + runtimeSelected = false; + }, }); await seedRuntime.create({ workspaceId, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 5fec6bad05..83241388f6 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -24,6 +24,7 @@ import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; import { deriveProjectKey } from "./project-key.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; const cleanupPaths: string[] = []; @@ -460,7 +461,7 @@ test("an explicit project FK remains unchanged when its worktree comes from anot // POSIX-only: Windows git worktree paths need separate canonicalization coverage. test.skipIf(isPlatform("win32"))( - "reuses an existing worktree and still upserts the workspace", + "keeps repeated worktree requests isolated by workspace identity", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -492,11 +493,11 @@ test.skipIf(isPlatform("win32"))( deps, ); - expect(second.created).toBe(false); - expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); + expect(second.created).toBe(true); + expect(second.worktree.worktreePath).not.toBe(first.worktree.worktreePath); expect(events).toContain(`workspace:${second.workspace.workspaceId}`); - // Creation never dedupes by directory: the same worktree path yields a - // distinct workspace record on the second call. + // Compatibility cwd is presentation data; repeated requests get distinct + // runtime-owned placement and identity. expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId); }, ); @@ -1087,6 +1088,28 @@ function createDeps(options?: { workspaceGitService, logger: createTestLogger(), }); + const runtimeHome = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-runtime-")); + cleanupPaths.push(runtimeHome); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: runtimeHome, + worktreesRoot: path.join(runtimeHome, "worktrees"), + resolveRuntimeId: async (workspaceId) => + workspaces.get(workspaceId)?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + workspaces.set(workspaceId, { + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + }); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }); return { github: createGitHubServiceStub(), @@ -1094,6 +1117,8 @@ function createDeps(options?: { workspaces, workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }; } diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index b32123cb37..780450c701 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,4 +1,3 @@ -import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -6,29 +5,24 @@ import { getRealpathAwareRelativePath } from "../utils/path.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { - createWorktreeCore, type CreateWorktreeCoreDeps, type CreateWorktreeCoreInput, + planWorktreeCore, } from "./worktree-core.js"; -import { - mapWorkspaceRelativeCwdToWorktree, - rollbackCreatedPaseoWorktree, - seedPaseoConfigFile, - validateBranchSlug, - type WorktreeConfig, -} from "../utils/worktree.js"; +import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, normalizeBaseRefName, readPaseoWorktreeMetadata, - writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { WorkspaceRegistry } from "./workspace-registry.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -58,7 +52,9 @@ export interface AttemptFirstAgentBranchAutoNameResult { export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { workspaceGitService: WorkspaceGitService; - workspaceProvisioning: Pick; + workspaceProvisioning: Pick; + workspaceRuntime: WorkspaceRuntimeService; + workspaceRegistry: Pick; } export async function createPaseoWorktree( @@ -73,67 +69,72 @@ async function createPaseoWorktreeWithPriority( deps: CreatePaseoWorktreeDeps, ): Promise { const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); - const createdWorktree = await createWorktreeCore(input, deps); + const plan = await planWorktreeCore(input, deps); + const branchName = resolveIntentBranch(plan.intent); + const workspace = await deps.workspaceProvisioning.reserveRuntimeWorktreeWorkspace({ + sourceCwd: workspaceCwdPlan.inputCwd, + projectId: input.projectId, + repoRoot: plan.repoRoot, + branch: branchName, + baseBranch: resolveIntentBaseBranch(plan.intent), + title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), + expectsInitialAgent: Boolean(input.firstAgentContext), + }); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); - const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ - relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, - targetWorktreePath: createdWorktree.worktree.worktreePath, + const placement = await deps.workspaceRuntime.create({ + workspaceId: workspace.workspaceId, + runtimeId: "worktree", + project: { id: workspace.projectId, source: { kind: "host-directory", path: plan.repoRoot } }, + placement: { + kind: "resolved-worktree", + source: plan.intent, + worktreeSlug: plan.worktreeSlug, + ...(workspaceCwdPlan.relativeWorkspaceCwd + ? { relativeCwd: workspaceCwdPlan.relativeWorkspaceCwd } + : {}), + }, + markFirstAgentBranchAutoName: plan.intent.kind === "branch-off", + seedPaseoConfigFrom: workspaceCwdPlan.inputCwd, }); - if (!(await isDirectory(workspaceCwd))) { - throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`); + if (!placement.hostVisiblePath) { + throw new Error(`Worktree runtime has no host-visible path: ${workspace.workspaceId}`); } - - if (createdWorktree.created) { - await seedPaseoConfigFile({ - sourceCwd: workspaceCwdPlan.inputCwd, - targetCwd: workspaceCwd, - }); + const worktreeRoot = resolvePublicWorktreeRoot( + placement.hostVisiblePath, + workspaceCwdPlan.relativeWorkspaceCwd, + ); + const persistedWorkspace = await deps.workspaceRegistry.update( + workspace.workspaceId, + (record) => ({ ...record, worktreeRoot }), + ); + if (!persistedWorkspace) { + throw new Error(`Created workspace record is missing: ${workspace.workspaceId}`); } - const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ - sourceCwd: workspaceCwdPlan.inputCwd, - projectId: input.projectId, - repoRoot: createdWorktree.repoRoot, - cwd: workspaceCwd, - worktreeRoot: createdWorktree.worktree.worktreePath, - branch: createdWorktree.worktree.branchName || null, - baseBranch: resolveIntentBaseBranch(createdWorktree.intent), - title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), - expectsInitialAgent: Boolean(input.firstAgentContext), - }); - - deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); + const worktree = { + branchName, + worktreePath: worktreeRoot, + }; + deps.github.invalidate({ cwd: worktree.worktreePath }); return { - worktree: createdWorktree.worktree, - intent: createdWorktree.intent, - workspace, - repoRoot: createdWorktree.repoRoot, - created: createdWorktree.created, + worktree, + intent: plan.intent, + workspace: persistedWorkspace, + repoRoot: plan.repoRoot, + created: true, }; } catch (error) { - if (!createdWorktree.created) { - throw error; - } - return rollbackCreatedPaseoWorktree( - { - cwd: createdWorktree.repoRoot, - worktreePath: createdWorktree.worktree.worktreePath, - ...(input.runSetup === false ? { teardownCwds: [] } : {}), - paseoHome: input.paseoHome, - worktreesBaseRoot: input.worktreesRoot, - }, - error, - ); + await deps.workspaceRuntime + .destroy(workspace.workspaceId) + .catch(() => deps.workspaceRegistry.remove(workspace.workspaceId)); + throw error; } } -async function isDirectory(targetPath: string): Promise { - try { - return (await stat(targetPath)).isDirectory(); - } catch { - return false; - } +function resolvePublicWorktreeRoot(compatibilityCwd: string, relativeWorkspaceCwd: string): string { + if (!relativeWorkspaceCwd) return compatibilityCwd; + const depth = relativeWorkspaceCwd.split(/[\\/]/u).filter(Boolean).length; + return resolve(compatibilityCwd, ...Array.from({ length: depth }, () => "..")); } async function planWorkspaceCwdForWorktree( @@ -248,19 +249,6 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { - createdWorktree: Awaited>; -}): void { - const { createdWorktree } = options; - if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { - return; - } - - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { - placeholderBranchName: createdWorktree.worktree.branchName, - }); -} - // The base branch is normalized to match worktree.json's baseRefName (origin/ // stripped). checkout-branch worktrees have no distinct base, so they stay null. function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null { @@ -275,3 +263,14 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null return null; } } + +function resolveIntentBranch(intent: WorktreeCreationIntent): string { + switch (intent.kind) { + case "branch-off": + case "checkout-branch": + return intent.branchName; + case "checkout-change-request": + case "checkout-github-pr": + return intent.localBranchName ?? intent.headRef; + } +} diff --git a/packages/server/src/server/resolve-workspace-id-for-path.test.ts b/packages/server/src/server/resolve-workspace-id-for-path.test.ts index 162fdf1c5a..0d0e39d767 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.test.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.test.ts @@ -27,13 +27,26 @@ function createWorkspaceRecord( // The per-id status law is exercised in workspace-directory.test.ts. describe("resolveWorkspaceIdForPath", () => { - test("returns a single id when multiple workspaces share the exact cwd", () => { - const id = resolveWorkspaceIdForPath("/workspace/project", [ - createWorkspaceRecord("/workspace/project", "ws-1"), - createWorkspaceRecord("/workspace/project", "ws-2"), - createWorkspaceRecord("/workspace/other", "ws-3"), - ]); - expect(["ws-1", "ws-2"]).toContain(id); + test("fails closed when multiple workspaces share the exact compatibility cwd", () => { + expect( + resolveWorkspaceIdForPath("/workspace/project", [ + createWorkspaceRecord("/workspace/project", "ws-1"), + createWorkspaceRecord("/workspace/project", "ws-2"), + createWorkspaceRecord("/workspace/other", "ws-3"), + ]), + ).toBeNull(); + }); + + test("uses only an explicit host-visible path for a selected workspace", () => { + const selected = createWorkspaceRecord("/workspace", "docker-workspace"); + selected.runtime = { runtimeId: "docker" }; + selected.hostVisiblePath = null; + expect(resolveWorkspaceIdForPath("/workspace", [selected])).toBeNull(); + + selected.hostVisiblePath = "/host/worktrees/selected"; + expect(resolveWorkspaceIdForPath("/host/worktrees/selected", [selected])).toBe( + "docker-workspace", + ); }); test("resolves an exact archived workspace match for archive-by-path", () => { diff --git a/packages/server/src/server/resolve-workspace-id-for-path.ts b/packages/server/src/server/resolve-workspace-id-for-path.ts index 58b7572cff..7a61bdd02b 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.ts @@ -12,25 +12,31 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; // agents under a workspace — those are keyed by `workspaceId`, and git facts // derive from a workspace's OWN cwd (id → cwd). // -// Resolution: an exact directory match wins; otherwise the deepest enclosing -// workspace directory, never the home directory; null when nothing encloses it. +// Selected records participate only through their explicit host-visible-path +// projection. Resolution fails closed on ambiguity. Otherwise an exact path wins, +// then the deepest enclosing path, never the home directory. export function resolveWorkspaceIdForPath( cwd: string, workspaces: Iterable, ): string | null { const workspaceRecords = Array.from(workspaces); const resolvedCwd = resolve(cwd); - const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd); - if (exactMatch) { - return exactMatch.workspaceId; + const candidates = workspaceRecords.flatMap((workspace) => { + const visiblePath = workspace.runtime ? workspace.hostVisiblePath : workspace.cwd; + return visiblePath ? [{ workspace, visiblePath: resolve(visiblePath) }] : []; + }); + const exactMatches = candidates.filter((candidate) => candidate.visiblePath === resolvedCwd); + if (exactMatches.length !== 0) { + return exactMatches.length === 1 ? (exactMatches[0]?.workspace.workspaceId ?? null) : null; } const userHome = resolve(homedir()); let bestMatchLength = 0; let bestMatch: PersistedWorkspaceRecord | null = null; - for (const workspace of workspaceRecords) { + let bestMatchIsAmbiguous = false; + for (const { workspace, visiblePath } of candidates) { if (workspace.archivedAt) continue; - const workspaceCwd = resolve(workspace.cwd); + const workspaceCwd = visiblePath; if (workspaceCwd === userHome) continue; const prefix = workspaceCwd.endsWith(sep) ? workspaceCwd : `${workspaceCwd}${sep}`; if (!resolvedCwd.startsWith(prefix)) { @@ -39,8 +45,11 @@ export function resolveWorkspaceIdForPath( if (workspaceCwd.length > bestMatchLength) { bestMatchLength = workspaceCwd.length; bestMatch = workspace; + bestMatchIsAmbiguous = false; + } else if (workspaceCwd.length === bestMatchLength) { + bestMatchIsAmbiguous = true; } } - return bestMatch?.workspaceId ?? null; + return bestMatchIsAmbiguous ? null : (bestMatch?.workspaceId ?? null); } diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 3c963f907c..44c806634a 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -544,8 +544,18 @@ describe("script-status-projection", () => { serviceProxy: routeStore, runtimeStore, daemonPort: 6767, - resolveWorkspaceDirectory: async (requestedWorkspaceId) => - requestedWorkspaceId === "workspace-emitter" ? workspace.repoDir : null, + resolveWorkspaceProjection: async (requestedWorkspaceId) => + requestedWorkspaceId === "workspace-emitter" + ? { + workspaceDirectory: "/workspace", + paseoConfig: { + scripts: { + api: { type: "service", command: "npm run api" }, + typecheck: { command: "npm run typecheck" }, + }, + }, + } + : null, logger: createTestLogger(), }); diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2d02751fc6..9e9dbba466 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -278,7 +278,7 @@ export function createScriptStatusEmitter({ runtimeStore, daemonPort, serviceProxyPublicBaseUrl, - resolveWorkspaceDirectory, + resolveWorkspaceProjection, logger, }: { sessions: () => SessionEmitter[]; @@ -286,13 +286,18 @@ export function createScriptStatusEmitter({ runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null | (() => number | null); serviceProxyPublicBaseUrl?: string | null; - resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; + resolveWorkspaceProjection: ( + workspaceId: string, + ) => + | { workspaceDirectory: string; paseoConfig: PaseoConfig | null } + | null + | Promise<{ workspaceDirectory: string; paseoConfig: PaseoConfig | null } | null>; logger: Logger; }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { return (workspaceId, scripts) => { void (async () => { - const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); - if (!workspaceDirectory) { + const projection = await resolveWorkspaceProjection(workspaceId); + if (!projection) { return; } @@ -303,8 +308,8 @@ export function createScriptStatusEmitter({ const projected = buildWorkspaceScriptPayloads({ workspaceId, - workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + workspaceDirectory: projection.workspaceDirectory, + paseoConfig: projection.paseoConfig, serviceProxy, runtimeStore, daemonPort: resolvedDaemonPort, @@ -320,6 +325,8 @@ export function createScriptStatusEmitter({ for (const session of sessions()) { session.emit(message); } - })(); + })().catch((error) => { + logger.warn({ err: error, workspaceId }, "Failed to emit workspace script status"); + }); }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f98b37d2a2..1504d2b89a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -640,6 +640,7 @@ export class Session { private unsubscribeProjectMutations: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; private registryMutationQueue: Promise = Promise.resolve(); + private readonly workspacesAwaitingInitialAgent = new Set(); private isCleanedUp = false; private viewedTimelineAgentIds = new Set(); private readonly viewedTimelineAgentIdsBySource = new Map>(); @@ -801,6 +802,10 @@ export class Session { getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), getProject: (projectId) => this.projectRegistry.get(projectId), isDirectory: (path) => this.filesystem.isDirectory(path), + inspectRuntime: (workspaceId) => { + if (!this.workspaceRuntime) return Promise.resolve("missing"); + return this.workspaceRuntime.inspect(workspaceId).then((inspection) => inspection.status); + }, unarchiveWorkspace: async (workspace) => { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, @@ -1204,22 +1209,26 @@ export class Session { workspace: WorkspaceDescriptorPayload, optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { - if (this.workspaceUpdatesSubscription) { - await this.emitWorkspaceUpdatesForWorkspaceIds( - [workspace.id], - optimisticStatus ? { optimisticStatus } : undefined, - ); - return; + try { + if (this.workspaceUpdatesSubscription) { + await this.emitWorkspaceUpdatesForWorkspaceIds( + [workspace.id], + optimisticStatus ? { optimisticStatus } : undefined, + ); + return; + } + // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. + // Older clients create before subscribing and require the causal update beside the response. + this.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace, + }, + }); + } finally { + if (optimisticStatus) this.workspacesAwaitingInitialAgent.delete(workspace.id); } - // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. - // Older clients create before subscribing and require the causal update beside the response. - this.emit({ - type: "workspace_update", - payload: { - kind: "upsert", - workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace, - }, - }); } markWorkspaceArchivingForExternalMutation( @@ -1459,8 +1468,12 @@ export class Session { mutation.kind === "remove" || mutation.workspace?.archivedAt ) { + this.workspacesAwaitingInitialAgent.delete(mutation.workspaceId); this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); } else { + if (mutation.expectsInitialAgent) { + this.workspacesAwaitingInitialAgent.add(mutation.workspaceId); + } await this.syncWorkspaceMutationObserver(mutation); } if (this.isCleanedUp) { @@ -1468,7 +1481,9 @@ export class Session { } await this.emitWorkspaceUpdatesForWorkspaceIds( [mutation.workspaceId], - mutation.expectsInitialAgent ? { optimisticStatus: "running" } : undefined, + this.workspacesAwaitingInitialAgent.has(mutation.workspaceId) + ? { optimisticStatus: "running" } + : undefined, ); } catch (error) { this.sessionLogger.warn( @@ -2844,20 +2859,33 @@ export class Session { } const removedWorkspaceIds: string[] = []; + const destroyRuntimeWorkspace = async (workspaceId: string): Promise => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.destroy(workspaceId); + }; try { - for (const workspaceId of activeWorkspaceIds) { - await archiveWorkspaceContents( - { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - killTerminalsForWorkspace: (id) => - this.terminalController.killTerminalsForWorkspace(id), - sessionLogger: this.sessionLogger, - }, - workspaceId, - ); - await this.archiveWorkspaceRecord(workspaceId); - removedWorkspaceIds.push(workspaceId); + for (const workspace of projectWorkspaces) { + if (!workspace.archivedAt) { + await archiveWorkspaceContents( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + killTerminalsForWorkspace: (id) => + this.terminalController.killTerminalsForWorkspace(id), + sessionLogger: this.sessionLogger, + }, + workspace.workspaceId, + ); + } + if (workspace.runtime) { + await destroyRuntimeWorkspace(workspace.workspaceId); + removedWorkspaceIds.push(workspace.workspaceId); + } else if (!workspace.archivedAt) { + await this.archiveWorkspaceRecord(workspace.workspaceId); + removedWorkspaceIds.push(workspace.workspaceId); + } } await this.projectRegistry.remove(resolvedProjectId); @@ -3192,10 +3220,7 @@ export class Session { createdWorktree, workspacePromptTitle, }); - const resolvedCwd = resolve(resolvedIntent.config.cwd); - if (!(await this.filesystem.isDirectory(resolvedCwd))) { - throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`); - } + await this.requireCreateAgentWorkspaceReady(resolvedIntent); const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -3289,6 +3314,26 @@ export class Session { } } + private async requireCreateAgentWorkspaceReady( + resolvedIntent: ResolvedSessionCreateAgentIntent, + ): Promise { + const workspace = await this.workspaceRegistry.get(resolvedIntent.intent.workspaceId); + if (!workspace?.runtime) { + const cwd = resolve(resolvedIntent.config.cwd); + if (!(await this.filesystem.isDirectory(cwd))) { + throw new Error(`Working directory does not exist or is not a directory: ${cwd}`); + } + return; + } + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspace.workspaceId}`); + } + const inspection = await this.workspaceRuntime.inspect(workspace.workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspace.workspaceId}`); + } + } + private async resolveSessionCreateAgentIntent(input: { request: CreateAgentRequestMessage; createdWorktree: CreatePaseoWorktreeWorkflowResult | null; @@ -4467,6 +4512,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + hostVisiblePath: workspace.runtime ? (workspace.hostVisiblePath ?? undefined) : workspace.cwd, worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, @@ -4732,7 +4778,15 @@ export class Session { } private async restoreWorkspaceAndEmit(workspaceId: string): Promise { - await this.workspaceRecovery.restore(workspaceId); + const existing = await this.workspaceRegistry.get(workspaceId); + if (existing?.runtime) { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.restore(workspaceId); + } else { + await this.workspaceRecovery.restore(workspaceId); + } const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace) { throw new Error(`Recovered workspace record not found: ${workspaceId}`); @@ -4774,6 +4828,7 @@ export class Session { resolveDefaultBranch?: (repoRoot: string) => Promise; }, ): Promise { + if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed"); const result = await createPaseoWorktree(input, { github: this.github, ...(options?.resolveDefaultBranch @@ -4781,6 +4836,8 @@ export class Session { : {}), workspaceGitService: this.workspaceGitService, workspaceProvisioning: this.workspaceProvisioning, + workspaceRuntime: this.workspaceRuntime, + workspaceRegistry: this.workspaceRegistry, }); void Promise.all([ this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"), @@ -4801,20 +4858,31 @@ export class Session { .map((workspace) => ({ workspaceId: workspace.workspaceId, cwd: workspace.cwd, + hostVisiblePath: workspace.hostVisiblePath, kind: workspace.kind, worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, mainRepoRoot: workspace.mainRepoRoot, + runtimeId: workspace.runtime?.runtimeId ?? null, })); } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { const archiveTimestamp = archivedAt ?? new Date().toISOString(); - const existingWorkspace = await archivePersistedWorkspaceRecord({ - workspaceId, - archivedAt: archiveTimestamp, - workspaceRegistry: this.workspaceRegistry, - }); + const currentWorkspace = await this.workspaceRegistry.get(workspaceId); + const existingWorkspace = currentWorkspace?.runtime + ? await (async () => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.archive(workspaceId); + return currentWorkspace; + })() + : await archivePersistedWorkspaceRecord({ + workspaceId, + archivedAt: archiveTimestamp, + workspaceRegistry: this.workspaceRegistry, + }); if (!existingWorkspace) { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); return; @@ -6031,6 +6099,10 @@ export class Session { onScriptsChanged: (workspaceId, workspaceDirectory) => { this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory); }, + runWorkspaceSetup: async (workspaceId, signal) => { + if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed"); + await this.workspaceRuntime.runSetup(workspaceId, signal); + }, }, input, options, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 0e15421e74..eb28ff73f7 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -19,6 +19,10 @@ import { createTestLogger } from "../test-utils/test-logger.js"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import { + bindProviderWorkspace, + resolveProviderPlacementPolicy, +} from "./agent/providers/workspace/index.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; @@ -714,15 +718,18 @@ function createSessionForWorkspaceTests( const workspace = await workspaceRegistry.get(workspaceId); return workspace?.runtime?.runtimeId ?? null; }, - persistRuntimeId: async (workspaceId, runtimeId) => { + persistRuntimeId: async (workspaceId, runtimeId, placement) => { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); - if (workspace.runtime?.runtimeId === runtimeId) return; await workspaceRegistry.upsert({ ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, runtime: { runtimeId }, }); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: (workspaceId) => workspaceRegistry.remove(workspaceId), }), filesystem: { isDirectory: async () => true }, chatService: asChatService(), @@ -929,17 +936,22 @@ test("create_agent_request keeps requested child cwd when grouped under an exist error: vi.fn(), }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + let workspaceRegistry!: FileBackedWorkspaceRegistry; const agentManager = new AgentManager({ clients: { codex: new CreateAgentTestClient() }, registry: agentStorage, logger: asSessionLogger(logger), idFactory: () => "00000000-0000-4000-8000-000000000551", + resolveProviderWorkspace: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + return workspace?.runtime ? undefined : null; + }, }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), asSessionLogger(logger), ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( + workspaceRegistry = new FileBackedWorkspaceRegistry( path.join(workdir, "workspaces.json"), asSessionLogger(logger), ); @@ -1084,12 +1096,6 @@ test("create_agent_request launches from an exact subdirectory in a created work error: vi.fn(), }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); - const agentManager = new AgentManager({ - clients: { codex: new CreateAgentTestClient() }, - registry: agentStorage, - logger: asSessionLogger(logger), - idFactory: () => "00000000-0000-4000-8000-000000000552", - }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), asSessionLogger(logger), @@ -1098,6 +1104,43 @@ test("create_agent_request launches from an exact subdirectory in a created work path.join(workdir, "workspaces.json"), asSessionLogger(logger), ); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(workdir, "paseo-home"), + worktreesRoot: path.join(workdir, "worktrees"), + resolveRuntimeId: async (workspaceId) => + (await workspaceRegistry.get(workspaceId))?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const updated = await workspaceRegistry.update(workspaceId, (workspace) => ({ + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + }, + beginWorkspaceDeletion: async (workspaceId) => { + await workspaceRegistry.requestDeletion(workspaceId, new Date().toISOString()); + }, + removeWorkspaceRecord: (workspaceId) => workspaceRegistry.remove(workspaceId), + }); + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000552", + resolveProviderWorkspace: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace?.runtime) return null; + return bindProviderWorkspace({ + runtime: await workspaceRuntime.bind(workspaceId), + cwd: ".", + policy: resolveProviderPlacementPolicy({ + runtimeId: workspace.runtime.runtimeId, + hostEnvironment: process.env, + }), + }); + }, + }); const workspaceGitService = createNoopWorkspaceGitService({ getCheckout: async (cwd: string) => ({ cwd, @@ -1147,6 +1190,7 @@ test("create_agent_request launches from an exact subdirectory in a created work agentStorage, projectRegistry, workspaceRegistry, + workspaceRuntime, chatService: asChatService(), scheduleService: asScheduleService(), loopService: asLoopService(), @@ -1238,6 +1282,7 @@ test("create_agent_request does not title an existing workspace from the agent p registry: agentStorage, logger: asSessionLogger(logger), idFactory: () => "00000000-0000-4000-8000-000000000552", + resolveProviderWorkspace: async () => null, }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), @@ -3994,6 +4039,13 @@ test("create paseo worktree response preserves an explicit non-Git project", asy ) => { workspaces.set(record.workspaceId, record); }; + session.workspaceRegistry.update = async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); session.projectRegistry.getOrCreateActiveByRoot = async (input) => { @@ -8928,6 +8980,13 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe upsert: async (record) => { workspaces.set(record.workspaceId, record); }, + update: async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }, archive: async () => {}, remove: async () => {}, }; @@ -8965,6 +9024,12 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe gitRuntime: { currentBranch: fixture.headRef }, }); const workspaceDirectory = response?.payload.workspace?.workspaceDirectory as string; + const persistedWorkspace = workspaces.get(response?.payload.workspace?.id ?? ""); + expect(persistedWorkspace).toMatchObject({ + cwd: workspaceDirectory, + hostVisiblePath: workspaceDirectory, + runtime: { runtimeId: "worktree" }, + }); expect(readCurrentBranch(workspaceDirectory)).toBe(fixture.headRef); expect(existsSync(path.join(workspaceDirectory, fixture.prFileName))).toBe(true); } finally { diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index c9f14e9cb5..fee5f78a0b 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -111,6 +111,10 @@ describe("WorkspaceFilesSession", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await runtime.create({ workspaceId: "selected-workspace", @@ -222,6 +226,72 @@ describe("WorkspaceFilesSession", () => { await runtime.destroy(record.workspaceId); }); + test("never selects a workspace runtime from compatibility cwd", async () => { + const cwd = makeDir("workspace-files-selected-cwd-"); + writeFileSync(join(cwd, "runtime.txt"), "runtime only\n"); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: makeDir("workspace-files-selected-cwd-home-"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const now = new Date().toISOString(); + const record: PersistedWorkspaceRecord = { + workspaceId: "selected-workspace", + projectId: "project", + cwd, + kind: "directory", + displayName: "selected", + title: null, + branch: null, + worktreeRoot: null, + baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + runtime: { runtimeId: "local" }, + createdAt: now, + updatedAt: now, + archivedAt: null, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }; + const { subsystem, emitted } = makeSubsystem({ + selectedWorkspace: { record, runtime }, + }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "runtime.txt", + mode: "file", + requestId: "selected-without-id", + }); + + expect(emitted).toEqual([ + expect.objectContaining({ + type: "file_explorer_response", + payload: expect.objectContaining({ + requestId: "selected-without-id", + error: "workspaceId is required for a selected workspace file operation", + }), + }), + ]); + await subsystem.dispose(); + await runtime.destroy(record.workspaceId); + }); + test("lists directory entries", async () => { const cwd = makeDir("workspace-files-list-"); writeFileSync(join(cwd, "a.txt"), "alpha"); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 387a4ec025..f6b1f4acce 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,4 +1,5 @@ import type pino from "pino"; +import { areEquivalentPaths } from "../../../utils/path.js"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { encodeFileTransferFrame, @@ -18,7 +19,6 @@ import type { import type { WorkspaceFileKind, WorkspaceFiles } from "../../workspace-helper/index.js"; import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js"; import type { WorkspaceRegistry } from "../../workspace-registry.js"; -import { areEquivalentPaths } from "../../../utils/path.js"; import { FileUploadStore } from "../../file-upload/index.js"; import type { DownloadTokenStore } from "../../file-download/token-store.js"; import { @@ -534,16 +534,17 @@ export class WorkspaceFilesSession { if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); return workspace.runtime ? this.workspaceRuntime.files(workspaceId) : null; } - const matches = (await this.workspaceRegistry.list()).filter( - (workspace) => - workspace.archivedAt === null && - workspace.runtime && - areEquivalentPaths(workspace.cwd, cwd), - ); - if (matches.length > 1) { - throw new Error("workspaceId is required because the workspace path is not unique"); + if ( + (await this.workspaceRegistry.list()).some( + (workspace) => + workspace.archivedAt === null && + workspace.runtime && + areEquivalentPaths(workspace.cwd, cwd), + ) + ) { + throw new Error("workspaceId is required for a selected workspace file operation"); } - return matches[0] ? this.workspaceRuntime.files(matches[0].workspaceId) : null; + return null; } private async emitStream( 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 index 4a543dd5da..17a5c76b42 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -47,6 +47,16 @@ export interface CreateWorktreeWorkspaceInput { expectsInitialAgent?: boolean; } +export interface ReserveRuntimeWorktreeWorkspaceInput { + sourceCwd: string; + projectId?: string; + repoRoot: string; + branch: string | null; + baseBranch: string | null; + title: string | null; + expectsInitialAgent?: boolean; +} + export interface WorkspaceProvisioningService { runInImportWorkspace( input: ImportWorkspaceInput, @@ -63,6 +73,9 @@ export interface WorkspaceProvisioningService { createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, ): Promise; + reserveRuntimeWorktreeWorkspace( + input: ReserveRuntimeWorktreeWorkspaceInput, + ): Promise; findOrCreateProjectForDirectory(cwd: string): Promise; ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, @@ -250,6 +263,38 @@ export function createWorkspaceProvisioningService(deps: { return workspace; } + async function reserveRuntimeWorktreeWorkspace( + input: ReserveRuntimeWorktreeWorkspaceInput, + ): Promise { + const sourceCwd = resolve(input.sourceCwd); + const repoRoot = resolve(input.repoRoot); + const project = await resolveSourceProjectForWorktree({ + sourceCwd, + projectId: input.projectId, + repoRoot, + }); + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ + source: "runtime_worktree", + cwd: sourceCwd, + branch: input.branch, + baseBranch: input.baseBranch, + mainRepoRoot: repoRoot, + }), + title: input.title, + runtime: { runtimeId: "worktree" }, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: input.expectsInitialAgent, + }); + return workspace; + } + async function resolveSourceProjectForWorktree(input: { sourceCwd: string; projectId?: string; @@ -454,6 +499,7 @@ export function createWorkspaceProvisioningService(deps: { resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory, createWorkspaceForWorktree, + reserveRuntimeWorktreeWorkspace, findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, }; diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1d98a9128c..a10ec83737 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -65,6 +65,7 @@ export function createWorkspaceRecoveryService(deps: { getProject: (projectId: string) => Promise; isDirectory: (path: string) => Promise; unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise; + inspectRuntime?: (workspaceId: string) => Promise<"missing" | "paused" | "ready" | "error">; }): WorkspaceRecoveryService { async function resolveRecovery( workspaceId: string, @@ -97,6 +98,27 @@ export function createWorkspaceRecoveryService(deps: { }; } + if (workspace.runtime) { + if (!deps.inspectRuntime) { + return { + kind: "unavailable", + workspaceId, + reason: "workspace_directory_missing", + message: "The workspace runtime is not available on this host.", + }; + } + const runtimeStatus = await deps.inspectRuntime(workspaceId); + if (runtimeStatus === "ready" || runtimeStatus === "paused") { + return createRecoveryPlan({ action: "unarchive", workspace }); + } + return { + kind: "unavailable", + workspaceId, + reason: "workspace_directory_missing", + message: `The archived workspace runtime is ${runtimeStatus}.`, + }; + } + if (await deps.isDirectory(workspace.cwd)) { return createRecoveryPlan({ action: "unarchive", workspace }); } diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 78675e03cf..539048a94e 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -195,6 +195,10 @@ describe("buildSnapshot", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await workspaceRuntime.create({ workspaceId: "ws-runtime-config", @@ -208,11 +212,18 @@ describe("buildSnapshot", () => { cwd: dir, runtime: { runtimeId: "local" }, } as PersistedWorkspaceRecord; - const { service } = buildService({ workspace, workspaceRuntime }); + const { service, spawnCalls } = buildService({ workspace, workspaceRuntime }); await expect(service.buildSnapshot(workspace)).resolves.toEqual([ expect.objectContaining({ scriptName: "app", type: "script" }), ]); + await service.launch({ workspaceId: workspace.workspaceId, scriptName: "app" }); + expect(spawnCalls[0]).toMatchObject({ + workspaceId: workspace.workspaceId, + runtimeCwd: ".", + paseoConfig: { scripts: { app: { command: "npm run app" } } }, + }); + expect(spawnCalls[0]?.runtime).toBeDefined(); await workspaceRuntime.destroy(workspace.workspaceId); }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 964c73f978..4d7e376552 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -52,6 +52,40 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } +export async function readWorkspacePaseoConfig(input: { + workspace: PersistedWorkspaceRecord; + workspaceRuntime?: WorkspaceRuntimeService | null; + logger: pino.Logger; +}): Promise { + const { workspace, workspaceRuntime, logger } = input; + if (!workspace.runtime) return readPaseoConfigForProjection(workspace.cwd, logger); + if (!workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspace.workspaceId}`); + } + const files = workspaceRuntime.files(workspace.workspaceId); + const version = await files.stat("paseo.json"); + if (version.status === "missing") return null; + if (version.status === "error") { + logger.warn( + { workspaceId: workspace.workspaceId, error: version.error }, + "Failed to read runtime paseo.json; treating workspace as having no scripts", + ); + return null; + } + try { + const file = await files.read("paseo.json"); + const chunks: Buffer[] = []; + for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); + return PaseoConfigSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch (error) { + logger.warn( + { workspaceId: workspace.workspaceId, err: error }, + "Failed to parse runtime paseo.json; treating workspace as having no scripts", + ); + return null; + } +} + export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; @@ -118,7 +152,7 @@ export function createWorkspaceScriptsService(deps: { return buildWorkspaceScriptPayloads({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, - paseoConfig: await readWorkspaceConfig(workspace), + paseoConfig: await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, @@ -128,37 +162,6 @@ export function createWorkspaceScriptsService(deps: { }); } - async function readWorkspaceConfig( - workspace: PersistedWorkspaceRecord, - ): Promise { - if (!workspace.runtime) return readPaseoConfigForProjection(workspace.cwd, logger); - if (!workspaceRuntime) { - throw new Error(`Workspace runtime is not available: ${workspace.workspaceId}`); - } - const files = workspaceRuntime.files(workspace.workspaceId); - const version = await files.stat("paseo.json"); - if (version.status === "missing") return null; - if (version.status === "error") { - logger.warn( - { workspaceId: workspace.workspaceId, error: version.error }, - "Failed to read runtime paseo.json; treating workspace as having no scripts", - ); - return null; - } - try { - const file = await files.read("paseo.json"); - const chunks: Buffer[] = []; - for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); - return PaseoConfigSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); - } catch (error) { - logger.warn( - { workspaceId: workspace.workspaceId, err: error }, - "Failed to parse runtime paseo.json; treating workspace as having no scripts", - ); - return null; - } - } - async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { try { const workspace = await workspaceRegistry.get(workspaceId); @@ -204,8 +207,21 @@ export function createWorkspaceScriptsService(deps: { const workspace = await getWorkspace(input.workspaceId); const project = await projectRegistry.get(workspace.projectId); const gitMetadata = resolveGitMetadata(workspace, project); + const paseoConfig = workspace.runtime + ? await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }) + : undefined; + if (workspace.runtime && !paseoConfig) { + throw new Error("Workspace does not contain a valid paseo.json"); + } const result = await spawnWorkspaceScript({ repoRoot: workspace.cwd, + ...(workspace.runtime + ? { + runtimeCwd: ".", + paseoConfig: paseoConfig!, + runtime: await workspaceRuntime!.bind(workspace.workspaceId), + } + : {}), workspaceId: workspace.workspaceId, projectSlug: gitMetadata.projectSlug, branchName: gitMetadata.currentBranch, diff --git a/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs new file mode 100644 index 0000000000..8154bf16aa --- /dev/null +++ b/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import readline from "node:readline"; + +const [helper, counterPath, ...args] = process.argv.slice(2); +if (!helper || !counterPath) throw new Error("helper and counter path are required"); + +if (args[0] !== "watch") { + await forward(); +} else { + const launch = Number(await readFile(counterPath, "utf8").catch(() => "0")) + 1; + await writeFile(counterPath, String(launch)); + if (launch !== 2) await forward(); + else await failSecondSubscription(); +} + +async function forward() { + const child = spawn(process.execPath, [helper, ...args], { stdio: "inherit" }); + const exit = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + if (exit.signal) process.kill(process.pid, exit.signal); + process.exitCode = exit.code ?? 1; +} + +async function failSecondSubscription() { + process.stdout.write(`${JSON.stringify({ protocolVersion: 1, type: "ready" })}\n`); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + let subscriptions = 0; + for await (const line of lines) { + const message = JSON.parse(line); + if (message.type === "subscribe") { + subscriptions += 1; + if (subscriptions === 2) { + process.exitCode = 41; + return; + } + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "unsubscribe") { + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "unsubscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "close") return; + } +} diff --git a/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs new file mode 100644 index 0000000000..94b1567d3e --- /dev/null +++ b/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import readline from "node:readline"; + +const [helper, counterPath, sharedRoot, ...args] = process.argv.slice(2); +if (!helper || !counterPath || !sharedRoot) { + throw new Error("helper, counter path, and shared root are required"); +} + +const rootIndex = args.indexOf("--root"); +const observedRoot = rootIndex < 0 ? null : args[rootIndex + 1]; +if (args[0] !== "watch" || observedRoot !== sharedRoot) { + await forward(); +} else { + const launch = Number(await readFile(counterPath, "utf8").catch(() => "0")) + 1; + await writeFile(counterPath, String(launch)); + if (launch === 2) await failSecondSubscription(); + else await forward(); +} + +async function forward() { + const child = spawn(process.execPath, [helper, ...args], { stdio: "inherit" }); + const exit = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + if (exit.signal) process.kill(process.pid, exit.signal); + process.exitCode = exit.code ?? 1; +} + +async function failSecondSubscription() { + process.stdout.write(`${JSON.stringify({ protocolVersion: 1, type: "ready" })}\n`); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + let subscriptions = 0; + for await (const line of lines) { + const message = JSON.parse(line); + if (message.type === "subscribe") { + subscriptions += 1; + if (subscriptions === 2) { + process.exitCode = 41; + return; + } + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "unsubscribe") { + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "unsubscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "close") return; + } +} diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs index 020daea96e..e63d7a8b51 100644 --- a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs +++ b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs @@ -33,7 +33,15 @@ try { const request = JSON.parse(await readStream(process.stdin)); switch (operation) { case "create": - writeJson({ protocolVersion: 1, type: "state", state: await create(workspaceId, request) }); + { + const state = await create(workspaceId, request); + writeJson({ + protocolVersion: 1, + type: "state", + state, + placement: publicPlacement(state), + }); + } break; case "inspect": writeJson({ @@ -47,11 +55,15 @@ try { writeJson({ protocolVersion: 1, type: "ok" }); break; case "resume": - writeJson({ - protocolVersion: 1, - type: "state", - state: await setLifecycle(workspaceId, request.options, "ready"), - }); + { + const state = await setLifecycle(workspaceId, request.options, "ready"); + writeJson({ + protocolVersion: 1, + type: "state", + state, + placement: publicPlacement(state), + }); + } break; case "destroy": await rm(stateFile(workspaceId, request.options), { force: true }); @@ -85,7 +97,13 @@ async function create(id, request) { async function inspect(id, options) { await applyInspectBarrier(options); const state = await readState(id, options); - return state ? { status: state.lifecycle, state } : { status: "missing" }; + return state + ? { status: state.lifecycle, state, placement: publicPlacement(state) } + : { status: "missing" }; +} + +function publicPlacement(state) { + return { cwd: state.root, hostVisiblePath: state.root }; } async function setLifecycle(id, options, lifecycle) { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f0b5165031..835b774efd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -335,6 +335,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { upsert: async () => {}, archive: async () => {}, remove: async () => {}, + requestDeletion: async () => {}, }; } diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 7e2b0c2064..22bf54826a 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -248,7 +248,7 @@ test("renaming a workspace updates every subscribed client", async () => { } }); -test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { +test("archive preserves an owned worktree and permanent project deletion removes it", async () => { const repoDir = createGitRepo(); const keepResult = await ctx.client.createWorkspace({ @@ -261,43 +261,18 @@ test("archiving the last reference to a worktree removes it from disk regardless const keepDir = keepWorkspace.workspaceDirectory; expect(existsSync(keepDir)).toBe(true); - // Last reference, deleteWorktreeFromDisk omitted (defaults ignored) → dir removed. const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir }); expect(keepArchive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(keepWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - await expect.poll(() => existsSync(keepDir), { timeout: 10000, interval: 100 }).toBe(false); + expect((await activeWorkspaceIds()).has(keepWorkspace.id)).toBe(false); + expect(existsSync(keepDir)).toBe(true); - const deleteResult = await ctx.client.createWorkspace({ - source: { - kind: "worktree", - cwd: repoDir, - worktreeSlug: "delete-from-disk", - baseBranch: "main", - }, - }); - const deleteWorkspace = deleteResult.workspace; - if (!deleteWorkspace?.workspaceDirectory) { - throw new Error(deleteResult.error ?? "Failed to create worktree workspace"); - } - const deleteDir = deleteWorkspace.workspaceDirectory; - expect(existsSync(deleteDir)).toBe(true); + await ctx.client.restoreWorkspace(keepWorkspace.id); + expect((await activeWorkspaceIds()).has(keepWorkspace.id)).toBe(true); + expect(existsSync(keepDir)).toBe(true); - // Last reference on a fresh worktree still removes the directory without any - // caller-supplied disk-deletion flag. - const deleteArchive = await ctx.client.archivePaseoWorktree({ worktreePath: deleteDir }); - expect(deleteArchive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - await expect.poll(() => existsSync(deleteDir), { timeout: 10000, interval: 100 }).toBe(false); + const removed = await ctx.client.removeProject(keepWorkspace.projectId); + expect(removed.removedWorkspaceIds).toContain(keepWorkspace.id); + expect(existsSync(keepDir)).toBe(false); }, 60000); test.skipIf(process.platform === "win32")( @@ -347,7 +322,7 @@ test.skipIf(process.platform === "win32")( retryError: null, }); expect((await activeWorkspaceIds()).has(workspace.id)).toBe(false); - expect(existsSync(workspace.workspaceDirectory)).toBe(false); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); } finally { writeFileSync(stopSetupPath, "stop\n"); } @@ -356,7 +331,7 @@ test.skipIf(process.platform === "win32")( ); test.skipIf(process.platform === "win32")( - "repeating archive removes a residual worktree for an already-archived workspace", + "repeating archive preserves the same runtime-owned worktree", async () => { const repoDir = createGitRepo(); const result = await ctx.client.createWorkspace({ @@ -403,7 +378,7 @@ test.skipIf(process.platform === "win32")( const retry = await ctx.client.archiveWorkspace(workspace.id); expect(retry.error).toBeNull(); - expect(existsSync(workspace.workspaceDirectory)).toBe(false); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); } finally { writeFileSync(stopWriterPath, "stop\n"); await writerExit; @@ -484,16 +459,10 @@ test("keeps the worktree on disk when a sibling workspace still references it", // directory must survive regardless of the legacy disk flag. const archive = await ctx.client.archivePaseoWorktree({ worktreePath: worktreeDir, + workspaceId: worktreeWorkspace.id, }); expect(archive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(worktreeWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - const remaining = await activeWorkspaceIds(); expect(remaining.has(worktreeWorkspace.id)).toBe(false); expect(remaining.has(siblingWorkspaceId)).toBe(true); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index cac3bc38df..e5d505d754 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino, { type Logger } from "pino"; @@ -791,21 +799,38 @@ describe("archiveByScope", () => { }); describe("resolveWorkspaceIdAtPath", () => { - test("prefers the worktree-kind record on an exact cwd tie", async () => { - const targetPath = "/worktrees/repo/feature"; + test("fails closed when duplicate records alias the same host-visible path", async () => { + const root = mkdtempSync(path.join(tmpdir(), "workspace-archive-alias-")); + cleanupPaths.push(root); + const targetPath = path.join(root, "target"); + const aliasPath = path.join(root, "alias"); + mkdirSync(targetPath); + symlinkSync(targetPath, aliasPath, process.platform === "win32" ? "junction" : "dir"); - const result = await resolveWorkspaceIdAtPath( - { - listActiveWorkspaces: async () => [ - { workspaceId: "ws-local", cwd: targetPath, kind: "local_checkout" }, - { workspaceId: "ws-worktree", cwd: targetPath, kind: "worktree" }, - ], - findWorkspaceIdForCwd: vi.fn(async () => "ws-local"), - }, - targetPath, - ); - - expect(result).toBe("ws-worktree"); + await expect( + resolveWorkspaceIdAtPath( + { + listActiveWorkspaces: async () => [ + { + workspaceId: "ws-first", + cwd: "/presentation/one", + hostVisiblePath: targetPath, + runtimeId: "local", + kind: "local_checkout", + }, + { + workspaceId: "ws-second", + cwd: "/presentation/two", + hostVisiblePath: aliasPath, + runtimeId: "worktree", + kind: "worktree", + }, + ], + findWorkspaceIdForCwd: async () => "ws-first", + }, + targetPath, + ), + ).rejects.toThrow("Ambiguous workspace path"); }); test("falls back to the path resolver when there is no exact match", async () => { diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 6fc841d84b..4b4ff5d4f3 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -24,7 +24,7 @@ import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export type ActiveWorkspaceRef = Pick< PersistedWorkspaceRecord, "workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot" ->; +> & { hostVisiblePath?: string | null; runtimeId?: string | null }; export interface ArchiveDependencies { paseoHome?: string; @@ -96,6 +96,7 @@ interface BackingDirectory { interface ArchiveTarget { backing: BackingDirectory | null; + preserveBackingDirectory: boolean; teardownTargets: Array<{ workspaceId: string | null; cwd: string }>; setupWorkspaceIds: string[]; workspaceIds: string[]; @@ -107,11 +108,19 @@ export async function resolveWorkspaceIdAtPath( ): Promise { const matchesTarget = createRealpathAwarePathMatcher(targetPath); const activeWorkspaces = await dependencies.listActiveWorkspaces(); - const exactMatches = activeWorkspaces.filter((workspace) => matchesTarget(workspace.cwd)); - const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); - if (worktreeMatch) { - return worktreeMatch.workspaceId; + const exactMatches = activeWorkspaces.filter((workspace) => { + const visiblePath = workspace.runtimeId ? workspace.hostVisiblePath : workspace.cwd; + return visiblePath ? matchesTarget(visiblePath) : false; + }); + if (exactMatches.length > 1) { + throw new Error( + `Ambiguous workspace path '${targetPath}' matches: ${exactMatches + .map((workspace) => workspace.workspaceId) + .sort() + .join(", ")}`, + ); } + if (exactMatches.length === 1) return exactMatches[0]!.workspaceId; return dependencies.findWorkspaceIdForCwd(targetPath); } @@ -203,11 +212,23 @@ async function resolveArchiveTarget( { workspaceId }, "Workspace not found for archive-by-scope; skipping", ); - return { backing: null, teardownTargets: [], setupWorkspaceIds: [], workspaceIds: [] }; + return { + backing: null, + preserveBackingDirectory: false, + teardownTargets: [], + setupWorkspaceIds: [], + workspaceIds: [], + }; } const isArchived = "archivedAt" in record && Boolean(record.archivedAt); + const isRuntimeSelected = + ("runtime" in record && Boolean(record.runtime)) || + ("runtimeId" in record && Boolean(record.runtimeId)); return { - backing: await resolveWorkspaceBackingDirectory(record, dependencies), + backing: isRuntimeSelected + ? null + : await resolveWorkspaceBackingDirectory(record, dependencies), + preserveBackingDirectory: isRuntimeSelected, teardownTargets: isArchived ? [] : [{ workspaceId, cwd: record.cwd }], setupWorkspaceIds: [workspaceId], workspaceIds: isArchived ? [] : [workspaceId], @@ -232,6 +253,9 @@ async function resolveArchiveTarget( ...backing, mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, }, + preserveBackingDirectory: targetWorkspaces.some( + (workspace) => workspace.runtimeId !== null && workspace.runtimeId !== undefined, + ), teardownTargets: targetWorkspaces.length > 0 ? targetWorkspaces.map((workspace) => ({ @@ -352,7 +376,7 @@ async function maybeRemoveDirectory( archivedWorkspaceIds: string[], ): Promise { const backing = target.backing; - if (!backing?.isPaseoOwnedWorktree) { + if (target.preserveBackingDirectory || !backing?.isPaseoOwnedWorktree) { return false; } diff --git a/packages/server/src/server/workspace-git-observation.ts b/packages/server/src/server/workspace-git-observation.ts index aadc5488e9..76d7f3927f 100644 --- a/packages/server/src/server/workspace-git-observation.ts +++ b/packages/server/src/server/workspace-git-observation.ts @@ -1,36 +1,5 @@ -import type { BoundWorkspaceRuntime, WorkspaceProcess } from "./workspace-runtime/index.js"; - -const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024; -const WATCH_SCRIPT = String.raw` -const fs = require("node:fs"); -const watcher = fs.watch(process.argv[1], { recursive: true }, () => process.stdout.write("changed\n")); -watcher.on("error", error => { process.stderr.write(String(error)); process.exit(1); }); -process.stdout.write("ready\n"); -`; -const STAT_SCRIPT = String.raw` -const fs = require("node:fs"); -const stat = fs.statSync(process.argv[1], { bigint: true }); -let mountNamespace = "host"; -try { mountNamespace = fs.readlinkSync("/proc/self/ns/mnt"); } catch {} -process.stdout.write(mountNamespace + ":" + String(stat.dev) + ":" + String(stat.ino)); -`; - -interface CommonRefMember { - runtime: BoundWorkspaceRuntime; - listener: () => void; -} - -interface CommonRefGroup { - key: string; - commonDir: string; - members: Map; - process: WorkspaceProcess | null; - owner: symbol | null; - stopping: boolean; - lifecycle: Promise; -} - -const commonRefGroups = new Map(); +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; +import { observeGitCommonMetadata } from "./workspace-runtime/git-observation/index.js"; export async function observeWorkspaceGit( runtime: BoundWorkspaceRuntime, @@ -46,8 +15,9 @@ export async function observeWorkspaceGit( let commonRefs: { unsubscribe(): Promise } | null = null; try { - const commonDir = await resolveCommonGitDirectory(runtime); - if (commonDir) commonRefs = await subscribeCommonRefs(runtime, commonDir, listener); + commonRefs = await observeGitCommonMetadata(runtime, () => { + if (ready) listener(); + }); ready = true; } catch (error) { await workingTree.unsubscribe(); @@ -63,226 +33,3 @@ export async function observeWorkspaceGit( }, }; } - -async function subscribeCommonRefs( - runtime: BoundWorkspaceRuntime, - commonDir: string, - listener: () => void, -): Promise<{ unsubscribe(): Promise }> { - const key = await resolveFilesystemIdentity(runtime, commonDir); - let group = commonRefGroups.get(key); - if (!group) { - group = { - key, - commonDir, - members: new Map(), - process: null, - owner: null, - stopping: false, - lifecycle: Promise.resolve(), - }; - commonRefGroups.set(key, group); - } - const memberId = Symbol(key); - group.members.set(memberId, { runtime, listener }); - try { - await updateGroupLifecycle(group, async () => { - if (!group.process) await startGroup(group); - }); - } catch (error) { - await updateGroupLifecycle(group, async () => { - group.members.delete(memberId); - if (group.members.size === 0) { - commonRefGroups.delete(key); - await stopGroup(group); - } - }); - throw error; - } - - let closed = false; - return { - async unsubscribe() { - if (closed) return; - closed = true; - await updateGroupLifecycle(group, async () => { - const wasOwner = group.owner === memberId; - group.members.delete(memberId); - if (group.members.size === 0) { - commonRefGroups.delete(key); - await stopGroup(group); - } else if (wasOwner) { - await stopGroup(group); - if (group.members.size > 0) await startGroup(group); - } - }); - }, - }; -} - -function updateGroupLifecycle(group: CommonRefGroup, update: () => Promise): Promise { - const result = group.lifecycle.then(update, update); - group.lifecycle = result.then( - () => undefined, - () => undefined, - ); - return result; -} - -async function startGroup(group: CommonRefGroup, excludedOwner?: symbol): Promise { - const candidates = [...group.members].filter(([id]) => id !== excludedOwner); - if (candidates.length === 0 && excludedOwner) { - candidates.push(...group.members); - } - let lastError: unknown = null; - for (const [memberId, member] of candidates) { - try { - const process = await member.runtime.run({ - argv: ["node", "-e", WATCH_SCRIPT, group.commonDir], - env: runtimeCommandEnvironment(), - purpose: { kind: "git" }, - }); - process.stdin.end(); - group.process = process; - group.owner = memberId; - group.stopping = false; - await waitForReady(process, () => { - if (group.process !== process) return; - for (const current of group.members.values()) current.listener(); - }); - void drain(process.stderr); - void process.exited.then(() => handleGroupExit(group, process, memberId)); - return; - } catch (error) { - const failedProcess = group.process; - group.process = null; - group.owner = null; - if (failedProcess) { - failedProcess.kill("SIGKILL"); - await failedProcess.exited.catch(() => undefined); - } - lastError = error; - } - } - throw lastError ?? new Error("Git common-ref observer has no runtime owner"); -} - -async function stopGroup(group: CommonRefGroup): Promise { - const process = group.process; - group.process = null; - group.owner = null; - if (!process) return; - group.stopping = true; - process.kill("SIGTERM"); - await process.exited.catch(() => undefined); - group.stopping = false; -} - -function handleGroupExit(group: CommonRefGroup, process: WorkspaceProcess, owner: symbol): void { - void updateGroupLifecycle(group, async () => { - if (group.process !== process) return; - group.process = null; - group.owner = null; - if (group.stopping || group.members.size === 0 || commonRefGroups.get(group.key) !== group) - return; - await startGroup(group, owner); - if (commonRefGroups.get(group.key) === group) { - for (const member of group.members.values()) member.listener(); - } - }).catch(() => undefined); -} - -function waitForReady(process: WorkspaceProcess, onChange: () => void): Promise { - return new Promise((resolve, reject) => { - let buffered = ""; - let ready = false; - process.stdout.on("data", (chunk) => { - buffered += Buffer.from(chunk).toString("utf8"); - const lines = buffered.split("\n"); - buffered = lines.pop() ?? ""; - for (const line of lines) { - if (line === "ready" && !ready) { - ready = true; - resolve(); - } else if (line === "changed" && ready) { - onChange(); - } - } - if (!ready && Buffer.byteLength(buffered) > MAX_COMMAND_OUTPUT_BYTES) { - process.kill("SIGKILL"); - reject(new Error("Git observer startup output exceeded 64 KiB")); - } - }); - void process.exited.then(() => { - if (!ready) reject(new Error("Git common-ref observer exited before ready")); - return undefined; - }); - }); -} - -async function resolveCommonGitDirectory(runtime: BoundWorkspaceRuntime): Promise { - const result = await runBoundCommand(runtime, [ - "git", - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - ]); - if (result.exit.code !== 0 || result.exit.signal !== null) return null; - return result.stdout.trim() || null; -} - -async function resolveFilesystemIdentity( - runtime: BoundWorkspaceRuntime, - path: string, -): Promise { - const result = await runBoundCommand(runtime, ["node", "-e", STAT_SCRIPT, path]); - if (result.exit.code !== 0 || result.exit.signal !== null || !result.stdout.trim()) { - throw new Error(`Could not identify Git common directory: ${result.stderr.trim()}`); - } - return result.stdout.trim(); -} - -async function runBoundCommand( - runtime: BoundWorkspaceRuntime, - argv: readonly [string, ...string[]], -) { - const process = await runtime.run({ - argv, - env: runtimeCommandEnvironment(), - purpose: { kind: "git" }, - }); - process.stdin.end(); - const [stdout, stderr, exit] = await Promise.all([ - collect(process.stdout, process), - collect(process.stderr, process), - process.exited, - ]); - return { stdout, stderr, exit }; -} - -async function collect(stream: NodeJS.ReadableStream, process: WorkspaceProcess): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of stream) { - const bytes = Buffer.from(chunk); - size += bytes.length; - if (size > MAX_COMMAND_OUTPUT_BYTES) { - process.kill("SIGKILL"); - throw new Error("Git observation command output exceeded 64 KiB"); - } - chunks.push(bytes); - } - return Buffer.concat(chunks).toString("utf8"); -} - -async function drain(stream: NodeJS.ReadableStream): Promise { - for await (const _chunk of stream) { - // Keep the long-running observer's diagnostic pipe flowing. - } -} - -function runtimeCommandEnvironment(): Readonly> { - return { - PATH: ["/usr/local/bin", "/usr/bin", "/bin", process.env.PATH].filter(Boolean).join(":"), - }; -} diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts index 566e7635c6..2230a3429f 100644 --- a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawn } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +16,9 @@ const fixtureExecutable = fileURLToPath( const helperExecutable = fileURLToPath( new URL("./workspace-helper/executable.mjs", import.meta.url), ); +const sharedRebindHelperExecutable = fileURLToPath( + new URL("./test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs", import.meta.url), +); const cleanupRoots: string[] = []; function eventWithin(event: Promise, label: string): Promise { @@ -49,6 +52,10 @@ test.each(["local", "worktree"] as const)( persistRuntimeId: async (id, selectedRuntimeId) => { runtimeIds.set(id, selectedRuntimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, }); await workspaceRuntime.create({ workspaceId, @@ -104,6 +111,208 @@ test.each(["local", "worktree"] as const)( 20_000, ); +test.each([ + ["local", "pause", "resume"], + ["worktree", "archive", "restore"], +] as const)( + "sole selected %s common-ref observation survives %s/%s", + async (runtimeId, suspend, awaken) => { + const root = await mkdtemp(path.join(tmpdir(), `paseo-${runtimeId}-git-lifecycle-`)); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = `${runtimeId}-lifecycle`; + const records = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => records.get(id)?.runtimeId ?? null, + persistRuntimeId: async (id, selectedRuntimeId) => { + records.set(id, { runtimeId: selectedRuntimeId, archived: false }); + }, + archiveWorkspaceRecord: async (id) => { + const record = records.get(id); + if (record) record.archived = true; + }, + restoreWorkspaceRecord: async (id) => { + const record = records.get(id); + if (record) record.archived = false; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + records.delete(id); + }, + }); + await service.create({ + workspaceId, + runtimeId, + project: { id: "lifecycle-project", source: { kind: "host-directory", path: source } }, + placement: + runtimeId === "local" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "lifecycle-worktree", + baseRef: "main", + worktreeSlug: "lifecycle-worktree", + }, + }); + + let observeChanges = false; + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await observeWorkspaceGit(await service.bind(workspaceId), () => { + if (observeChanges) resolveChanged(); + }); + + await service[suspend](workspaceId); + await service[awaken](workspaceId); + observeChanges = true; + await createBranchThroughRuntime(service, workspaceId, `${runtimeId}-after-lifecycle`); + await eventWithin(changed, `${runtimeId} common-ref update after lifecycle transition`); + + await subscription.unsubscribe(); + await service.destroy(workspaceId); + }, + 20_000, +); + +test("replayed common-ref observation survives runtime service reconstruction", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-git-observation-reconstruction-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = "reconstructed-observation"; + const records = new Map(); + const options = { + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id: string) => records.get(id)?.runtimeId ?? null, + persistRuntimeId: async (id: string, runtimeId: string) => { + records.set(id, { runtimeId, archived: false }); + }, + archiveWorkspaceRecord: async (id: string) => { + const record = records.get(id); + if (record) record.archived = true; + }, + restoreWorkspaceRecord: async (id: string) => { + const record = records.get(id); + if (record) record.archived = false; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id: string) => { + records.delete(id); + }, + }; + const original = createWorkspaceRuntimeService(options); + await original.create({ + workspaceId, + runtimeId: "local", + project: { id: "reconstructed-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + const originalSubscription = await observeWorkspaceGit( + await original.bind(workspaceId), + () => undefined, + ); + await original.archive(workspaceId); + + const reconstructed = createWorkspaceRuntimeService(options); + await reconstructed.restore(workspaceId); + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const replayed = await observeWorkspaceGit(await reconstructed.bind(workspaceId), resolveChanged); + await createBranchThroughRuntime(reconstructed, workspaceId, "after-reconstruction"); + await eventWithin(changed, "common-ref update after reconstruction replay"); + + await originalSubscription.unsubscribe(); + await replayed.unsubscribe(); + await reconstructed.destroy(workspaceId); +}, 20_000); + +test("failed second common-ref rebind rolls back every staged observer before retry", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-git-common-rebind-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = "common-rebind"; + const runtimeIds = new Map(); + const counterPath = path.join(root, "common-watch-launches"); + const commonRoot = await realpath(path.join(source, ".git")); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + helperCommand: [ + process.execPath, + sharedRebindHelperExecutable, + helperExecutable, + counterPath, + commonRoot, + ], + options: { stateDirectory: path.join(root, "state"), recordLaunchInWorkspace: false }, + }, + }, + }); + await service.create({ + workspaceId, + runtimeId: "fixture", + project: { id: "common-rebind-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + let observeChanges = false; + let resolveFirst!: () => void; + let resolveSecond!: () => void; + const firstChanged = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondChanged = new Promise((resolve) => { + resolveSecond = resolve; + }); + const runtime = await service.bind(workspaceId); + const subscriptions = await Promise.all([ + observeWorkspaceGit(runtime, () => { + if (observeChanges) resolveFirst(); + }), + observeWorkspaceGit(runtime, () => { + if (observeChanges) resolveSecond(); + }), + ]); + expect(Number(await readFile(counterPath, "utf8"))).toBe(1); + + await service.pause(workspaceId); + await expect(service.resume(workspaceId)).rejects.toThrow( + "Workspace helper subscribe acknowledgement timed out", + ); + await expect(createBranchThroughRuntime(service, workspaceId, "during-recovery")).rejects.toThrow( + `Workspace runtime is recovering: ${workspaceId}`, + ); + expect(hasSharedRebindHelper(root)).toBe(false); + await service.resume(workspaceId); + + observeChanges = true; + await createBranchThroughRuntime(service, workspaceId, "after-common-rebind"); + await Promise.all([ + eventWithin(firstChanged, "first common-ref observer after retry"), + eventWithin(secondChanged, "second common-ref observer after retry"), + ]); + expect(Number(await readFile(counterPath, "utf8"))).toBe(4); + + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + await service.destroy(workspaceId); + expect(hasSharedRebindHelper(root)).toBe(false); +}, 20_000); + test("selected sibling worktrees share common-ref fan-out without touching an unrelated workspace", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-")); cleanupRoots.push(root); @@ -117,6 +326,10 @@ test("selected sibling worktrees share common-ref fan-out without touching an un persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, }); for (const [workspaceId, branchName] of [ ["sibling-a", "sibling-a"], @@ -144,15 +357,15 @@ test("selected sibling worktrees share common-ref fan-out without touching an un const siblingChanged = new Promise((resolve) => { resolveSibling = resolve; }); - let phase: "initial" | "first-change" | "owner-reselected" | "after-pause-change" = "initial"; - let resolveSiblingOwnerReselected!: () => void; - const siblingOwnerReselected = new Promise((resolve) => { - resolveSiblingOwnerReselected = resolve; - }); + let phase: "initial" | "first-change" | "after-pause-change" | "after-destroy-change" = "initial"; let resolveSiblingAfterOwnerPause!: () => void; const siblingChangedAfterOwnerPause = new Promise((resolve) => { resolveSiblingAfterOwnerPause = resolve; }); + let resolveSiblingAfterOwnerDestroy!: () => void; + const siblingChangedAfterOwnerDestroy = new Promise((resolve) => { + resolveSiblingAfterOwnerDestroy = resolve; + }); let unrelatedChanges = 0; const siblingA = await service.bind("sibling-a"); const siblingB = await service.bind("sibling-b"); @@ -163,8 +376,8 @@ test("selected sibling worktrees share common-ref fan-out without touching an un }), await observeWorkspaceGit(siblingB, () => { if (phase === "first-change") resolveSibling(); - if (phase === "owner-reselected") resolveSiblingOwnerReselected(); if (phase === "after-pause-change") resolveSiblingAfterOwnerPause(); + if (phase === "after-destroy-change") resolveSiblingAfterOwnerDestroy(); }), await observeWorkspaceGit(unrelatedGit, () => { unrelatedChanges += 1; @@ -186,9 +399,7 @@ test("selected sibling worktrees share common-ref fan-out without touching an un ]); expect(unrelatedChanges).toBe(0); - phase = "owner-reselected"; await service.pause("sibling-a"); - await eventWithin(siblingOwnerReselected, "common-ref observer owner re-selection"); phase = "after-pause-change"; const branchAfterPause = await service.run({ workspaceId: "sibling-b", @@ -201,6 +412,19 @@ test("selected sibling worktrees share common-ref fan-out without touching an un await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause"); expect(unrelatedChanges).toBe(0); + await service.destroy("sibling-a"); + phase = "after-destroy-change"; + const branchAfterDestroy = await service.run({ + workspaceId: "sibling-b", + argv: ["git", "branch", "shared-ref-after-owner-destroy"], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + branchAfterDestroy.stdin.end(); + await expect(branchAfterDestroy.exited).resolves.toEqual({ code: 0, signal: null }); + await eventWithin(siblingChangedAfterOwnerDestroy, "sibling ref update after owner destroy"); + expect(unrelatedChanges).toBe(0); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); await Promise.all([service.destroy("sibling-a"), service.destroy("sibling-b")]); await service.destroy("unrelated"); @@ -218,6 +442,10 @@ test("concurrent sibling subscriptions own exactly one common-ref watcher until persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, }); for (const workspaceId of ["race-a", "race-b"] as const) { await service.create({ @@ -233,64 +461,63 @@ test("concurrent sibling subscriptions own exactly one common-ref watcher until }); } - let releaseLaunch!: () => void; - const launchReleased = new Promise((resolve) => { - releaseLaunch = resolve; - }); - let firstLaunchEntered!: () => void; - const firstLaunch = new Promise((resolve) => { - firstLaunchEntered = resolve; - }); - let watcherLaunches = 0; - let activeWatchers = 0; - let maximumActiveWatchers = 0; - const instrument = async (workspaceId: string) => { - const runtime = await service.bind(workspaceId); - return { - files: runtime.files, - run: async (input: Parameters[0]) => { - const isCommonRefWatcher = - input.argv[0] === "node" && - input.argv[1] === "-e" && - input.argv[2]?.includes("fs.watch(process.argv[1]") === true; - if (isCommonRefWatcher) { - watcherLaunches += 1; - firstLaunchEntered(); - await launchReleased; - } - const process = await runtime.run(input); - if (isCommonRefWatcher) { - activeWatchers += 1; - maximumActiveWatchers = Math.max(maximumActiveWatchers, activeWatchers); - void process.exited.finally(() => { - activeWatchers -= 1; - }); - } - return process; - }, - }; - }; - const [runtimeA, runtimeB] = await Promise.all([instrument("race-a"), instrument("race-b")]); - const subscriptionsPromise = Promise.all([ + const [runtimeA, runtimeB] = await Promise.all([service.bind("race-a"), service.bind("race-b")]); + const subscriptions = await Promise.all([ observeWorkspaceGit(runtimeA, () => undefined), observeWorkspaceGit(runtimeB, () => undefined), ]); + expect(countHelperWatchers(root)).toBe(3); - await firstLaunch; - await new Promise((resolve) => setImmediate(resolve)); - expect(watcherLaunches).toBe(1); - releaseLaunch(); - const subscriptions = await subscriptionsPromise; - expect(maximumActiveWatchers).toBe(1); + await service.pause("race-a"); + expect(countHelperWatchers(root)).toBe(2); + await service.resume("race-a"); + expect(countHelperWatchers(root)).toBe(3); await subscriptions[0].unsubscribe(); - expect(activeWatchers).toBe(1); + expect(countHelperWatchers(root)).toBe(2); await subscriptions[1].unsubscribe(); - expect(activeWatchers).toBe(0); + expect(countHelperWatchers(root)).toBe(0); await Promise.all([service.destroy("race-a"), service.destroy("race-b")]); }, 20_000); +function countHelperWatchers(root: string): number { + const processes = execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }); + return processes + .split("\n") + .filter( + (command) => + command.includes("workspace-helper/executable.mjs") && + command.includes(" watch ") && + command.includes(path.basename(root)), + ).length; +} + +function hasSharedRebindHelper(root: string): boolean { + return execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }) + .split("\n") + .some( + (command) => + command.includes("workspace-helper-shared-rebind-fixture.mjs") && + command.includes(path.basename(root)), + ); +} + +async function createBranchThroughRuntime( + service: ReturnType, + workspaceId: string, + branch: string, +): Promise { + const process = await service.run({ + workspaceId, + argv: ["git", "branch", branch], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + process.stdin.end(); + await expect(process.exited).resolves.toEqual({ code: 0, signal: null }); +} + test("selected workspace Git reads and mutations stay inside its command runtime", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-")); cleanupRoots.push(root); @@ -311,6 +538,10 @@ test("selected workspace Git reads and mutations stay inside its command runtime persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { fixture: { type: "command", @@ -425,7 +656,7 @@ test("selected workspace Git reads and mutations stay inside its command runtime await service.dispose(); await workspaceRuntime.destroy(workspaceId); -}, 20_000); +}, 40_000); test("selected commit history highlighting never reads a deleted file from the host cwd", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-history-")); @@ -451,6 +682,10 @@ test("selected commit history highlighting never reads a deleted file from the h persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { fixture: { type: "command", @@ -509,6 +744,10 @@ test("selected workspaces with the same public cwd keep Git state, mutations, an persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { fixture: { type: "command", @@ -693,7 +932,7 @@ test("selected workspaces with the same public cwd keep Git state, mutations, an workspaceRuntime.destroy("same-cwd-a"), workspaceRuntime.destroy("same-cwd-b"), ]); -}, 20_000); +}, 40_000); async function createRepository(directory: string): Promise { await mkdir(directory); diff --git a/packages/server/src/server/workspace-helper/internal/client.ts b/packages/server/src/server/workspace-helper/internal/client.ts index 0358adfb93..658572bb09 100644 --- a/packages/server/src/server/workspace-helper/internal/client.ts +++ b/packages/server/src/server/workspace-helper/internal/client.ts @@ -17,6 +17,12 @@ interface WatchState { closed: boolean; cleanup: Promise | null; diagnostics: Promise; + ready: boolean; +} + +interface LogicalWatchSubscription { + input: { paths: readonly string[]; recursive?: boolean; ignoredPaths?: readonly string[] }; + listener: (event: WorkspaceWatchEvent) => void; } const WATCH_ACKNOWLEDGEMENT_TIMEOUT_MS = 750; @@ -33,6 +39,10 @@ export function createClient(options: { let nextSubscriptionId = 0; let closing = false; let closePromise: Promise | null = null; + let watcherRecovery: Promise | null = null; + let watcherStart: Promise | null = null; + let replayAfterFailure = false; + const watchSubscriptions = new Map(); const processes = new Set(); const command = async (name: string, args: readonly string[] = []) => { @@ -123,41 +133,20 @@ export function createClient(options: { }, async subscribe(input, listener) { if (closing) throw new Error("Workspace files client is closed"); - const state = watcher ?? (await startWatcher()); - if (state.closed) throw new Error("Workspace helper watcher is not running"); const id = `subscription-${++nextSubscriptionId}`; - state.subscriptions.set(id, listener); - const acknowledged = acknowledgement(state, `subscribed:${id}`); - state.process.stdin.write( - `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths, recursive: input.recursive === true, ignoredPaths: input.ignoredPaths ?? [] })}\n`, - ); - await waitForAcknowledgement( - state, - `subscribed:${id}`, - "Workspace helper subscribe acknowledgement timed out", - acknowledged, - ); - let active = true; - return { - async unsubscribe() { - if (!active) return; - active = false; - state.subscriptions.delete(id); - if (!state.closed) { - const removed = acknowledgement(state, `unsubscribed:${id}`); - state.process.stdin.write( - `${JSON.stringify({ protocolVersion: 1, type: "unsubscribe", id })}\n`, - ); - await waitForAcknowledgement( - state, - `unsubscribed:${id}`, - "Workspace helper unsubscribe acknowledgement timed out", - removed, - ); - } - if (state.subscriptions.size === 0) await stopWatcher(state); - }, - }; + watchSubscriptions.set(id, { input, listener }); + let state: WatchState | null = null; + try { + state = await requireWatcher(); + if (state.subscriptions.has(id)) return subscriptionFor(id); + state.subscriptions.set(id, listener); + await sendSubscription(state, id, input); + } catch (error) { + watchSubscriptions.delete(id); + state?.subscriptions.delete(id); + throw error; + } + return subscriptionFor(id); }, }, async resolveCommand(commandName) { @@ -179,16 +168,65 @@ export function createClient(options: { async close() { closePromise ??= (async () => { closing = true; + watchSubscriptions.clear(); if (watcher) { await stopWatcher(watcher, new Error("Workspace files client is closed")); } + await watcherRecovery?.catch(() => undefined); await Promise.all([...processes].map((child) => terminate(child))); })(); await closePromise; }, }; + function subscriptionFor(id: string) { + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + watchSubscriptions.delete(id); + await watcherRecovery?.catch(() => undefined); + const state = watcher; + state?.subscriptions.delete(id); + if (state && !state.closed) { + const removed = acknowledgement(state, `unsubscribed:${id}`); + state.process.stdin.write( + `${JSON.stringify({ protocolVersion: 1, type: "unsubscribe", id })}\n`, + ); + try { + await waitForAcknowledgement( + state, + `unsubscribed:${id}`, + "Workspace helper unsubscribe acknowledgement timed out", + removed, + ); + } catch (error) { + if (!(error instanceof Error) || error.message !== "Workspace helper watcher is closed") + throw error; + } + } + if (watchSubscriptions.size === 0 && state) await stopWatcher(state); + }, + }; + } + + async function requireWatcher(): Promise { + await watcherRecovery; + if (watcherStart) return watcherStart; + if (watcher && !watcher.closed) return watcher; + return launchWatcher(); + } + + function launchWatcher(): Promise { + watcherStart ??= startWatcher().finally(() => { + watcherStart = null; + }); + return watcherStart; + } + async function startWatcher(): Promise { + const replaying = replayAfterFailure; const process = await command("watch"); const state: WatchState = { process, @@ -197,6 +235,7 @@ export function createClient(options: { closed: false, cleanup: null, diagnostics: collect(process.stderr), + ready: false, }; watcher = state; process.stdin.on("error", () => undefined); @@ -226,9 +265,37 @@ export function createClient(options: { "Workspace helper ready acknowledgement timed out", ready, ); + for (const [id, subscription] of watchSubscriptions) { + state.subscriptions.set(id, subscription.listener); + await sendSubscription(state, id, subscription.input); + } + state.ready = true; + replayAfterFailure = false; + if (replaying) { + for (const subscription of watchSubscriptions.values()) { + subscription.listener({ type: "changed", paths: [...subscription.input.paths] }); + } + } return state; } + async function sendSubscription( + state: WatchState, + id: string, + input: LogicalWatchSubscription["input"], + ): Promise { + const acknowledged = acknowledgement(state, `subscribed:${id}`); + state.process.stdin.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths, recursive: input.recursive === true, ignoredPaths: input.ignoredPaths ?? [] })}\n`, + ); + await waitForAcknowledgement( + state, + `subscribed:${id}`, + "Workspace helper subscribe acknowledgement timed out", + acknowledged, + ); + } + async function stopWatcher(state: WatchState, reason?: Error): Promise { if (state.cleanup) return state.cleanup; if (state.closed) return; @@ -276,9 +343,6 @@ export function createClient(options: { if (state.closed) return; state.closed = true; if (watcher === state) watcher = null; - for (const listener of state.subscriptions.values()) { - listener({ type: "error", error: error.message }); - } state.subscriptions.clear(); for (const pending of state.acknowledgements.values()) pending.reject(error); state.acknowledgements.clear(); @@ -288,7 +352,23 @@ export function createClient(options: { await state.diagnostics.catch(() => undefined); return undefined; }); - return state.cleanup; + if (!closing && state.ready && watchSubscriptions.size > 0 && !watcherRecovery) { + replayAfterFailure = true; + watcherRecovery = state.cleanup + .then(async () => { + if (!closing && watchSubscriptions.size > 0) await launchWatcher(); + return undefined; + }) + .catch((recoveryError) => { + for (const subscription of watchSubscriptions.values()) { + subscription.listener({ type: "error", error: toError(recoveryError).message }); + } + }) + .finally(() => { + watcherRecovery = null; + }); + } + await state.cleanup; } } diff --git a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts b/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts index 32dbb1b1f7..bcfc320918 100644 --- a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts +++ b/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; +import type { WorkspaceWatchEvent } from "./index.js"; import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "./integration/index.js"; const posixDescribe = describe.runIf(process.platform !== "win32"); @@ -97,6 +98,75 @@ posixDescribe("workspace-helper public capability", () => { await client.close(); }); + test("a clean watcher restart does not replay a stale file version", async () => { + const { client, root } = await fixture(); + const first = await client.files.subscribe({ paths: ["later.txt"] }, () => undefined); + await first.unsubscribe(); + const events: WorkspaceWatchEvent[] = []; + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const second = await client.files.subscribe({ paths: ["later.txt"] }, (event) => { + events.push(event); + if (event.type === "changed") resolveChanged(); + }); + + expect(events).toEqual([]); + await writeFile(path.join(root, "later.txt"), "created\n"); + await changed; + expect(events).toContainEqual({ type: "changed", paths: ["later.txt"] }); + + await second.unsubscribe(); + await client.close(); + }); + + test("a helper crash relaunches the watcher and replays live subscriptions", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-replay-")); + cleanupRoots.push(parent); + const root = path.join(parent, "workspace"); + await mkdir(root); + await writeFile(path.join(root, "watched.txt"), "before\n"); + const watcherChildren: ReturnType[] = []; + let resolveSecondSubscription!: () => void; + const secondSubscription = new Promise((resolve) => { + resolveSecondSubscription = resolve; + }); + const client = bindWorkspaceHelper({ + root, + command: [process.execPath, workspaceHelper], + launch: async (argv) => { + const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + if (argv.includes("watch")) { + watcherChildren.push(child); + if (watcherChildren.length === 2) { + child.stdout.on("data", (chunk) => { + if (chunk.toString().includes('"type":"subscribed"')) { + resolveSecondSubscription(); + } + }); + } + } + return childProcess(child); + }, + }); + let resolveChanged!: (paths: string[]) => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await client.files.subscribe({ paths: ["watched.txt"] }, (event) => { + if (event.type === "changed") resolveChanged(event.paths); + }); + + watcherChildren[0]?.kill("SIGKILL"); + await secondSubscription; + await writeFile(path.join(root, "watched.txt"), "after\n"); + + await expect(changed).resolves.toEqual(["watched.txt"]); + await subscription.unsubscribe(); + await client.close(); + }); + test("cancelling a partially-consumed read waits for the helper to exit", async () => { const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-cancel-")); cleanupRoots.push(parent); @@ -260,7 +330,7 @@ posixDescribe("workspace-helper public capability", () => { await subscription.unsubscribe(); await changeSubscription.unsubscribe(); await client.close(); - }); + }, 15_000); }); async function recordedClient(mode: string) { @@ -306,6 +376,10 @@ async function launchTestProcess( argv: readonly [string, ...string[]], ): Promise { const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + return childProcess(child); +} + +function childProcess(child: ReturnType): WorkspaceHelperProcess { return { stdin: child.stdin, stdout: child.stdout, diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1a885378db..6229511c31 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -367,6 +367,51 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); + test("never treats a selected runtime compatibility cwd as a host directory", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-runtime-project-")), + ); + tempDirs.push(projectRoot); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "runtime-project", + createPersistedProjectRecord({ + projectId: "runtime-project", + rootPath: projectRoot, + kind: "git", + displayName: "runtime-project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "docker-workspace", + createPersistedWorkspaceRecord({ + workspaceId: "docker-workspace", + projectId: "runtime-project", + cwd: "/workspace", + kind: "local_checkout", + displayName: "docker-workspace", + runtime: { runtimeId: "docker" }, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + await service.reconcileGitMetadata(); + const result = await service.runOnce(); + + expect(result.changesApplied).not.toContainEqual( + expect.objectContaining({ kind: "workspace_archived", workspaceId: "docker-workspace" }), + ); + expect(workspaces.get("docker-workspace")?.archivedAt).toBeNull(); + }); + test("reads fresh checkout facts on every metadata pass", async () => { const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-fresh-git-"))); tempDirs.push(projectRoot); @@ -693,6 +738,7 @@ describe("WorkspaceReconciliationService", () => { workspaceId: "w1", projectId: "p1", cwd: "/tmp/does-not-exist-reconcile-orphan", + hostVisiblePath: null, kind: "directory", displayName: "orphan", title: null, @@ -706,6 +752,7 @@ describe("WorkspaceReconciliationService", () => { updatedAt: expect.any(String), archivedAt: expect.any(String), autoArchivedChangeRequestUrl: null, + deletionRequestedAt: null, }); expect(projects.get("p1")).toEqual(project); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..ef1c918181 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -198,7 +198,12 @@ export class WorkspaceReconciliationService { ]); const workspacesByProject = new Map(); for (const workspace of workspaces) { - if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + if ( + workspace.archivedAt || + workspace.runtime || + this.inspectDirectory(workspace.cwd) !== "directory" + ) + continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); @@ -222,7 +227,9 @@ export class WorkspaceReconciliationService { const allWorkspaces = await this.workspaceRegistry.list(); const activeProjects = allProjects.filter((p) => !p.archivedAt); - const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); + // Runtime-selected workspace placement is recovered by driver inspect. Its + // compatibility cwd may be runtime-local and must never be statted on the host. + const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt && !w.runtime); const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ workspace, state: this.inspectDirectory(workspace.cwd), diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index acc4d49538..88c9facc90 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -69,6 +69,13 @@ export type InitialWorkspacePlacementInput = branch: string | null; baseBranch: string | null; mainRepoRoot: string; + } + | { + source: "runtime_worktree"; + cwd: string; + branch: string | null; + baseBranch: string | null; + mainRepoRoot: string; }; export interface WorkspacePlacementUpdate { @@ -80,6 +87,18 @@ export interface WorkspacePlacementUpdate { export function initialWorkspacePlacement( input: InitialWorkspacePlacementInput, ): PersistedWorkspacePlacement { + if (input.source === "runtime_worktree") { + return { + cwd: input.cwd, + kind: "worktree", + displayName: input.branch || input.cwd, + branch: input.branch, + worktreeRoot: null, + baseBranch: input.baseBranch, + isPaseoOwnedWorktree: true, + mainRepoRoot: input.mainRepoRoot, + }; + } if (input.source === "created_worktree") { return { cwd: input.cwd, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index e95949276d..217adc5181 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync } from "node:fs"; import { beforeEach, afterEach, describe, expect, test } from "vitest"; @@ -419,6 +419,66 @@ describe("workspace registries", () => { expect(await workspaceRegistry.list()).toEqual([]); }); + test("keeps the committed workspace snapshot when a removal cannot be persisted", async () => { + const registryPath = path.join(tmpDir, "projects", "workspaces.json"); + const backupPath = `${registryPath}.backup`; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "durable-workspace", + projectId: "durable-project", + cwd: "/tmp/durable", + kind: "local_checkout", + displayName: "durable", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await workspaceRegistry.upsert(workspace); + renameSync(registryPath, backupPath); + mkdirSync(registryPath); + + await expect(workspaceRegistry.remove(workspace.workspaceId)).rejects.toThrow(); + expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace); + + rmSync(registryPath, { recursive: true }); + renameSync(backupPath, registryPath); + const reconstructed = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructed.get(workspace.workspaceId)).resolves.toEqual(workspace); + await expect(workspaceRegistry.remove(workspace.workspaceId)).resolves.toBeUndefined(); + await expect(workspaceRegistry.get(workspace.workspaceId)).resolves.toBeNull(); + }); + + test("does not expose deletion intent until it is durably persisted", async () => { + const registryPath = path.join(tmpDir, "projects", "workspaces.json"); + const backupPath = `${registryPath}.backup`; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "intent-workspace", + projectId: "intent-project", + cwd: "/tmp/intent", + kind: "local_checkout", + displayName: "intent", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await workspaceRegistry.upsert(workspace); + renameSync(registryPath, backupPath); + mkdirSync(registryPath); + + await expect( + workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z"), + ).rejects.toThrow(); + expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace); + + rmSync(registryPath, { recursive: true }); + renameSync(backupPath, registryPath); + const reconstructedBeforeRetry = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructedBeforeRetry.get(workspace.workspaceId)).resolves.toEqual(workspace); + + await workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z"); + const reconstructedAfterRetry = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructedAfterRetry.get(workspace.workspaceId)).resolves.toMatchObject({ + deletionRequestedAt: "2026-03-02T00:00:00.000Z", + }); + }); + test("refreshes workspace archive timestamps when an archive is repeated", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 8ed5c904bf..ae84134d08 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -44,6 +44,11 @@ const PersistedWorkspaceRecordSchema = z.object({ workspaceId: z.string(), projectId: z.string(), cwd: z.string(), + hostVisiblePath: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), kind: z.enum(["local_checkout", "worktree", "directory"]), displayName: z.string(), // User-set title layered over the derived displayName. In Model B the title is @@ -78,6 +83,12 @@ const PersistedWorkspaceRecordSchema = z.object({ isPaseoOwnedWorktree: z.boolean().default(false), mainRepoRoot: z.string().nullable().default(null), runtime: z.object({ runtimeId: z.string().min(1) }).optional(), + // COMPAT(workspaceDeletionIntent): added in v0.2.7 on 2026-08-11; remove optional after 2027-02-11. + deletionRequestedAt: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -164,6 +175,7 @@ export interface WorkspaceRegistry { context?: WorkspaceArchiveContext, ): Promise; remove(workspaceId: string): Promise; + requestDeletion(workspaceId: string, requestedAt: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( listener: (mutation: WorkspaceMutation) => void | Promise, @@ -179,7 +191,7 @@ class FileBackedRegistry { private readonly getId: (record: TRecord) => string; private loaded = false; private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); + private mutationQueue: Promise = Promise.resolve(); constructor(options: { filePath: string; @@ -221,22 +233,20 @@ class FileBackedRegistry { } async upsert(record: TRecord): Promise { - await this.load(); const parsed = this.schema.parse(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); + await this.mutate((records) => { + records.set(this.getId(parsed), parsed); + }); } async update(id: string, updater: (record: TRecord) => TRecord): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - const next = this.schema.parse(updater(existing)); - this.cache.set(id, next); - await this.enqueuePersist(); - return next; + return this.mutate((records) => { + const existing = records.get(id); + if (!existing) return null; + const next = this.schema.parse(updater(existing)); + records.set(id, next); + return next; + }); } async archive(id: string, archivedAt: string): Promise { @@ -244,29 +254,30 @@ class FileBackedRegistry { } protected async archiveIfPresent(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) return null; - return this.persistArchive(existing, archivedAt); + return this.mutate((records) => this.archiveRecord(records, id, archivedAt)); } protected async archiveIfActive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing || existing.archivedAt) { - return null; - } - return this.persistArchive(existing, archivedAt); + return this.mutate((records) => { + const existing = records.get(id); + if (!existing || existing.archivedAt) return null; + return this.archiveRecord(records, id, archivedAt); + }); } - private async persistArchive(existing: TRecord, archivedAt: string): Promise { + private archiveRecord( + records: Map, + id: string, + archivedAt: string, + ): TRecord | null { + const existing = records.get(id); + if (!existing) return null; const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt, }); - this.cache.set(this.getId(next), next); - await this.enqueuePersist(); + records.set(this.getId(next), next); return next; } @@ -275,14 +286,12 @@ class FileBackedRegistry { } protected async removeIfPresent(id: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - this.cache.delete(id); - await this.enqueuePersist(); - return existing; + return this.mutate((records) => { + const existing = records.get(id); + if (!existing) return null; + records.delete(id); + return existing; + }); } private async load(): Promise { @@ -306,18 +315,43 @@ class FileBackedRegistry { this.loaded = true; } - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await writeJsonFileAtomic(this.filePath, records); + private async persist(records: ReadonlyMap): Promise { + await writeJsonFileAtomic(this.filePath, Array.from(records.values())); } - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; + private async mutate( + mutation: (records: Map) => TResult, + ): Promise { + await this.load(); + const previous = this.mutationQueue; + let release!: () => void; + this.mutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + const next = new Map(this.cache); + const result = mutation(next); + if (mapsEqual(this.cache, next)) return result; + await this.persist(next); + this.cache.clear(); + for (const [id, record] of next) this.cache.set(id, record); + return result; + } finally { + release(); + } } } +function mapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of left) if (right.get(key) !== value) return false; + return true; +} + export class FileBackedProjectRegistry extends FileBackedRegistry implements ProjectRegistry @@ -516,6 +550,16 @@ export class FileBackedWorkspaceRegistry await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); } + async requestDeletion(workspaceId: string, requestedAt: string): Promise { + const workspace = await super.update(workspaceId, (existing) => ({ + ...existing, + deletionRequestedAt: existing.deletionRequestedAt ?? requestedAt, + updatedAt: existing.deletionRequestedAt ? existing.updatedAt : requestedAt, + })); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + await this.notifyMutation({ kind: "upsert", workspaceId, workspace }); + } + private async notifyMutation(mutation: WorkspaceMutation): Promise { await Promise.all([...this.mutationListeners].map((listener) => listener(mutation))); } @@ -559,6 +603,7 @@ export function createPersistedWorkspaceRecord(input: { isPaseoOwnedWorktree?: boolean; mainRepoRoot?: string | null; runtime?: { runtimeId: string }; + deletionRequestedAt?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -574,6 +619,7 @@ export function createPersistedWorkspaceRecord(input: { isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, mainRepoRoot: input.mainRepoRoot ?? null, ...(input.runtime ? { runtime: input.runtime } : {}), + deletionRequestedAt: input.deletionRequestedAt ?? null, archivedAt: input.archivedAt ?? null, autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null, pinnedAt: input.pinnedAt ?? null, diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts index 3a3c228d96..9b619a2bc6 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -7,6 +7,7 @@ import { StringDecoder } from "node:string_decoder"; import type { WorkspaceDriverCreateInput, WorkspaceDriverInspection, + WorkspaceDriverState, WorkspaceDriverSpawnInput, WorkspacePipeProcess, WorkspacePtyProcess, @@ -31,6 +32,7 @@ export function createCommandRuntime( config: CommandRuntimeConfig, ): WorkspaceRuntimeDriver { let described: Promise> | null = null; + let supportsReconciliation = false; function describe(): Promise> { described ??= runCommand(["describe"], undefined).then((output) => { @@ -38,6 +40,7 @@ export function createCommandRuntime( if (!description.modes.includes("pipes")) { throw new Error(`Workspace runtime ${runtimeId} does not support pipes`); } + supportsReconciliation = description.reconcile; return new Set(description.modes); }); return described; @@ -58,17 +61,24 @@ export function createCommandRuntime( return { id: runtimeId, + reconciliationDomainId: JSON.stringify({ command: config.command, options: config.options }), workspaceHelperCommand: config.helperCommand ?? ["paseo-workspace-helper"], async create(input) { const response = await lifecycle("create", input.workspaceId, input); if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`); - return response.state; + return commandReady(response.state, response.placement); }, async inspect(workspaceId): Promise { const response = await lifecycle("inspect", workspaceId); if (response.type !== "inspection") { throw new Error(`Invalid inspect response from ${runtimeId}`); } + if (response.inspection.status === "ready" || response.inspection.status === "paused") { + return { + ...response.inspection, + ...commandReady(response.inspection.state, response.inspection.placement), + }; + } return response.inspection as WorkspaceDriverInspection; }, async spawn(input) { @@ -88,12 +98,22 @@ export function createCommandRuntime( async resume(workspaceId) { const response = await lifecycle("resume", workspaceId); if (response.type !== "state") throw new Error(`Invalid resume response from ${runtimeId}`); - return response.state; + return commandReady(response.state, response.placement); }, async destroy(workspaceId) { const response = await lifecycle("destroy", workspaceId); if (response.type !== "ok") throw new Error(`Invalid destroy response from ${runtimeId}`); }, + async reconcile(workspaceIds) { + await describe(); + if (!supportsReconciliation) return; + const output = await runCommand( + ["reconcile"], + JSON.stringify({ protocolVersion: 1, workspaceIds, options: config.options ?? {} }), + ); + const response = CommandRuntimeLifecycleResponseSchema.parse(JSON.parse(output)); + if (response.type !== "ok") throw new Error(`Invalid reconcile response from ${runtimeId}`); + }, }; async function runCommand(args: string[], stdin: string | undefined): Promise { @@ -121,6 +141,16 @@ export function createCommandRuntime( } } +function commandReady( + state: WorkspaceDriverState, + placement: { cwd: string; hostVisiblePath?: string } | undefined, +) { + if (!placement) { + throw new Error("Workspace runtime did not return public placement"); + } + return { state, placement }; +} + function spawnCommandPty( runtimeId: string, command: readonly [string, ...string[]], diff --git a/packages/server/src/server/workspace-runtime/command/internal/protocol.ts b/packages/server/src/server/workspace-runtime/command/internal/protocol.ts index cb6b508a6d..c89fa58f47 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/protocol.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/protocol.ts @@ -6,12 +6,28 @@ export const CommandRuntimeStateSchema = z.object({ revision: z.string(), executionDomainId: z.string(), lifecycle: z.enum(["ready", "paused"]), + lifecycleEnvironment: z.record(z.string(), z.string()).optional(), +}); + +export const CommandRuntimePlacementSchema = z.object({ + cwd: z.string(), + hostVisiblePath: z.string().optional(), }); export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("missing") }), - z.object({ status: z.literal("paused"), state: CommandRuntimeStateSchema }), - z.object({ status: z.literal("ready"), state: CommandRuntimeStateSchema }), + z.object({ + status: z.literal("paused"), + state: CommandRuntimeStateSchema, + // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. + placement: CommandRuntimePlacementSchema.optional(), + }), + z.object({ + status: z.literal("ready"), + state: CommandRuntimeStateSchema, + // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. + placement: CommandRuntimePlacementSchema.optional(), + }), z.object({ status: z.literal("error"), message: z.string() }), ]); @@ -20,6 +36,8 @@ export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type" type: z.literal("state"), protocolVersion: z.literal(1), state: CommandRuntimeStateSchema, + // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. + placement: CommandRuntimePlacementSchema.optional(), }), z.object({ type: z.literal("inspection"), @@ -32,6 +50,7 @@ export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type" export const CommandRuntimeDescribeResponseSchema = z.object({ protocolVersion: z.literal(1), modes: z.array(z.enum(["pipes", "pty"])), + reconcile: z.boolean().optional().default(false), }); export const CommandRuntimePtyEventSchema = z.discriminatedUnion("type", [ diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index ccd093b0cc..37a0795d49 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -16,12 +16,15 @@ export type WorkspacePlacementIntent = relativeCwd?: string; worktreeSlug?: string; } - | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string }; + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string } + | import("../index.js").ResolvedWorktreePlacement; export interface WorkspaceDriverCreateInput { workspaceId: WorkspaceId; project: { projectId: string; source: WorkspaceProjectSource }; placement: WorkspacePlacementIntent; + markFirstAgentBranchAutoName?: boolean; + seedPaseoConfigFrom?: string; } export interface WorkspaceDriverState { @@ -30,12 +33,23 @@ export interface WorkspaceDriverState { revision: string; executionDomainId: string; lifecycle: "ready" | "paused"; + lifecycleEnvironment?: Readonly>; +} + +export interface WorkspacePublicPlacement { + cwd: string; + hostVisiblePath?: string; +} + +export interface WorkspaceDriverReady { + state: WorkspaceDriverState; + placement: WorkspacePublicPlacement; } export type WorkspaceDriverInspection = | { status: "missing" } - | { status: "paused"; state: WorkspaceDriverState } - | { status: "ready"; state: WorkspaceDriverState } + | ({ status: "paused" } & WorkspaceDriverReady) + | ({ status: "ready" } & WorkspaceDriverReady) | { status: "error"; message: string }; export interface WorkspaceDriverSpawnInput { @@ -82,12 +96,14 @@ export type WorkspaceDriverProcess = WorkspacePipeProcess | WorkspacePtyProcess; export interface WorkspaceRuntimeDriver { readonly id: WorkspaceRuntimeId; + readonly reconciliationDomainId?: string; /** Runtime-local command for Paseo's compatible workspace helper. */ readonly workspaceHelperCommand: readonly [string, ...string[]]; - create(input: WorkspaceDriverCreateInput): Promise; + create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; pause(workspaceId: WorkspaceId): Promise; - resume(workspaceId: WorkspaceId): Promise; + resume(workspaceId: WorkspaceId): Promise; destroy(workspaceId: WorkspaceId): Promise; + reconcile?(workspaceIds: readonly WorkspaceId[]): Promise; } diff --git a/packages/server/src/server/workspace-runtime/git-observation/index.ts b/packages/server/src/server/workspace-runtime/git-observation/index.ts new file mode 100644 index 0000000000..728d5364a3 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/index.ts @@ -0,0 +1,10 @@ +import type { BoundWorkspaceRuntime } from "../index.js"; +import { requireGitCommonObservationCapability } from "./internal/capability.js"; + +/** Observe metadata shared by Git worktrees without exposing its runtime placement. */ +export function observeGitCommonMetadata( + runtime: BoundWorkspaceRuntime, + listener: () => void, +): Promise<{ unsubscribe(): Promise }> { + return requireGitCommonObservationCapability(runtime).observe(listener); +} diff --git a/packages/server/src/server/workspace-runtime/git-observation/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/integration.ts new file mode 100644 index 0000000000..cf05362e72 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/integration.ts @@ -0,0 +1,445 @@ +import nodePath from "node:path"; + +import type { BoundWorkspaceRuntime } from "../index.js"; +import type { WorkspaceRuntimeDriver, WorkspaceDriverProcess } from "../drivers/index.js"; +import type { WorkspaceFilesOwner } from "../../workspace-helper/integration/index.js"; +import { bindWorkspaceHelper } from "../../workspace-helper/integration/index.js"; +import type { WorkspaceFilesSubscription } from "../../workspace-helper/index.js"; +import { registerGitCommonObservationCapability } from "./internal/capability.js"; + +const MAX_DISCOVERY_OUTPUT_BYTES = 64 * 1024; + +interface LogicalObservation { + workspaceId: string; + driver: WorkspaceRuntimeDriver; + listener: () => void; + scope: SharedScope; +} + +interface PhysicalOwner { + workspaceId: string; + client: WorkspaceFilesOwner; + bindings: Map; +} + +interface SharedScope { + key: string; + root: string; + observations: Set; + owner: PhysicalOwner | null; + tail: Promise; +} + +export interface ObservationRebindTransaction { + commit(): Promise; + rollback(): Promise; +} + +export interface GitCommonObservationCoordinator { + bind(runtime: BoundWorkspaceRuntime, workspaceId: string, driver: WorkspaceRuntimeDriver): void; + pause(workspaceId: string): Promise; + stageResume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; +} + +export function createGitCommonObservationCoordinator(options: { + launchHelper( + driver: WorkspaceRuntimeDriver, + workspaceId: string, + argv: readonly [string, ...string[]], + ): Promise; + runGit( + driver: WorkspaceRuntimeDriver, + workspaceId: string, + argv: readonly [string, ...string[]], + ): Promise; + isUnavailable(workspaceId: string): boolean; +}): GitCommonObservationCoordinator { + const scopes = new Map(); + + return { + bind(runtime, workspaceId, driver) { + registerGitCommonObservationCapability(runtime, { + observe: (listener) => observe(workspaceId, driver, listener), + }); + }, + async pause(workspaceId) { + for (const scope of relevantScopes(workspaceId)) { + await withScope(scope, () => pauseInScope(scope, workspaceId)); + } + }, + async stageResume(workspaceId) { + const locked = await lockScopes(relevantScopes(workspaceId)); + const transactions: ObservationRebindTransaction[] = []; + try { + for (const scope of locked.scopes) { + transactions.push(await stageScopeResume(scope, workspaceId)); + } + } catch (error) { + await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); + locked.release(); + throw error; + } + return finishWithRelease(combineTransactions(transactions), locked.release); + }, + async destroy(workspaceId) { + for (const scope of relevantScopes(workspaceId)) { + await withScope(scope, async () => { + await pauseInScope(scope, workspaceId); + const owned = [...scope.observations].filter( + (observation) => observation.workspaceId === workspaceId, + ); + for (const observation of owned) removeLogicalObservation(observation); + await closeEmptyScope(scope); + }); + } + }, + }; + + function relevantScopes(workspaceId: string): SharedScope[] { + return [...scopes.values()].filter( + (scope) => + scope.owner?.workspaceId === workspaceId || + [...scope.observations].some((observation) => observation.workspaceId === workspaceId), + ); + } + + async function observe( + workspaceId: string, + driver: WorkspaceRuntimeDriver, + listener: () => void, + ): Promise<{ unsubscribe(): Promise }> { + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const root = await discoverCommonGitDirectory(driver, workspaceId); + if (!root) return { unsubscribe: async () => {} }; + assertRuntimeAbsolutePath(root); + const key = `${inspection.state.executionDomainId}\0${root}`; + const scope = scopes.get(key) ?? { + key, + root, + observations: new Set(), + owner: null, + tail: Promise.resolve(), + }; + scopes.set(key, scope); + const logical: LogicalObservation = { workspaceId, driver, listener, scope }; + await withScope(scope, async () => { + scope.observations.add(logical); + try { + if (!scope.owner) { + const staged = await stageReplacementOwner(scope, workspaceId); + await staged.commit(); + } else { + const staged = await stageBindings(scope.owner, [logical]); + await staged.commit(); + } + } catch (error) { + removeLogicalObservation(logical); + await closeEmptyScope(scope); + throw error; + } + }); + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + await withScope(scope, async () => { + const binding = scope.owner?.bindings.get(logical); + scope.owner?.bindings.delete(logical); + await binding?.unsubscribe(); + removeLogicalObservation(logical); + await closeEmptyScope(scope); + }); + }, + }; + } + + async function pauseInScope(scope: SharedScope, workspaceId: string): Promise { + if (scope.owner?.workspaceId === workspaceId) { + const candidates = readyObservations(scope); + if (candidates.length === 0) { + await closeOwner(scope); + return; + } + const staged = await stageReplacementOwner(scope); + await staged.commit(); + return; + } + if (!scope.owner) return; + const owned = [...scope.owner.bindings].filter( + ([observation]) => observation.workspaceId === workspaceId, + ); + for (const [observation, binding] of owned) { + scope.owner.bindings.delete(observation); + await binding.unsubscribe(); + } + } + + async function stageScopeResume( + scope: SharedScope, + workspaceId: string, + ): Promise { + const missing = [...scope.observations].filter( + (observation) => + observation.workspaceId === workspaceId && !scope.owner?.bindings.has(observation), + ); + if (missing.length === 0) return noOpTransaction(); + if (!scope.owner) return stageReplacementOwner(scope, workspaceId); + return stageBindings(scope.owner, missing); + } + + async function stageReplacementOwner( + scope: SharedScope, + allowedWorkspaceId?: string, + ): Promise { + const candidates = readyObservations(scope, allowedWorkspaceId); + const selected = candidates[0]; + if (!selected) throw new Error("Git common observation has no ready workspace owner"); + const client = bindWorkspaceHelper({ + root: scope.root, + command: selected.driver.workspaceHelperCommand, + launch: async (argv) => { + const process = await options.launchHelper(selected.driver, selected.workspaceId, argv); + if (process.kind !== "pipes") { + throw new Error("Git common observation helper requires pipe mode"); + } + return process; + }, + }); + const owner: PhysicalOwner = { + workspaceId: selected.workspaceId, + client, + bindings: new Map(), + }; + const staged = await stageBindings(owner, candidates).catch(async (error) => { + await client.close(); + throw error; + }); + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + const previous = scope.owner; + await previous?.client.close(); + scope.owner = owner; + await staged.commit(); + }, + async rollback() { + if (finished) return; + finished = true; + await staged.rollback(); + await client.close(); + }, + }; + } + + async function stageBindings( + owner: PhysicalOwner, + observations: readonly LogicalObservation[], + ): Promise { + let committed = false; + const staged = new Map(); + try { + for (const observation of observations) { + if (owner.bindings.has(observation)) continue; + staged.set( + observation, + await owner.client.files.subscribe( + { paths: ["."], recursive: true, ignoredPaths: ["objects"] }, + (event) => { + if ( + committed && + event.type !== "error" && + !options.isUnavailable(observation.workspaceId) + ) { + observation.listener(); + } + }, + ), + ); + } + } catch (error) { + await Promise.allSettled([...staged.values()].map((binding) => binding.unsubscribe())); + throw error; + } + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const [observation, binding] of staged) owner.bindings.set(observation, binding); + committed = true; + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled([...staged.values()].map((binding) => binding.unsubscribe())); + }, + }; + } + + function readyObservations( + scope: SharedScope, + allowedWorkspaceId?: string, + ): LogicalObservation[] { + return [...scope.observations].filter( + (observation) => + observation.workspaceId === allowedWorkspaceId || + !options.isUnavailable(observation.workspaceId), + ); + } + + function removeLogicalObservation(observation: LogicalObservation): void { + observation.scope.observations.delete(observation); + } + + async function closeEmptyScope(scope: SharedScope): Promise { + if (scope.observations.size > 0) return; + await closeOwner(scope); + if (scopes.get(scope.key) === scope) scopes.delete(scope.key); + } + + async function closeOwner(scope: SharedScope): Promise { + const owner = scope.owner; + scope.owner = null; + await owner?.client.close(); + } + + async function discoverCommonGitDirectory( + driver: WorkspaceRuntimeDriver, + workspaceId: string, + ): Promise { + const process = await options.runGit(driver, workspaceId, [ + "git", + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]); + if (process.kind !== "pipes") throw new Error("Git common directory discovery requires pipes"); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout, process), + collect(process.stderr, process), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) return null; + const root = stdout.trim(); + if (!root && stderr.trim()) return null; + return root || null; + } + + async function withScope(scope: SharedScope, operation: () => Promise): Promise { + const locked = await lockScopes([scope]); + try { + return await operation(); + } finally { + locked.release(); + } + } + + async function lockScopes( + requested: readonly SharedScope[], + ): Promise<{ scopes: SharedScope[]; release(): void }> { + const ordered = [...new Set(requested)].sort((left, right) => + left.key.localeCompare(right.key), + ); + const releases: Array<() => void> = []; + for (const scope of ordered) { + const previous = scope.tail; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + scope.tail = previous.then(() => gate); + await previous; + releases.push(release); + } + let released = false; + return { + scopes: ordered, + release() { + if (released) return; + released = true; + for (const release of releases.toReversed()) release(); + }, + }; + } +} + +function combineTransactions( + transactions: readonly ObservationRebindTransaction[], +): ObservationRebindTransaction { + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const transaction of transactions) await transaction.commit(); + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); + }, + }; +} + +function noOpTransaction(): ObservationRebindTransaction { + return { commit: async () => {}, rollback: async () => {} }; +} + +function finishWithRelease( + transaction: ObservationRebindTransaction, + release: () => void, +): ObservationRebindTransaction { + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + try { + await transaction.commit(); + } finally { + release(); + } + }, + async rollback() { + if (finished) return; + finished = true; + try { + await transaction.rollback(); + } finally { + release(); + } + }, + }; +} + +function assertRuntimeAbsolutePath(root: string): void { + if (!nodePath.posix.isAbsolute(root) && !nodePath.win32.isAbsolute(root)) { + throw new Error("Git common directory must be runtime-absolute"); + } +} + +async function collect( + stream: NodeJS.ReadableStream, + process: WorkspaceDriverProcess, +): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of stream) { + const bytes = Buffer.from(chunk); + size += bytes.length; + if (size > MAX_DISCOVERY_OUTPUT_BYTES) { + process.kill("SIGKILL"); + throw new Error("Git common directory discovery exceeded 64 KiB"); + } + chunks.push(bytes); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts new file mode 100644 index 0000000000..01010dd195 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts @@ -0,0 +1,22 @@ +import type { BoundWorkspaceRuntime } from "../../index.js"; + +export interface GitCommonObservationCapability { + observe(listener: () => void): Promise<{ unsubscribe(): Promise }>; +} + +const capabilities = new WeakMap(); + +export function registerGitCommonObservationCapability( + runtime: BoundWorkspaceRuntime, + capability: GitCommonObservationCapability, +): void { + capabilities.set(runtime, capability); +} + +export function requireGitCommonObservationCapability( + runtime: BoundWorkspaceRuntime, +): GitCommonObservationCapability { + const capability = capabilities.get(runtime); + if (!capability) throw new Error("Bound workspace runtime cannot observe Git common metadata"); + return capability; +} diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 3824ab88d5..0f18da98ee 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -20,7 +20,35 @@ export type WorkspacePlacement = relativeCwd?: string; worktreeSlug?: string; } - | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string }; + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string } + | ResolvedWorktreePlacement; + +export type ResolvedWorktreeSource = + | { kind: "branch-off"; baseBranch: string; branchName: string } + | { kind: "checkout-branch"; branchName: string } + | { + kind: "checkout-change-request" | "checkout-github-pr"; + forge?: string; + changeRequestNumber?: number; + githubPrNumber?: number; + headRef: string; + headRepositoryOwner?: string; + baseRefName: string; + checkoutRefs?: readonly { + remoteName?: string; + remoteRef: string; + }[]; + localBranchName?: string; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + }; + +export interface ResolvedWorktreePlacement { + kind: "resolved-worktree"; + source: ResolvedWorktreeSource; + worktreeSlug: string; + relativeCwd?: string; +} export type WorkspaceProcessPurpose = | { kind: "agent"; agentId: string; provider: string } @@ -44,6 +72,9 @@ export interface CreateWorkspaceInput { project: { id: string; source: WorkspaceSource }; placement: WorkspacePlacement; setup?: readonly WorkspaceSetupCommand[]; + setupFromPaseoConfig?: boolean; + markFirstAgentBranchAutoName?: boolean; + seedPaseoConfigFrom?: string; } export interface WorkspaceProcessInput extends WorkspaceSetupCommand { @@ -86,19 +117,47 @@ export interface BoundWorkspaceRuntime { } export interface WorkspaceRuntimeService { - create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>; + reconcile(): Promise; + create(input: CreateWorkspaceInput): Promise; run(input: WorkspaceProcessInput): Promise; openTerminal(input: WorkspaceTerminalInput): Promise; bind(workspaceId: string): Promise; files(workspaceId: string): WorkspaceFiles; + inspect(workspaceId: string): Promise; + requireHostVisiblePath(workspaceId: string): Promise; + runSetup(workspaceId: string, signal?: AbortSignal): Promise; pause(workspaceId: string): Promise; resume(workspaceId: string): Promise; + archive(workspaceId: string): Promise; + restore(workspaceId: string): Promise; destroy(workspaceId: string): Promise; } +export interface WorkspaceRuntimePlacement { + workspaceId: string; + runtimeId: string; + cwd: string; + hostVisiblePath?: string; +} + +export type WorkspaceRuntimeInspection = + | { status: "missing" | "error" } + | ({ status: "paused" | "ready" } & Omit); + export interface WorkspaceRuntimeRecordStore { resolveRuntimeId(workspaceId: string): Promise; - persistRuntimeId(workspaceId: string, runtimeId: string): Promise; + persistRuntimeId( + workspaceId: string, + runtimeId: string, + placement: { cwd: string; hostVisiblePath?: string }, + ): Promise; + archiveWorkspaceRecord?(workspaceId: string): Promise; + restoreWorkspaceRecord?(workspaceId: string): Promise; + beginWorkspaceDeletion?(workspaceId: string): Promise; + removeWorkspaceRecord?(workspaceId: string): Promise; + listRuntimeRecords?(): Promise< + readonly { workspaceId: string; runtimeId: string; archived: boolean; deleting?: boolean }[] + >; } export interface ExternalWorkspaceRuntime { diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts index d0cf918648..d92f69fa22 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -12,15 +12,23 @@ import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-proces import { hostWorkspaceHelperCommand } from "./host-helper.js"; import { createRuntimeStateStore } from "./runtime-state.js"; +interface LocalRuntimeState extends WorkspaceDriverState { + compatibilityCwd?: string; +} + export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { const states = createRuntimeStateStore(paseoHome, "local"); async function inspect(workspaceId: string): Promise { - const state = await states.read(workspaceId); + const state = (await states.read(workspaceId)) as LocalRuntimeState | null; if (!state) return { status: "missing" }; try { if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; - return { status: state.lifecycle, state }; + return { + status: state.lifecycle, + state, + placement: hostPlacement(state.compatibilityCwd ?? state.root), + }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; return { status: "error", message: String(error) }; @@ -40,24 +48,24 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { workspaceHelperCommand: hostWorkspaceHelperCommand, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); - if (existing.status === "ready" || existing.status === "paused") return existing.state; + if (existing.status === "ready" || existing.status === "paused") return existing; if (input.project.source.kind !== "host-directory" || input.placement.kind !== "existing") { throw new Error("The local runtime adopts an existing host directory"); } - const root = await resolveRuntimeCwd( - path.resolve(input.project.source.path), - input.placement.relativeCwd, - ); + const sourceCwd = path.resolve(input.project.source.path); + const compatibilityCwd = path.resolve(sourceCwd, input.placement.relativeCwd ?? "."); + const root = await resolveRuntimeCwd(sourceCwd, input.placement.relativeCwd); if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`); - const state: WorkspaceDriverState = { + const state: LocalRuntimeState = { workspaceId: input.workspaceId, root, revision: "local:1", executionDomainId: "host", lifecycle: "ready", + compatibilityCwd, }; await states.write(state); - return state; + return { state, placement: hostPlacement(compatibilityCwd) }; }, inspect, async spawn(input: WorkspaceDriverSpawnInput) { @@ -79,10 +87,15 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { } const state = { ...inspection.state, lifecycle: "ready" as const }; await states.write(state); - return state; + const localState = state as LocalRuntimeState; + return { state, placement: hostPlacement(localState.compatibilityCwd ?? state.root) }; }, async destroy(workspaceId) { await states.remove(workspaceId); }, }; } + +function hostPlacement(root: string) { + return { cwd: root, hostVisiblePath: root }; +} diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index b0cfff3c0f..424b80d1b4 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -13,11 +13,20 @@ import type { WorkspaceDriverCreateInput, WorkspaceRuntimeDriver, } from "../drivers/index.js"; -import type { WorkspaceFiles } from "../../workspace-helper/index.js"; +import type { + WorkspaceFiles, + WorkspaceFilesSubscription, + WorkspaceWatchEvent, +} from "../../workspace-helper/index.js"; import { bindWorkspaceHelper, type WorkspaceFilesOwner, } from "../../workspace-helper/integration/index.js"; +import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema"; +import { + createGitCommonObservationCoordinator, + type ObservationRebindTransaction, +} from "../git-observation/integration.js"; const gracefulStopMilliseconds = 1_000; const forcedStopMilliseconds = 1_000; @@ -38,11 +47,34 @@ export function createService( { runtimeId: string; revision: string; client: WorkspaceFilesOwner } >(); const boundFiles = new Map(); - const unavailableFiles = new Map(); + const fileSubscriptions = new Map< + string, + Set<{ + input: Parameters[0]; + listener: (event: WorkspaceWatchEvent) => void; + bound: WorkspaceFilesSubscription | null; + }> + >(); + const unavailableFiles = new Map(); const boundRuntimes = new Map< string, { runtimeId: string; revision: string; runtime: BoundWorkspaceRuntime } >(); + const gitCommonObservations = createGitCommonObservationCoordinator({ + launchHelper: (driver, workspaceId, argv) => launchHelper(driver, workspaceId, argv), + runGit: async (driver, workspaceId, argv) => { + const runtimeProcess = await driver.spawn({ + workspaceId, + argv, + env: lifecycleEnvironment(), + purpose: { kind: "git" }, + stdio: { kind: "pipes" }, + }); + trackProcess(workspaceId, runtimeProcess); + return runtimeProcess; + }, + isUnavailable: (workspaceId) => unavailableFiles.has(workspaceId), + }); function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver { const driver = driversById.get(runtimeId); @@ -60,6 +92,7 @@ export function createService( driver: WorkspaceRuntimeDriver, input: WorkspaceProcessInput, ): Promise { + assertWorkspaceAvailable(input.workspaceId); validateRelativeCwd(input.cwd); const inspection = await driver.inspect(input.workspaceId); if (inspection.status !== "ready") { @@ -77,6 +110,7 @@ export function createService( driver: WorkspaceRuntimeDriver, input: WorkspaceTerminalInput, ): Promise { + assertWorkspaceAvailable(input.workspaceId); validateRelativeCwd(input.cwd); const inspection = await driver.inspect(input.workspaceId); if (inspection.status !== "ready") { @@ -94,6 +128,60 @@ export function createService( } return { + async reconcile() { + const runtimeRecords = (await records.listRuntimeRecords?.()) ?? []; + const failures: unknown[] = []; + const reconciledDomains = new Set(); + for (const driver of drivers) { + const domainId = driver.reconciliationDomainId ?? driver.id; + if (reconciledDomains.has(domainId)) continue; + reconciledDomains.add(domainId); + try { + await driver.reconcile?.( + runtimeRecords + .filter( + (record) => + (driversById.get(record.runtimeId)?.reconciliationDomainId ?? + record.runtimeId) === domainId, + ) + .map((record) => record.workspaceId), + ); + } catch (error) { + failures.push(error); + } + } + for (const record of runtimeRecords) { + try { + await sequence(record.workspaceId, async () => { + const driver = requireRegistered(record.runtimeId); + if (record.deleting) { + await finishDestroy(record.workspaceId, driver); + return; + } + const inspection = await driver.inspect(record.workspaceId); + if (inspection.status === "missing" || inspection.status === "error") return; + await records.persistRuntimeId( + record.workspaceId, + record.runtimeId, + inspection.placement, + ); + if (record.archived && inspection.status === "ready") { + await pauseWithDriver(record.workspaceId, driver); + } else if (!record.archived && inspection.status === "paused") { + unavailableFiles.set(record.workspaceId, "recovering"); + await driver.resume(record.workspaceId); + const rebind = await stageSubscriptionRebind(record.workspaceId, true); + await rebind.commit(); + unavailableFiles.delete(record.workspaceId); + } + }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) + throw new AggregateError(failures, "Workspace reconciliation failed"); + }, async create(input) { return sequence(input.workspaceId, async () => { const selectedRuntimeId = await records.resolveRuntimeId(input.workspaceId); @@ -106,11 +194,11 @@ export function createService( const before = await driver.inspect(input.workspaceId); let ownsNewResource = false; try { - const state = await driver.create(toDriverCreateInput(input)); + const ready = await driver.create(toDriverCreateInput(input)); ownsNewResource = before.status === "missing"; - if (state.lifecycle === "ready") { + if (ready.state.lifecycle === "ready") { const helper = bindWorkspaceHelper({ - root: state.root, + root: ready.state.root, command: driver.workspaceHelperCommand, launch: (argv) => launchHelper(driver, input.workspaceId, argv), }); @@ -121,7 +209,10 @@ export function createService( } } if (ownsNewResource) { - for (const command of input.setup ?? []) { + const setupCommands = input.setupFromPaseoConfig + ? await readConfiguredSetupCommands(input.workspaceId) + : (input.setup ?? []); + for (const command of setupCommands) { const process = await runWithDriver(driver, { workspaceId: input.workspaceId, ...command, @@ -138,9 +229,13 @@ export function createService( } } } - await records.persistRuntimeId(input.workspaceId, input.runtimeId); + await records.persistRuntimeId(input.workspaceId, input.runtimeId, ready.placement); unavailableFiles.delete(input.workspaceId); - return { workspaceId: input.workspaceId, runtimeId: input.runtimeId }; + return { + workspaceId: input.workspaceId, + runtimeId: input.runtimeId, + ...ready.placement, + }; } catch (error) { if (ownsNewResource) { try { @@ -167,6 +262,7 @@ export function createService( ); }, async bind(workspaceId) { + assertWorkspaceAvailable(workspaceId); const driver = await resolve(workspaceId); const inspection = await driver.inspect(workspaceId); if (inspection.status !== "ready") { @@ -183,6 +279,7 @@ export function createService( files: bindFiles(workspaceId), homeFiles: bindHomeFiles(workspaceId), }; + gitCommonObservations.bind(runtime, workspaceId, driver); boundRuntimes.set(workspaceId, { runtimeId: driver.id, revision: inspection.state.revision, @@ -198,34 +295,209 @@ export function createService( } return files; }, + async inspect(workspaceId) { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) return { status: "missing" }; + const inspection = await requireRegistered(runtimeId).inspect(workspaceId); + if (inspection.status === "ready" || inspection.status === "paused") { + return { status: inspection.status, ...inspection.placement }; + } + return { status: inspection.status }; + }, + async requireHostVisiblePath(workspaceId) { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) throw new Error(`Workspace runtime is not selected: ${workspaceId}`); + const inspection = await requireRegistered(runtimeId).inspect(workspaceId); + if (inspection.status !== "ready" && inspection.status !== "paused") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + if (!inspection.placement.hostVisiblePath) { + throw new Error(`Workspace has no host-visible path: ${workspaceId}`); + } + return inspection.placement.hostVisiblePath; + }, + async runSetup(workspaceId, signal) { + const commands = await readConfiguredSetupCommands(workspaceId); + for (const command of commands) { + if (signal?.aborted) throw signal.reason; + const process = await sequence(workspaceId, async () => + runWithDriver(await resolve(workspaceId), { + workspaceId, + ...command, + purpose: { kind: "setup" }, + }), + ); + const abort = () => process.kill("SIGTERM"); + signal?.addEventListener("abort", abort, { once: true }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + drainText(process.stdout), + drainText(process.stderr), + process.exited, + ]).finally(() => signal?.removeEventListener("abort", abort)); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Workspace setup failed (${exit.code ?? exit.signal}): ${stderr || stdout}`, + ); + } + } + }, async pause(workspaceId) { await sequence(workspaceId, async () => { - const driver = await resolve(workspaceId); - unavailableFiles.set(workspaceId, "paused"); - boundRuntimes.delete(workspaceId); - await closeFiles(workspaceId); - await stopProcesses(workspaceId); - await driver.pause(workspaceId); + await pauseWithDriver(workspaceId, await resolve(workspaceId)); }); }, async resume(workspaceId) { await sequence(workspaceId, async () => { + unavailableFiles.set(workspaceId, "recovering"); await (await resolve(workspaceId)).resume(workspaceId); + const rebind = await stageSubscriptionRebind(workspaceId, true); + await rebind.commit(); unavailableFiles.delete(workspaceId); }); }, + async archive(workspaceId) { + await sequence(workspaceId, async () => { + if (!records.archiveWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot archive workspaces"); + } + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status === "ready") await runArchiveHooks(workspaceId, driver); + await pauseWithDriver(workspaceId, driver); + await records.archiveWorkspaceRecord(workspaceId); + }); + }, + async restore(workspaceId) { + await sequence(workspaceId, async () => { + if (!records.restoreWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot restore workspaces"); + } + unavailableFiles.set(workspaceId, "recovering"); + await (await resolve(workspaceId)).resume(workspaceId); + const rebind = await stageSubscriptionRebind(workspaceId, true); + try { + await records.restoreWorkspaceRecord(workspaceId); + await rebind.commit(); + unavailableFiles.delete(workspaceId); + } catch (error) { + await rebind.rollback(); + throw error; + } + }); + }, async destroy(workspaceId) { await sequence(workspaceId, async () => { - const driver = await resolve(workspaceId); - unavailableFiles.set(workspaceId, "destroyed"); - boundRuntimes.delete(workspaceId); - await closeFiles(workspaceId); - await stopProcesses(workspaceId); - await driver.destroy(workspaceId); + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) { + unavailableFiles.delete(workspaceId); + return; + } + if (!records.beginWorkspaceDeletion || !records.removeWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot permanently delete workspaces"); + } + const driver = requireRegistered(runtimeId); + await records.beginWorkspaceDeletion(workspaceId); + await finishDestroy(workspaceId, driver); }); }, }; + async function finishDestroy(workspaceId: string, driver: WorkspaceRuntimeDriver): Promise { + unavailableFiles.set(workspaceId, "destroyed"); + boundRuntimes.delete(workspaceId); + await closeFiles(workspaceId, true); + await gitCommonObservations.destroy(workspaceId); + await stopProcesses(workspaceId); + await driver.destroy(workspaceId); + if (!records.removeWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot permanently delete workspaces"); + } + await records.removeWorkspaceRecord(workspaceId); + unavailableFiles.delete(workspaceId); + } + + async function pauseWithDriver( + workspaceId: string, + driver: WorkspaceRuntimeDriver, + ): Promise { + unavailableFiles.set(workspaceId, "paused"); + boundRuntimes.delete(workspaceId); + await closeFiles(workspaceId); + await gitCommonObservations.pause(workspaceId); + await stopProcesses(workspaceId); + await driver.pause(workspaceId); + } + + async function runArchiveHooks( + workspaceId: string, + driver: WorkspaceRuntimeDriver, + ): Promise { + const config = await readWorkspaceConfig(workspaceId); + if (!config) return; + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const env = { + ...lifecycleEnvironment(), + ...inspection.state.lifecycleEnvironment, + }; + for (const command of config.worktree?.teardown ?? []) { + const argv = lifecycleShellCommand(command); + const process = await runWithDriver(driver, { + workspaceId, + argv, + env, + purpose: { kind: "archive" }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + drainText(process.stdout), + drainText(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Workspace archive command failed (${exit.code ?? exit.signal}): ${stderr || stdout}`, + ); + } + } + } + + async function readConfiguredSetupCommands(workspaceId: string) { + const config = await readWorkspaceConfig(workspaceId); + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const env = { + ...lifecycleEnvironment(), + ...inspection.state.lifecycleEnvironment, + }; + return (config?.worktree?.setup ?? []).map((command) => ({ + argv: lifecycleShellCommand(command), + env, + })); + } + + async function readWorkspaceConfig(workspaceId: string) { + const files = await requireFiles(workspaceId); + const stat = await files.stat("paseo.json"); + if (stat.status === "missing") return null; + if (stat.status === "error") throw new Error(stat.error); + const file = await files.read("paseo.json"); + const chunks: Buffer[] = []; + for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); + return PaseoConfigSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } + + function assertWorkspaceAvailable(workspaceId: string): void { + const unavailable = unavailableFiles.get(workspaceId); + if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); + } + function bindFiles(workspaceId: string): WorkspaceFiles { return { async stat(path) { @@ -241,7 +513,28 @@ export function createService( return (await requireFiles(workspaceId)).write(input); }, async subscribe(input, listener) { - return (await requireFiles(workspaceId)).subscribe(input, listener); + const logical = { input, listener, bound: null as WorkspaceFilesSubscription | null }; + const subscriptions = fileSubscriptions.get(workspaceId) ?? new Set(); + subscriptions.add(logical); + fileSubscriptions.set(workspaceId, subscriptions); + try { + logical.bound = await (await requireFiles(workspaceId)).subscribe(input, listener); + } catch (error) { + subscriptions.delete(logical); + if (subscriptions.size === 0) fileSubscriptions.delete(workspaceId); + throw error; + } + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + subscriptions.delete(logical); + if (subscriptions.size === 0) fileSubscriptions.delete(workspaceId); + await logical.bound?.unsubscribe(); + logical.bound = null; + }, + }; }, }; } @@ -270,9 +563,13 @@ export function createService( return (await requireFilesOwner(workspaceId)).files; } - async function requireFilesOwner(workspaceId: string): Promise { + async function requireFilesOwner( + workspaceId: string, + allowUnavailable = false, + ): Promise { const unavailable = unavailableFiles.get(workspaceId); - if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); + if (unavailable && !allowUnavailable) + throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); const driver = await resolve(workspaceId); const inspection = await driver.inspect(workspaceId); if (inspection.status !== "ready") { @@ -324,15 +621,71 @@ export function createService( return client; } - async function closeFiles(workspaceId: string): Promise { + async function closeFiles(workspaceId: string, forgetSubscriptions = false): Promise { const cached = fileClients.get(workspaceId); const cachedHome = homeFileClients.get(workspaceId); fileClients.delete(workspaceId); homeFileClients.delete(workspaceId); + const subscriptions = fileSubscriptions.get(workspaceId); + if (subscriptions) { + for (const subscription of subscriptions) subscription.bound = null; + if (forgetSubscriptions) fileSubscriptions.delete(workspaceId); + } if (cached) await cached.client.close(); if (cachedHome) await cachedHome.client.close(); } + async function stageSubscriptionRebind( + workspaceId: string, + allowUnavailable = false, + ): Promise { + const files = await stageFileSubscriptionRebind(workspaceId, allowUnavailable); + try { + const git = await gitCommonObservations.stageResume(workspaceId); + return combineRebindTransactions([files, git]); + } catch (error) { + await files.rollback(); + throw error; + } + } + + async function stageFileSubscriptionRebind( + workspaceId: string, + allowUnavailable = false, + ): Promise { + const subscriptions = fileSubscriptions.get(workspaceId); + if (!subscriptions || subscriptions.size === 0) return noOpRebindTransaction(); + const files = (await requireFilesOwner(workspaceId, allowUnavailable)).files; + const staged: Array<{ + logical: typeof subscriptions extends Set ? T : never; + bound: WorkspaceFilesSubscription; + }> = []; + try { + for (const logical of subscriptions) { + staged.push({ + logical, + bound: await files.subscribe(logical.input, logical.listener), + }); + } + } catch (error) { + await Promise.allSettled(staged.map(({ bound }) => bound.unsubscribe())); + throw error; + } + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const { logical, bound } of staged) logical.bound = bound; + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled(staged.map(({ bound }) => bound.unsubscribe())); + }, + }; + } + async function launchHelper( driver: WorkspaceRuntimeDriver, workspaceId: string, @@ -397,12 +750,58 @@ export function createService( } } +function combineRebindTransactions( + transactions: readonly ObservationRebindTransaction[], +): ObservationRebindTransaction { + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const transaction of transactions) await transaction.commit(); + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); + }, + }; +} + +function noOpRebindTransaction(): ObservationRebindTransaction { + return { commit: async () => {}, rollback: async () => {} }; +} + async function drain(stream: NodeJS.ReadableStream): Promise { for await (const _chunk of stream) { // Setup output is consumed here so a verbose command cannot block on a full pipe. } } +async function drainText(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (bytes < 64 * 1024) chunks.push(buffer.subarray(0, 64 * 1024 - bytes)); + bytes += buffer.byteLength; + } + return Buffer.concat(chunks).toString("utf8").trim(); +} + +function lifecycleShellCommand(command: string): readonly [string, ...string[]] { + return process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", command] + : ["/bin/sh", "-c", command]; +} + +function lifecycleEnvironment(): Readonly> { + if (process.platform === "win32") { + return { PATH: process.env.PATH ?? "" }; + } + return { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin" }; +} + function validateRelativeCwd(cwd: string | undefined): void { if (cwd === undefined) return; const parts = cwd.split(/[\\/]/u); @@ -419,6 +818,8 @@ function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreate source: input.project.source, }, placement: input.placement, + ...(input.markFirstAgentBranchAutoName ? { markFirstAgentBranchAutoName: true } : {}), + ...(input.seedPaseoConfigFrom ? { seedPaseoConfigFrom: input.seedPaseoConfigFrom } : {}), }; } diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index 52775f89f9..b516519458 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -5,6 +5,7 @@ import { createWorktree, deletePaseoWorktree, getGitCommonDir, + seedPaseoConfigFile, type WorktreeSource, } from "../../../utils/worktree.js"; import type { @@ -17,6 +18,7 @@ import type { import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-process.js"; import { hostWorkspaceHelperCommand } from "./host-helper.js"; import { createRuntimeStateStore } from "./runtime-state.js"; +import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata } from "../../../utils/worktree-metadata.js"; interface WorktreeRuntimeState extends WorkspaceDriverState { sourceRoot: string; @@ -34,7 +36,7 @@ export function createWorktreeRuntime(options: { if (!state) return { status: "missing" }; try { if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; - return { status: state.lifecycle, state }; + return { status: state.lifecycle, state, placement: hostPlacement(state.root) }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; return { status: "error", message: String(error) }; @@ -54,7 +56,7 @@ export function createWorktreeRuntime(options: { workspaceHelperCommand: hostWorkspaceHelperCommand, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); - if (existing.status === "ready" || existing.status === "paused") return existing.state; + if (existing.status === "ready" || existing.status === "paused") return existing; if (input.project.source.kind !== "host-directory") { throw new Error("The worktree runtime requires a host Git checkout"); } @@ -62,16 +64,42 @@ export function createWorktreeRuntime(options: { throw new Error("The worktree runtime creates and owns its worktree"); } const sourceRoot = path.resolve(input.project.source.path); + const worktreeSlug = + input.placement.kind === "resolved-worktree" + ? input.placement.worktreeSlug + : (input.placement.worktreeSlug ?? input.workspaceId); const worktree = await createWorktree({ cwd: sourceRoot, source: toWorktreeSource(input.placement), - worktreeSlug: input.placement.worktreeSlug ?? input.workspaceId, + worktreeSlug, runSetup: false, paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, }); try { - const root = await resolveRuntimeCwd(worktree.worktreePath, input.placement.relativeCwd); + if ( + input.markFirstAgentBranchAutoName && + input.placement.kind === "resolved-worktree" && + input.placement.source.kind === "branch-off" + ) { + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktree.worktreePath, { + placeholderBranchName: worktree.branchName, + }); + } + const root = await resolveRuntimeCwd( + worktree.worktreePath, + input.placement.relativeCwd, + ).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Selected project directory is missing from the worktree", { + cause: error, + }); + } + throw error; + }); + if (input.seedPaseoConfigFrom) { + await seedPaseoConfigFile({ sourceCwd: input.seedPaseoConfigFrom, targetCwd: root }); + } if (!(await stat(root)).isDirectory()) { throw new Error(`Selected project directory is missing from the worktree: ${root}`); } @@ -83,9 +111,15 @@ export function createWorktreeRuntime(options: { revision: `worktree:${Date.now()}`, executionDomainId: "host", lifecycle: "ready", + lifecycleEnvironment: { + PASEO_SOURCE_CHECKOUT_PATH: sourceRoot, + PASEO_ROOT_PATH: sourceRoot, + PASEO_WORKTREE_PATH: worktree.worktreePath, + PASEO_BRANCH_NAME: worktree.branchName, + }, }; await states.write(state); - return state; + return { state, placement: hostPlacement(root) }; } catch (error) { await deletePaseoWorktree({ cwd: sourceRoot, @@ -117,7 +151,7 @@ export function createWorktreeRuntime(options: { } const state = { ...inspection.state, lifecycle: "ready" as const }; await states.write(state); - return state; + return { state, placement: hostPlacement(state.root) }; }, async destroy(workspaceId) { const state = (await states.read(workspaceId)) as WorktreeRuntimeState | null; @@ -139,9 +173,14 @@ export function createWorktreeRuntime(options: { }; } +function hostPlacement(root: string) { + return { cwd: root, hostVisiblePath: root }; +} + function toWorktreeSource( placement: Exclude, ): WorktreeSource { + if (placement.kind === "resolved-worktree") return placement.source as WorktreeSource; if (placement.kind === "branch") { return { kind: "branch-off", diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index 361a079f82..1cb52d0e1f 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { existsSync, watch } from "node:fs"; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -14,6 +15,9 @@ const fixtureExecutable = fileURLToPath( const helperExecutable = fileURLToPath( new URL("../workspace-helper/executable.mjs", import.meta.url), ); +const rebindHelperExecutable = fileURLToPath( + new URL("../test-utils/fixtures/workspace-helper-rebind-fixture.mjs", import.meta.url), +); const cleanupRoots: string[] = []; afterEach(async () => { @@ -95,6 +99,67 @@ test("a command runtime tears down and reconstructs its bound files capability", }); }); +test("a failed second subscription rebind rolls back the staged observer set before retry", async () => { + const fixture = await createFixture("transactional-rebind", false, "pty", {}, (root) => [ + processExecPath(), + rebindHelperExecutable, + helperExecutable, + path.join(root, "watch-launches"), + ]); + await Promise.all([ + writeFile(path.join(fixture.source, "first.txt"), "before\n"), + writeFile(path.join(fixture.source, "second.txt"), "before\n"), + ]); + await fixture.service.create(fixture.createInput); + const files = fixture.service.files(fixture.workspaceId); + let observeChanges = false; + let resolveFirstChange!: () => void; + let resolveSecondChange!: () => void; + const firstChange = new Promise((resolve) => { + resolveFirstChange = resolve; + }); + const secondChange = new Promise((resolve) => { + resolveSecondChange = resolve; + }); + const subscriptions = await Promise.all([ + files.subscribe({ paths: ["first.txt"] }, (event) => { + if (observeChanges && event.type === "changed") resolveFirstChange(); + }), + files.subscribe({ paths: ["second.txt"] }, (event) => { + if (observeChanges && event.type === "changed") resolveSecondChange(); + }), + ]); + + await fixture.service.pause(fixture.workspaceId); + await expect(fixture.service.resume(fixture.workspaceId)).rejects.toThrow( + "Workspace helper subscribe acknowledgement timed out", + ); + await expect(files.list(".")).rejects.toThrow( + `Workspace runtime is recovering: ${fixture.workspaceId}`, + ); + await fixture.service.resume(fixture.workspaceId); + + observeChanges = true; + await Promise.all([ + writeFile(path.join(fixture.source, "first.txt"), "after\n"), + writeFile(path.join(fixture.source, "second.txt"), "after\n"), + ]); + await Promise.all([firstChange, secondChange]); + expect(Number(await readFile(path.join(fixture.root, "watch-launches"), "utf8"))).toBe(4); + + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + expect( + execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }) + .split("\n") + .some( + (command) => + command.includes("workspace-helper-rebind-fixture.mjs") && + command.includes(path.basename(fixture.root)), + ), + ).toBe(false); + await fixture.service.destroy(fixture.workspaceId); +}); + test("a file operation racing pause cannot reconstruct the closing helper client", async () => { const fixture = await createFixture("files-pause-race", true); await writeFile(path.join(fixture.source, "watched.txt"), "before\n"); @@ -359,6 +424,7 @@ async function createFixture( withBarrier = false, modes: "pipes" | "pty" = "pty", runtimeOptions: Readonly> = {}, + helperCommand?: (root: string) => readonly [string, ...string[]], ) { const root = await mkdtemp(path.join(tmpdir(), `paseo-command-runtime-${name}-`)); cleanupRoots.push(root); @@ -374,6 +440,10 @@ async function createFixture( persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { fixture: { type: "command", @@ -382,7 +452,7 @@ async function createFixture( fixtureExecutable, ...(modes === "pipes" ? ["--modes", "pipes"] : []), ], - helperCommand: [processExecPath(), helperExecutable], + helperCommand: helperCommand?.(root) ?? [processExecPath(), helperExecutable], options: { stateDirectory, ...runtimeOptions, @@ -392,6 +462,7 @@ async function createFixture( }, }); return { + root, source, stateDirectory, barrierDirectory, diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index c4bb382cf6..0ad59664b9 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -21,6 +21,7 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { allocateWorkspaceServicePort } from "../workspace-service-port-allocator.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -69,6 +70,10 @@ test("the command runtime owns Docker workspace materialization, pipes, and life persistRuntimeId: async (workspaceId: string, runtimeId: string) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId: string) => { + runtimeIds.delete(workspaceId); + }, externalRuntimes: { docker: { type: "command", @@ -90,8 +95,15 @@ test("the command runtime owns Docker workspace materialization, pipes, and life }, placement: { kind: "existing" }, }); - expect(workspace).toEqual({ workspaceId, runtimeId: "docker" }); - expect("cwd" in workspace).toBe(false); + + expect(workspace).toEqual({ workspaceId, runtimeId: "docker", cwd: "/workspace" }); + await expect(service.inspect(workspaceId)).resolves.toEqual({ + status: "ready", + cwd: "/workspace", + }); + await expect(service.requireHostVisiblePath(workspaceId)).rejects.toThrow( + `Workspace has no host-visible path: ${workspaceId}`, + ); for (const [name, value] of [ ["user.email", "test@example.com"], @@ -351,7 +363,73 @@ test("the command runtime owns Docker workspace materialization, pipes, and life env: {}, purpose: { kind: "workspace-script", script: "missing-contract" }, }), - ).rejects.toThrow(`Workspace runtime is missing: ${workspaceId}`); + ).rejects.toThrow(`Workspace runtime is not selected: ${workspaceId}`); + } finally { + await service.destroy(workspaceId).catch(() => undefined); + } +}, 120_000); + +test("a selected Docker service port script executes in the container and leaves the host decoy untouched", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-port-script-")); + cleanupRoots.push(root); + const repo = await createRepository(root); + const script = path.join(repo, "port-script.sh"); + await writeFile( + script, + "#!/bin/sh\nprintf runtime > runtime-port-script.txt\nprintf '41234\\n'\n", + ); + await chmod(script, 0o755); + execFileSync("git", ["add", "port-script.sh"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "port script"], { + cwd: repo, + }); + const workspaceId = `docker-port-script-${Date.now()}`; + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + try { + await service.create({ + workspaceId, + runtimeId: "docker", + project: { id: "docker-port-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + await writeFile(script, "#!/bin/sh\nprintf host > host-port-script.txt\nprintf '49999\\n'\n"); + + await expect( + allocateWorkspaceServicePort({ + allocation: { portScript: "./port-script.sh" }, + cwd: repo, + scriptName: "web", + workspaceId, + branchName: null, + runtime: await service.bind(workspaceId), + }), + ).resolves.toBe(41234); + await expect(service.files(workspaceId).stat("runtime-port-script.txt")).resolves.toMatchObject( + { + status: "ready", + }, + ); + await expect(readFile(path.join(repo, "host-port-script.txt"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); } finally { await service.destroy(workspaceId).catch(() => undefined); } @@ -398,6 +476,10 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi persistRuntimeId: async (workspaceId, runtimeId) => { selectedRuntimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + selectedRuntimeIds.delete(workspaceId); + }, }); const projectRegistry = new FileBackedProjectRegistry( path.join(paseoHome, "projects", "projects.json"), @@ -474,8 +556,26 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi try { await publicClient.connect(); await publicClient.fetchAgents({ subscribe: { subscriptionId: "docker-runtime-agents" } }); + const recoveredRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + await recoveredRegistry.initialize(); + const recoveredRecords = await recoveredRegistry.list(); + const publicCwds = new Map( + recoveredRecords + .filter((workspace) => workspaceIds.includes(workspace.workspaceId)) + .map((workspace) => [workspace.workspaceId, workspace.cwd]), + ); + expect(Array.from(publicCwds.values())).toEqual(["/workspace", "/workspace"]); + expect( + recoveredRecords + .filter((workspace) => workspaceIds.includes(workspace.workspaceId)) + .map((workspace) => workspace.hostVisiblePath), + ).toEqual([null, null]); for (const workspaceId of workspaceIds) { - const snapshot = await publicClient.getProvidersSnapshot({ cwd: repo, workspaceId }); + const cwd = publicCwds.get(workspaceId)!; + const snapshot = await publicClient.getProvidersSnapshot({ cwd, workspaceId }); expect(snapshot.entries).toContainEqual( expect.objectContaining({ provider: "docker-fixture", @@ -496,7 +596,8 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi } const workspaceId = workspaceIds[0]!; - const workspaceGit = publicClient.bindWorkspaceGit({ workspaceId, cwd: repo }); + const cwd = publicCwds.get(workspaceId)!; + const workspaceGit = publicClient.bindWorkspaceGit({ workspaceId, cwd }); await expect(workspaceGit.getStatus()).resolves.toMatchObject({ isGit: true, isDirty: false, @@ -504,7 +605,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi const agent = await publicClient.createAgent({ provider: "docker-fixture", model: "fixture-model-v1", - cwd: repo, + cwd, workspaceId, title: "Docker public fake provider", }); @@ -513,14 +614,14 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi status: "idle", }); const edited = await publicClient.readFile( - repo, + cwd, "stdio-agent-output.txt", undefined, workspaceId, ); expect(new TextDecoder().decode(edited.bytes)).toBe("public docker edit\n"); const editedTrackedFile = await publicClient.readFile( - repo, + cwd, "committed.txt", undefined, workspaceId, @@ -529,7 +630,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi const dirtyDiff = await workspaceGit.getDiff({ mode: "uncommitted" }); expect(dirtyDiff.files.map((file) => file.path)).toContain("committed.txt"); const isolatedEnvironment = await publicClient.readFile( - repo, + cwd, "stdio-agent-env.txt", undefined, workspaceId, @@ -539,7 +640,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi const configuredAgent = await publicClient.createAgent({ provider: "docker-fixture", model: "fixture-model-v1", - cwd: repo, + cwd: publicCwds.get(workspaceIds[1]!)!, workspaceId: workspaceIds[1], title: "Configured Docker public fake provider", }); @@ -548,7 +649,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi status: "idle", }); const configuredEnvironment = await publicClient.readFile( - repo, + publicCwds.get(workspaceIds[1]!)!, "stdio-agent-env.txt", undefined, workspaceIds[1], @@ -568,7 +669,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi placement: { kind: "existing" }, }); - const rebuiltA = await publicClient.getProvidersSnapshot({ cwd: repo, workspaceId }); + const rebuiltA = await publicClient.getProvidersSnapshot({ cwd, workspaceId }); expect(rebuiltA.entries).toContainEqual( expect.objectContaining({ provider: "docker-fixture", @@ -576,7 +677,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi }), ); const unchangedB = await publicClient.getProvidersSnapshot({ - cwd: repo, + cwd: publicCwds.get(workspaceIds[1]!)!, workspaceId: workspaceIds[1], }); expect(unchangedB.entries).toContainEqual( @@ -652,6 +753,10 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { docker: { type: "command", @@ -708,6 +813,10 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou persistRuntimeId: async (id, runtimeId) => { runtimeIds.set(id, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, externalRuntimes: { docker: { type: "command", @@ -739,6 +848,62 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou } }, 30_000); +test("Docker reconciliation removes only orphaned resources with deterministic names and ownership labels", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-reconcile-")); + cleanupRoots.push(root); + const orphanWorkspaceId = `docker-orphan-${Date.now()}`; + const foreignWorkspaceId = `${orphanWorkspaceId}-foreign`; + const orphanVolume = dockerResourceName(orphanWorkspaceId); + const foreignVolume = `${dockerResourceName(foreignWorkspaceId)}-foreign`; + execFileSync("docker", [ + "volume", + "create", + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${orphanWorkspaceId}`, + orphanVolume, + ]); + execFileSync("docker", [ + "volume", + "create", + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${foreignWorkspaceId}`, + foreignVolume, + ]); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async () => null, + persistRuntimeId: async () => undefined, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async () => {}, + listRuntimeRecords: async () => [], + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + + try { + await service.reconcile(); + expect(dockerResourceCount("volume", orphanWorkspaceId)).toBe(0); + expect( + execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", foreignVolume], { + encoding: "utf8", + }).trim(), + ).toBe(foreignVolume); + } finally { + execFileSync("docker", ["volume", "rm", "-f", orphanVolume, foreignVolume], { + stdio: "ignore", + }); + } +}, 30_000); + async function createRepository(root: string): Promise { const repo = path.join(root, "repo"); await mkdir(repo); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 09ddd1474d..435b1a4fa0 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -139,8 +139,22 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", test("creates, pipes an exact environment, preserves status, and owns only its resources", async () => { const fixture = await createFixture(runtimeId); const created = await fixture.service.create(fixture.createInput); - expect(created).toEqual({ workspaceId: fixture.workspaceId, runtimeId }); - expect("cwd" in created).toBe(false); + const compatibilityCwd = + runtimeId === "local" ? fixture.runtimeRoot() : await realpath(fixture.runtimeRoot()); + expect(created).toEqual({ + workspaceId: fixture.workspaceId, + runtimeId, + cwd: compatibilityCwd, + hostVisiblePath: compatibilityCwd, + }); + await expect(fixture.service.inspect(fixture.workspaceId)).resolves.toEqual({ + status: "ready", + cwd: compatibilityCwd, + hostVisiblePath: compatibilityCwd, + }); + await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).resolves.toBe( + compatibilityCwd, + ); const runtimeRoot = fixture.runtimeRoot(); expect(await readFile(path.join(runtimeRoot, "committed.txt"), "utf8")).toBe("committed\n"); @@ -215,7 +229,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", env: {}, purpose: { kind: "workspace-script", script: "missing-contract" }, }), - ).rejects.toThrow(`Workspace runtime is missing: ${fixture.workspaceId}`); + ).rejects.toThrow(`Workspace runtime is not selected: ${fixture.workspaceId}`); }); test("repeated create does not rerun setup or change paused state", async () => { @@ -245,6 +259,243 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", await fixture.service.resume(fixture.workspaceId); await fixture.service.destroy(fixture.workspaceId); }); + + test("archive, restore, reconstruction, and permanent deletion converge", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "preserved\n"); + let resolveRestoredChange!: (paths: string[]) => void; + const restoredChange = new Promise((resolve) => { + resolveRestoredChange = resolve; + }); + const subscription = await fixture.service + .files(fixture.workspaceId) + .subscribe({ paths: ["dirty-untracked.txt"] }, (event) => { + if (event.type === "changed") resolveRestoredChange(event.paths); + }); + + await Promise.all([ + fixture.service.archive(fixture.workspaceId), + fixture.service.archive(fixture.workspaceId), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(true); + + await Promise.all([ + fixture.service.restore(fixture.workspaceId), + fixture.service.restore(fixture.workspaceId), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); + await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "observed\n"); + await expect(restoredChange).resolves.toEqual(["dirty-untracked.txt"]); + await subscription.unsubscribe(); + + await fixture.service.archive(fixture.workspaceId); + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await expect( + reconstructed.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "archived-admission" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await Promise.all([ + reconstructed.restore(fixture.workspaceId), + reconstructed.restore(fixture.workspaceId), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); + expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe( + "observed\n", + ); + + await Promise.all([ + reconstructed.destroy(fixture.workspaceId), + reconstructed.destroy(fixture.workspaceId), + ]); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(fixture.repo)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + }); + + test("registry failures leave lifecycle transitions retryable without opening an admission gap", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + fixture.lifecycleFailures.archive = 1; + await expect(fixture.service.archive(fixture.workspaceId)).rejects.toThrow( + "archive persistence failed", + ); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-archive-barrier" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.archive(fixture.workspaceId); + + fixture.lifecycleFailures.restore = 1; + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "restore persistence failed", + ); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-restore-barrier" }, + }), + ).rejects.toThrow(`Workspace runtime is recovering: ${fixture.workspaceId}`); + await fixture.service.restore(fixture.workspaceId); + const admitted = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "restored-admission" }, + }); + admitted.stdin.end(); + await expect(admitted.exited).resolves.toEqual({ code: 0, signal: null }); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("permanent deletion survives intent and final-record persistence failures", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await writeFile(path.join(runtimeRoot, "adopted-state.txt"), "keep local data\n"); + + fixture.lifecycleFailures.beginDelete = 1; + await expect(fixture.service.destroy(fixture.workspaceId)).rejects.toThrow( + "delete intent persistence failed", + ); + expect(existsSync(runtimeRoot)).toBe(true); + const usable = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-delete-intent" }, + }); + usable.stdin.end(); + await expect(usable.exited).resolves.toEqual({ code: 0, signal: null }); + + fixture.lifecycleFailures.remove = 1; + await expect(fixture.service.destroy(fixture.workspaceId)).rejects.toThrow( + "record removal persistence failed", + ); + expect(fixture.deletingWorkspaceIds.has(fixture.workspaceId)).toBe(true); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await reconstructed.reconcile(); + await reconstructed.destroy(fixture.workspaceId); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(fixture.repo)).toBe(true); + if (runtimeId === "local") { + await expect(readFile(path.join(runtimeRoot, "adopted-state.txt"), "utf8")).resolves.toBe( + "keep local data\n", + ); + } + }); + + test("startup reconciliation converges interrupted archive and restore transitions", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + await fixture.service.pause(fixture.workspaceId); + + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await reconstructed.reconcile(); + const resumed = await reconstructed.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "reconciled-resume" }, + }); + resumed.stdin.end(); + await expect(resumed.exited).resolves.toEqual({ code: 0, signal: null }); + + await reconstructed.archive(fixture.workspaceId); + await reconstructed.resume(fixture.workspaceId); + const afterInterruptedRestore = createWorkspaceRuntimeService(fixture.options); + await afterInterruptedRestore.reconcile(); + await expect( + afterInterruptedRestore.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "reconciled-archive" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await afterInterruptedRestore.destroy(fixture.workspaceId); + }); + + test("archive hooks execute inside the selected runtime exactly once", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const configure = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + process.execPath, + "-e", + `require("node:fs").writeFileSync("paseo.json", ${JSON.stringify( + JSON.stringify({ + worktree: { teardown: ["printf archived >> archive-hook.txt"] }, + }), + )})`, + ], + env: {}, + purpose: { kind: "setup" }, + }); + configure.stdin.end(); + await expect(configure.exited).resolves.toEqual({ code: 0, signal: null }); + + await fixture.service.archive(fixture.workspaceId); + await fixture.service.archive(fixture.workspaceId); + expect(await readFile(path.join(fixture.runtimeRoot(), "archive-hook.txt"), "utf8")).toBe( + "archived", + ); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + +posixDescribe("reconstruction placement recovery", () => { + test("refreshes persisted compatibility cwd from driver inspect", async () => { + const root = await temporaryRoot("reconcile-compatibility-cwd"); + const repo = await createRepository(root); + const runtimeIds = new Map(); + const placements = new Map(); + const options: WorkspaceRuntimeOptions = { + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + runtimeIds.set(workspaceId, runtimeId); + placements.set(workspaceId, placement); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + placements.delete(workspaceId); + }, + listRuntimeRecords: async () => [ + { workspaceId: "recover-cwd", runtimeId: "local", archived: false }, + ], + }; + const service = createWorkspaceRuntimeService(options); + await service.create({ + workspaceId: "recover-cwd", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + placements.set("recover-cwd", { cwd: "/stale/presentation/path" }); + + await createWorkspaceRuntimeService(options).reconcile(); + + expect(placements.get("recover-cwd")).toEqual({ cwd: repo, hostVisiblePath: repo }); + await service.destroy("recover-cwd"); + }); }); posixDescribe("fail-closed and producer cleanup", () => { @@ -387,10 +638,16 @@ posixDescribe.each(["local", "worktree"] as const)( }, ); -async function createFixture(runtimeId: "local" | "worktree") { +async function createFixture( + runtimeId: "local" | "worktree", + fixtureOptions: { lifecycleRecords?: boolean } = {}, +) { const root = await temporaryRoot(runtimeId); const repo = await createRepository(root); const runtimeIds = new Map(); + const archivedWorkspaceIds = new Set(); + const deletingWorkspaceIds = new Set(); + const lifecycleFailures = { archive: 0, restore: 0, beginDelete: 0, remove: 0 }; const worktreesRoot = path.join(root, "worktrees"); const options: WorkspaceRuntimeOptions = { paseoHome: path.join(root, "home"), @@ -399,6 +656,47 @@ async function createFixture(runtimeId: "local" | "worktree") { persistRuntimeId: async (workspaceId, selectedRuntimeId) => { runtimeIds.set(workspaceId, selectedRuntimeId); }, + beginWorkspaceDeletion: async (workspaceId) => { + if (lifecycleFailures.beginDelete > 0) { + lifecycleFailures.beginDelete -= 1; + throw new Error("delete intent persistence failed"); + } + deletingWorkspaceIds.add(workspaceId); + }, + removeWorkspaceRecord: async (workspaceId) => { + if (lifecycleFailures.remove > 0) { + lifecycleFailures.remove -= 1; + throw new Error("record removal persistence failed"); + } + runtimeIds.delete(workspaceId); + archivedWorkspaceIds.delete(workspaceId); + deletingWorkspaceIds.delete(workspaceId); + }, + ...(fixtureOptions.lifecycleRecords + ? { + archiveWorkspaceRecord: async (workspaceId: string) => { + if (lifecycleFailures.archive > 0) { + lifecycleFailures.archive -= 1; + throw new Error("archive persistence failed"); + } + archivedWorkspaceIds.add(workspaceId); + }, + restoreWorkspaceRecord: async (workspaceId: string) => { + if (lifecycleFailures.restore > 0) { + lifecycleFailures.restore -= 1; + throw new Error("restore persistence failed"); + } + archivedWorkspaceIds.delete(workspaceId); + }, + listRuntimeRecords: async () => + [...runtimeIds].map(([workspaceId, selectedRuntimeId]) => ({ + workspaceId, + runtimeId: selectedRuntimeId, + archived: archivedWorkspaceIds.has(workspaceId), + deleting: deletingWorkspaceIds.has(workspaceId), + })), + } + : {}), }; const workspaceId = `${runtimeId}-workspace`; const createInput: CreateWorkspaceInput = { @@ -423,6 +721,10 @@ async function createFixture(runtimeId: "local" | "worktree") { root, repo, options, + runtimeIds, + archivedWorkspaceIds, + deletingWorkspaceIds, + lifecycleFailures, workspaceId, createInput, service: createWorkspaceRuntimeService(options), diff --git a/packages/server/src/server/workspace-service-port-allocator.test.ts b/packages/server/src/server/workspace-service-port-allocator.test.ts index c955434872..4859794bdb 100644 --- a/packages/server/src/server/workspace-service-port-allocator.test.ts +++ b/packages/server/src/server/workspace-service-port-allocator.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -60,7 +60,7 @@ describe("allocateWorkspaceServicePort", () => { branchName: "feature/allocator-context", }), ).resolves.toBe(port); - expect(readFileSync(join(tempDir, "cwd"), "utf8")).toBe(tempDir); + expect(readFileSync(join(tempDir, "cwd"), "utf8")).toBe(realpathSync(tempDir)); expect(readFileSync(join(tempDir, "argv"), "utf8")).toBe( `app-server|wks_port_allocator|feature/allocator-context|${tempDir}`, ); diff --git a/packages/server/src/server/workspace-service-port-allocator.ts b/packages/server/src/server/workspace-service-port-allocator.ts index 7d0dd78884..e10f033d27 100644 --- a/packages/server/src/server/workspace-service-port-allocator.ts +++ b/packages/server/src/server/workspace-service-port-allocator.ts @@ -2,6 +2,7 @@ import net from "node:net"; import { execCommand } from "../utils/spawn.js"; import { findFreePort } from "./service-proxy.js"; import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; const PORT_SCRIPT_TIMEOUT_MS = 10_000; const PORT_SCRIPT_MAX_OUTPUT_BYTES = 1024; @@ -19,6 +20,7 @@ export interface AllocateWorkspaceServicePortOptions { scriptName: string; workspaceId: string; branchName: string | null; + runtime?: Pick; reservedPorts?: ReadonlySet; } @@ -33,6 +35,7 @@ export async function allocateWorkspaceServicePort( workspaceId: options.workspaceId, branchName: options.branchName, reservedPorts: options.reservedPorts, + runtime: options.runtime, }); } if (options.allocation?.range) { @@ -51,25 +54,32 @@ async function allocatePortFromScript(options: { workspaceId: string; branchName: string | null; reservedPorts: ReadonlySet | undefined; + runtime: Pick | undefined; }): Promise { let result: { stdout: string; stderr: string }; try { - result = await execCommand( + const argv: [string, ...string[]] = [ options.command, - [options.scriptName, options.workspaceId, options.branchName ?? "", options.cwd], - { - cwd: options.cwd, - envOverlay: { - PASEO_SCRIPTNAME: options.scriptName, - PASEO_WORKSPACE_ID: options.workspaceId, - PASEO_BRANCH_NAME: options.branchName ?? "", - PASEO_WORKTREE_PATH: options.cwd, - }, - timeout: PORT_SCRIPT_TIMEOUT_MS, - maxBuffer: PORT_SCRIPT_MAX_OUTPUT_BYTES, - shell: false, - }, - ); + options.scriptName, + options.workspaceId, + options.branchName ?? "", + options.cwd, + ]; + const env = { + PASEO_SCRIPTNAME: options.scriptName, + PASEO_WORKSPACE_ID: options.workspaceId, + PASEO_BRANCH_NAME: options.branchName ?? "", + PASEO_WORKTREE_PATH: options.cwd, + }; + result = options.runtime + ? await runInWorkspace(options.runtime, argv, env) + : await execCommand(options.command, argv.slice(1), { + cwd: options.cwd, + envOverlay: env, + timeout: PORT_SCRIPT_TIMEOUT_MS, + maxBuffer: PORT_SCRIPT_MAX_OUTPUT_BYTES, + shell: false, + }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Service port script '${options.command}' failed: ${detail}`, { cause: error }); @@ -91,6 +101,53 @@ async function allocatePortFromScript(options: { return port; } +async function runInWorkspace( + runtime: Pick, + argv: [string, ...string[]], + env: Record, +): Promise<{ stdout: string; stderr: string }> { + const executable = await runtime.resolveCommand(argv[0]); + if (!executable) throw new Error(`command is unavailable in the workspace: ${argv[0]}`); + const process = await runtime.run({ + argv: [executable, ...argv.slice(1)], + cwd: ".", + env, + purpose: { kind: "workspace-script", script: "service-port-allocation" }, + }); + process.stdin.end(); + let timeout: NodeJS.Timeout | undefined; + try { + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + process.kill("SIGKILL"); + reject(new Error(`timed out after ${PORT_SCRIPT_TIMEOUT_MS}ms`)); + }, PORT_SCRIPT_TIMEOUT_MS); + }); + const [stdout, stderr, exit] = await Promise.race([ + Promise.all([readBounded(process.stdout), readBounded(process.stderr), process.exited]), + timedOut, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`exited with ${exit.code ?? exit.signal}: ${stderr || stdout}`); + } + return { stdout, stderr }; + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function readBounded(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > PORT_SCRIPT_MAX_OUTPUT_BYTES) throw new Error("output exceeded limit"); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + async function allocatePortFromRange( range: PortRange, reservedPorts: ReadonlySet, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 75785057e4..d59032e339 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -30,7 +30,11 @@ import { requirePlannedWorkspaceServicePort, refreshWorkspaceServicePort, } from "./workspace-service-port-registry.js"; -import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; +import type { + PaseoConfig, + PaseoServicePortAllocation, +} from "@getpaseo/protocol/paseo-config-schema"; +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -701,6 +705,9 @@ export interface WorktreeScriptResult { export interface SpawnWorkspaceScriptOptions { repoRoot: string; + runtimeCwd?: string; + runtime?: Pick; + paseoConfig?: PaseoConfig; workspaceId: string; projectSlug: string; branchName: string | null; @@ -736,6 +743,7 @@ async function setupServiceScriptRoute(params: { existingRuntimeEntry: ReturnType; serviceProxy: ServiceProxySubsystem; servicePortAllocation: PaseoServicePortAllocation | undefined; + runtime: Pick | undefined; }): Promise { const { scriptConfigs, @@ -751,6 +759,7 @@ async function setupServiceScriptRoute(params: { existingRuntimeEntry, serviceProxy, servicePortAllocation, + runtime, } = params; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; @@ -777,6 +786,7 @@ async function setupServiceScriptRoute(params: { workspaceId, branchName, reservedPorts, + runtime, }), }); const port = @@ -792,6 +802,7 @@ async function setupServiceScriptRoute(params: { workspaceId, branchName, reservedPorts, + runtime, }), }) : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); @@ -830,6 +841,7 @@ async function acquireWorkspaceScriptTerminal(params: { existingRuntimeEntry: ReturnType; terminalManager: TerminalManager; repoRoot: string; + runtimeCwd?: string; workspaceId: string; scriptName: string; env: Record | undefined; @@ -839,6 +851,7 @@ async function acquireWorkspaceScriptTerminal(params: { existingRuntimeEntry, terminalManager, repoRoot, + runtimeCwd, workspaceId, scriptName, env, @@ -851,6 +864,7 @@ async function acquireWorkspaceScriptTerminal(params: { reusableTerminal ?? (await terminalManager.createTerminal({ cwd: repoRoot, + ...(runtimeCwd ? { cwd: runtimeCwd } : {}), workspaceId, name: scriptName, title: scriptName, @@ -864,6 +878,8 @@ export async function spawnWorkspaceScript( ): Promise { const { repoRoot, + runtimeCwd, + paseoConfig, workspaceId, projectSlug, branchName, @@ -878,11 +894,8 @@ export async function spawnWorkspaceScript( logger, onLifecycleChanged, } = options; - const configResult = readPaseoConfig(repoRoot); - if (!configResult.ok) { - throw paseoConfigParseError(configResult); - } - const scriptConfigs = getScriptConfigs(configResult.config); + const resolvedPaseoConfig = resolveWorkspaceScriptConfig(repoRoot, paseoConfig); + const scriptConfigs = getScriptConfigs(resolvedPaseoConfig); const config = scriptConfigs.get(scriptName); if (!config) { throw new Error(`Script '${scriptName}' is not configured in paseo.json`); @@ -917,7 +930,8 @@ export async function spawnWorkspaceScript( serviceProxyPublicBaseUrl, existingRuntimeEntry, serviceProxy, - servicePortAllocation: configResult.config?.worktree?.servicePorts ?? globalServicePorts, + servicePortAllocation: resolvedPaseoConfig?.worktree?.servicePorts ?? globalServicePorts, + runtime: options.runtime, }); hostname = serviceSetup.hostname; port = serviceSetup.port; @@ -930,6 +944,7 @@ export async function spawnWorkspaceScript( existingRuntimeEntry, terminalManager, repoRoot, + runtimeCwd, workspaceId, scriptName, env, @@ -1044,6 +1059,16 @@ export async function spawnWorkspaceScript( } } +function resolveWorkspaceScriptConfig( + repoRoot: string, + suppliedConfig: PaseoConfig | undefined, +): PaseoConfig | null { + if (suppliedConfig) return suppliedConfig; + const result = readPaseoConfig(repoRoot); + if (!result.ok) throw paseoConfigParseError(result); + return result.config; +} + export function teardownWorktreeScripts(options: { hostnames: string[]; serviceProxy: Pick; diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 98ffa777da..479d0e03bb 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -48,6 +48,12 @@ export interface CreateWorktreeCoreResult { created: boolean; } +export interface WorktreeCorePlan { + repoRoot: string; + intent: WorktreeCreationIntent; + worktreeSlug: string; +} + export async function createWorktreeCore( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, @@ -59,6 +65,37 @@ async function createWorktreeCoreWithPriority( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, ): Promise { + const plan = await planWorktreeCore(input, deps); + const { repoRoot, intent, worktreeSlug: normalizedSlug } = plan; + const existingWorktree = await resolveExistingWorktreeForSlug({ + slug: normalizedSlug, + repoRoot, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }); + if (existingWorktree) { + return { worktree: existingWorktree, intent, repoRoot, created: false }; + } + + return { + worktree: await createWorktree({ + cwd: repoRoot, + worktreeSlug: normalizedSlug, + source: intent, + runSetup: input.runSetup ?? true, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }), + intent, + repoRoot, + created: true, + }; +} + +export async function planWorktreeCore( + input: CreateWorktreeCoreInput, + deps: CreateWorktreeCoreDeps, +): Promise { const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); const requestedWorktreeSlug = input.worktreeSlug ? normalizeWorktreeSlug(input.worktreeSlug) @@ -118,29 +155,7 @@ async function createWorktreeCoreWithPriority( } } - const existingWorktree = await resolveExistingWorktreeForSlug({ - slug: normalizedSlug, - repoRoot, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }); - if (existingWorktree) { - return { worktree: existingWorktree, intent, repoRoot, created: false }; - } - - return { - worktree: await createWorktree({ - cwd: repoRoot, - worktreeSlug: normalizedSlug, - source: intent, - runSetup: input.runSetup ?? true, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }), - intent, - repoRoot, - created: true, - }; + return { repoRoot, intent, worktreeSlug: normalizedSlug }; } async function resolveForge( diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cbf9a7f6ee..b30f3c7090 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -110,6 +110,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl?: string | null; onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null; + runWorkspaceSetup?: (workspaceId: string, signal?: AbortSignal) => Promise; } interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies { @@ -650,6 +651,15 @@ export async function createPaseoWorktreeWorkflow( setupContinuation: { kind: "agent", startAfterAgentCreate: ({ agentId }) => { + if (dependencies.runWorkspaceSetup) { + void dependencies.runWorkspaceSetup(workspace.workspaceId).catch((error) => { + dependencies.sessionLogger.error( + { err: error, workspaceId: workspace.workspaceId, agentId }, + "Runtime-owned workspace setup failed", + ); + }); + return; + } void runAsyncWorktreeBootstrap({ agentId, workspaceId: workspace.workspaceId, @@ -736,6 +746,10 @@ export async function runWorktreeSetupInBackground( if (!options.shouldBootstrap) { emitSetupProgress("completed", null); + } else if (dependencies.runWorkspaceSetup) { + setupStarted = true; + await dependencies.runWorkspaceSetup(workspaceId, signal); + emitSetupProgress("completed", null); } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index e2e07ce1d3..b74bf29d29 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -262,6 +262,61 @@ describe("terminal-session-controller legacy terminal creation", () => { ]); }); + test("never selects a runtime terminal from compatibility cwd", async () => { + const cwd = "/workspace"; + const outboundMessages: SessionOutboundMessage[] = []; + const createTerminal = vi.fn(); + const terminalManager: TerminalManager = { + getTerminals: vi.fn(), + createTerminal, + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [ + { workspaceId: "docker-a", cwd, runtimeId: "docker" }, + { workspaceId: "docker-b", cwd, runtimeId: "docker" }, + ], + }); + + await controller.dispatch({ + type: "create_terminal_request", + cwd, + requestId: "selected-without-id", + }); + + expect(createTerminal).not.toHaveBeenCalled(); + expect(outboundMessages).toEqual([ + { + type: "create_terminal_response", + payload: { + terminal: null, + error: "workspaceId is required", + requestId: "selected-without-id", + }, + }, + ]); + }); + test("forwards the client-provided viewport size to the terminal manager", async () => { const outboundMessages: SessionOutboundMessage[] = []; const createTerminal = vi.fn( diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 05d544dcc2..e4c94a6ff0 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -88,6 +88,7 @@ export interface TerminalSessionControllerOptions { interface TerminalWorkspaceRef { workspaceId: string; cwd: string; + runtimeId?: string | null; } export interface TerminalSessionControllerMetrics { @@ -584,7 +585,9 @@ export class TerminalSessionController { } private async resolveLegacyTerminalWorkspaceId(cwd: string): Promise { - const workspaceRefs = await this.listTerminalWorkspaceRefs(); + const workspaceRefs = (await this.listTerminalWorkspaceRefs()).filter( + (workspace) => workspace.runtimeId == null, + ); if (workspaceRefs.length === 0) { return null; }