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; }