diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..007c9d670 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,12 +104,15 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/preview-isolation.test.ts + packages/ui/src/components/session-list-projection.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/native/directory-path.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -127,6 +130,7 @@ jobs: packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-metadata.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts - name: Test restore ownership integration @@ -136,6 +140,9 @@ jobs: packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts + packages/ui/src/stores/session-worktree-binding.test.ts + packages/ui/src/stores/worktree-deletion.test.ts + packages/ui/src/stores/opencode-workspaces.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" @@ -174,6 +181,9 @@ jobs: - name: Test Windows server spawn behavior run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts + - name: Test Electron native behavior on Windows + run: npm run test:native --workspace @neuralnomads/codenomad-electron-app + - name: Prepare Tauri test resources run: >- npm run dev:prep --workspace @codenomad/tauri-app && @@ -181,4 +191,5 @@ jobs: - name: Test Tauri crate on Windows working-directory: packages/tauri-app/src-tauri - run: cargo test --locked + # ponytail: cross-host tests spawn real Node/PowerShell processes; avoid runner contention. + run: cargo test --locked -- --test-threads=1 diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts index 15f1698e8..596545c1f 100644 --- a/packages/electron-app/electron/main/client-state-ipc.test.ts +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -6,10 +6,10 @@ import { setupClientStateIPC } from "./client-state-ipc" function harness() { const handlers = new Map unknown>() const listeners = new Map void>() - const frame = { url: "http://127.0.0.1:3000/app" } + const frame = { url: "http://127.0.0.1:3000/?launch=desktop" } const webContents = { mainFrame: frame, - getURL: () => "http://127.0.0.1:3000/app", + getURL: () => frame.url, on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener), } const window = { isDestroyed: () => false, webContents } @@ -50,6 +50,10 @@ test("IPC channels enforce the current main sender, frame, origin, and token", a { sender: h.webContents, senderFrame: { url: h.frame.url } }, { sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } }, ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) + + h.frame.url = "http://127.0.0.1:3000/previews/token" + await assert.rejects(h.handlers.get("client-state:claimAccess")!(event as never, "preview-token") as Promise, /renderer document/) + await assert.rejects(h.handlers.get("client-state:load")!(event as never, "token") as Promise, /renderer document/) }) test("only the registered current window can reset renderer authority", () => { diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts index 51b0b0c5f..31cd7e7c8 100644 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -1,7 +1,7 @@ import type { BrowserWindow, IpcMainInvokeEvent } from "electron" import type { ClientStateManager } from "./client-state" import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" -import { isAllowedRendererOrigin } from "./renderer-origin" +import { isAllowedRendererDocument, isAllowedRendererOrigin } from "./renderer-origin" interface IPCRegistrar { handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void @@ -19,11 +19,11 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | n const currentUrl = mainWindow.webContents.getURL() if ( - !isAllowedRendererOrigin(currentUrl, allowedOrigins) || - !isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) || + !isAllowedRendererDocument(currentUrl, allowedOrigins) || + !isAllowedRendererDocument(event.senderFrame.url, allowedOrigins) || new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin ) { - throw new Error("Client state IPC is not available to the current renderer origin") + throw new Error("Client state IPC is not available to the current renderer document") } } diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts index 18ee8d3ab..f8c621d69 100644 --- a/packages/electron-app/electron/main/client-state.test.ts +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -29,13 +29,15 @@ function harness(t: test.TestContext, initial?: object) { return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } } -test("renderer access is exclusive per document and resettable", async (t) => { +test("renderer capability rejects missing, wrong, and stale document tokens", async (t) => { const manager = harness(t, { version: 1, restoreEnabled: true }).create() assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) + assert.throws(() => manager.assertRendererAccessToken(undefined), /nonempty string/) assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/) assert.equal(manager.claimClientStateAccess("document-1"), true) assert.equal(manager.claimClientStateAccess("document-1"), true) assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/) + assert.throws(() => manager.assertRendererAccessToken("document-2"), /has not been claimed/) manager.assertRendererAccessToken("document-1") assert.equal(await manager.saveClientState({ saved: true }), true) manager.resetRendererAccessToken() diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 6e664d907..d18d9f6e8 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,7 +1,9 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" import fs from "fs" +import type { ClientStateManager } from "./client-state" import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" +import { isManagedMainFrame, openManagedWorktreeDirectory } from "./worktree-directory" let wakeLockId: number | null = null @@ -18,7 +20,7 @@ interface DialogOpenResult { paths: string[] } -export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { +export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager, clientStateManager: ClientStateManager) { cliManager.on("status", (status: CliStatus) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send("cli:status", status) @@ -88,6 +90,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return directories }) + ipcMain.handle("filesystem:openDirectory", async (event, accessToken: unknown, instanceId: unknown, worktreeSlug: unknown): Promise<{ ok: boolean }> => { + const renderer = { sender: event.sender, senderFrame: event.senderFrame } + const authority = await cliManager.captureReadyAuthority() + const baseUrl = authority?.url + if (!authority || !baseUrl || !isManagedMainFrame(renderer, mainWindow, baseUrl)) { + throw new Error("Directory opening is unavailable from this renderer") + } + clientStateManager.assertRendererAccessToken(accessToken) + if (typeof instanceId !== "string" || typeof worktreeSlug !== "string") throw new Error("Invalid worktree") + await openManagedWorktreeDirectory({ + baseUrl, + instanceId, + worktreeSlug, + fetch: (url, init) => mainWindow.webContents.session.fetch(url, init), + openPath: (directory) => cliManager.withReadyAuthority(authority, () => { + if (!isManagedMainFrame(renderer, mainWindow, baseUrl)) { + throw new Error("Directory opening is unavailable from this renderer") + } + clientStateManager.assertRendererAccessToken(accessToken) + return shell.openPath(directory) + }), + }) + return { ok: true } + }) + ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { const next = Boolean(enabled) if (next) { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index f90fb3611..3737aa0e0 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -11,7 +11,7 @@ import { ClientStateLifecycle } from "./client-state-lifecycle" import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { isAllowedMainWindowDocument, resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" import { clampWindowBounds, @@ -235,6 +235,8 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole } function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { + const isTrustedLocalDocument = (url: string) => !navigationController + || isAllowedMainWindowDocument(url, getAllowedRendererOrigins(window)) const handleExternal = (url: string) => { shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) } @@ -244,13 +246,15 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli handleExternal(url) return { action: "deny" } } - return { action: "allow" } + return { action: isTrustedLocalDocument(url) ? "allow" : "deny" } }) window.webContents.on("will-navigate", (event, url) => { if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) + } else if (!isTrustedLocalDocument(url)) { + event.preventDefault() } else if (navigationController) { event.preventDefault() void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { @@ -265,6 +269,8 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) + } else if (!isTrustedLocalDocument(url)) { + event.preventDefault() } }) } @@ -446,7 +452,7 @@ function createWindow() { void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) }, }) - setupCliIPC(window, cliManager) + setupCliIPC(window, cliManager, clientStateManager) bindClientStateWindow(window) clientStateLifecycle.attachMainWindow(window, windowStateTracker) diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 090de0d11..a45fda72c 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -39,6 +39,13 @@ export interface CliStatus { error?: string } +export interface CliReadyAuthority { + generation: number + pid: number + startIdentity: string + url: string +} + export interface CliLogEntry { stream: "stdout" | "stderr" message: string @@ -145,6 +152,7 @@ export class CliProcessManager extends EventEmitter { private requestedStop = false private shutdownStatus: "complete" | "incomplete" | null = null private lifecycle = new SerializedLifecycle() + private generation = 0 start(options: StartOptions): Promise { return this.lifecycle.enqueue(() => this.startNow(options)) @@ -172,6 +180,7 @@ export class CliProcessManager extends EventEmitter { await this.stopNow() if (this.child) throw new Error("CLI process did not exit before restart") } + this.generation++ this.stdoutBuffer = "" this.stderrBuffer = "" @@ -234,6 +243,7 @@ export class CliProcessManager extends EventEmitter { child.on("exit", (code, signal) => { if (this.child !== child) return + this.generation++ const failed = this.status.state !== "ready" const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) @@ -265,6 +275,7 @@ export class CliProcessManager extends EventEmitter { } private async stopNow(): Promise { + this.generation++ const child = this.child if (!child) { this.updateStatus({ state: "stopped" }) @@ -348,6 +359,45 @@ export class CliProcessManager extends EventEmitter { return { ...this.status } } + async captureReadyAuthority(): Promise { + const generation = this.generation + const child = this.child + const status = this.status + const pid = child?.pid + if (this.requestedStop || status.state !== "ready" || !pid || !status.url || !this.childStartIdentity) return undefined + const startIdentity = await this.childStartIdentity + if (!startIdentity) return undefined + const liveIdentity = await getProcessStartIdentityAsync(pid, 1_500) + if (liveIdentity !== startIdentity || generation !== this.generation || child !== this.child || this.status.state !== "ready") return undefined + return { generation, pid, startIdentity, url: status.url } + } + + async validateReadyAuthority(authority: CliReadyAuthority): Promise { + const child = this.child + if (this.requestedStop || this.generation !== authority.generation || this.status.state !== "ready" + || this.status.url !== authority.url || child?.pid !== authority.pid || !this.childStartIdentity) return false + const [capturedIdentity, liveIdentity] = await Promise.all([ + this.childStartIdentity, + getProcessStartIdentityAsync(authority.pid, 1_500), + ]) + return capturedIdentity === authority.startIdentity && liveIdentity === authority.startIdentity + && this.generation === authority.generation && child === this.child && this.status.state === "ready" + } + + private isReadyAuthorityCurrent(authority: CliReadyAuthority): boolean { + return !this.requestedStop && this.generation === authority.generation && this.status.state === "ready" + && this.status.url === authority.url && this.child?.pid === authority.pid + } + + withReadyAuthority(authority: CliReadyAuthority, dispatch: () => T | Promise): Promise { + return this.lifecycle.enqueue(async () => { + if (!await this.validateReadyAuthority(authority) || !this.isReadyAuthorityCurrent(authority)) { + throw new Error("Directory opening is unavailable from this renderer") + } + return await dispatch() + }) + } + getAuthCookieName(): string { return this.authCookieName } diff --git a/packages/electron-app/electron/main/process-stop.test.ts b/packages/electron-app/electron/main/process-stop.test.ts index 898a79c38..861f451ef 100644 --- a/packages/electron-app/electron/main/process-stop.test.ts +++ b/packages/electron-app/electron/main/process-stop.test.ts @@ -540,3 +540,68 @@ test("incomplete shutdown status remains terminal", async () => { hooks.deregister() } }) + +test("folder authority is denied after a CLI generation change or exit", async () => { + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "electron") { + return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" } + } + return nextResolve(specifier, context) + }, + }) + try { + const [{ CliProcessManager }, { getProcessStartIdentityAsync }] = await Promise.all([ + import("./process-manager"), + import("./client-state-process-identity"), + ]) + const identity = await getProcessStartIdentityAsync(process.pid, 1_500) + assert.ok(identity) + const manager = new CliProcessManager() as any + manager.generation = 7 + manager.requestedStop = false + manager.child = { pid: process.pid } + manager.childStartIdentity = Promise.resolve(identity) + manager.status = { state: "ready", pid: process.pid, url: "http://127.0.0.1:43123" } + const authority = await manager.captureReadyAuthority() + assert.ok(authority) + + manager.generation++ + assert.equal(await manager.validateReadyAuthority(authority), false) + manager.generation = authority.generation + manager.status = { state: "stopped" } + manager.child = undefined + assert.equal(await manager.validateReadyAuthority(authority), false) + } finally { + hooks.deregister() + } +}) + +test("folder authority rotation during final validation prevents native dispatch", async () => { + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "electron") { + return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" } + } + return nextResolve(specifier, context) + }, + }) + try { + const { CliProcessManager } = await import("./process-manager") + const manager = new CliProcessManager() as any + const authority = { generation: 7, pid: process.pid, startIdentity: "identity", url: "http://127.0.0.1:43123" } + manager.generation = authority.generation + manager.requestedStop = false + manager.child = { pid: authority.pid } + manager.status = { state: "ready", pid: authority.pid, url: authority.url } + manager.validateReadyAuthority = async () => { + manager.generation++ + return true + } + let dispatched = false + await assert.rejects(manager.withReadyAuthority(authority, () => { dispatched = true }), /unavailable from this renderer/) + assert.equal(dispatched, false) + } finally { + hooks.deregister() + } +}) diff --git a/packages/electron-app/electron/main/renderer-origin.test.ts b/packages/electron-app/electron/main/renderer-origin.test.ts index 9080e7cce..de2aeda46 100644 --- a/packages/electron-app/electron/main/renderer-origin.test.ts +++ b/packages/electron-app/electron/main/renderer-origin.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { isAllowedMainWindowDocument, isAllowedRendererDocument, resolveConfiguredRendererOrigins } from "./renderer-origin" test("packaged renderer origins exclude development server environment URLs", () => { assert.deepEqual( @@ -23,3 +23,13 @@ test("development renderer origins include configured development servers", () = ["http://127.0.0.1:43123", "http://localhost:3000"], ) }) + +test("main-window navigation allows shell reloads and denies same-origin preview documents", () => { + const origins = ["http://127.0.0.1:43123"] + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/?launch=desktop#state", origins), true) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/login", origins), true) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/previews/token", origins), false) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/workspaces/owned", origins), false) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43124/", origins), false) + assert.equal(isAllowedRendererDocument("http://127.0.0.1:43123/login", origins), false) +}) diff --git a/packages/electron-app/electron/main/renderer-origin.ts b/packages/electron-app/electron/main/renderer-origin.ts index 40f501b12..1e30626d8 100644 --- a/packages/electron-app/electron/main/renderer-origin.ts +++ b/packages/electron-app/electron/main/renderer-origin.ts @@ -7,6 +7,24 @@ export function isAllowedRendererOrigin(origin: string | undefined | null, allow } } +export function isAllowedRendererDocument(url: string | undefined | null, allowedOrigins: string[]): boolean { + if (!isAllowedRendererOrigin(url, allowedOrigins)) return false + try { + return new URL(url!).pathname === "/" + } catch { + return false + } +} + +export function isAllowedMainWindowDocument(url: string | undefined | null, allowedOrigins: string[]): boolean { + if (!isAllowedRendererOrigin(url, allowedOrigins)) return false + try { + return ["/", "/login", "/auth/token"].includes(new URL(url!).pathname) + } catch { + return false + } +} + export function resolveConfiguredRendererOrigins( currentCliUrl: string | null, isPackaged: boolean, diff --git a/packages/electron-app/electron/main/worktree-directory.test.ts b/packages/electron-app/electron/main/worktree-directory.test.ts new file mode 100644 index 000000000..8d71927e6 --- /dev/null +++ b/packages/electron-app/electron/main/worktree-directory.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" +import { + holdVerifiedDirectory, + isManagedMainFrame, + isNetworkPath, + isRemoteWindowsDrive, + openManagedWorktreeDirectory, + sameDirectoryIdentity, + type OpenManagedWorktreeOptions, +} from "./worktree-directory" + +const endpoint = "http://127.0.0.1:43123/api/workspaces/workspace-1/worktrees" +const response = (body: unknown, overrides: Partial<{ status: number; redirected: boolean; url: string; contentLength: string; chunks: Uint8Array[]; cancel: () => void }> = {}) => { + const encoded = new TextEncoder().encode(JSON.stringify(body)) + const chunks = overrides.chunks ?? [encoded] + let index = 0 + return { + status: 200, + redirected: false, + url: endpoint, + headers: { get: (name: string) => name.toLowerCase() === "content-length" ? overrides.contentLength ?? null : null }, + body: { getReader: () => ({ + read: async () => index < chunks.length ? { done: false, value: chunks[index++] } : { done: true }, + cancel: async () => { overrides.cancel?.() }, + }) }, + ...overrides, + } +} + +function options(body: unknown) { + const opened: string[] = [] + const canonical = process.cwd() + const value: OpenManagedWorktreeOptions = { + baseUrl: "http://127.0.0.1:43123", + instanceId: "workspace-1", + worktreeSlug: "feature", + fetch: async () => response(body), + canonicalize: async () => canonical, + isDirectory: async () => true, + isRemoteDrive: async () => false, + platform: "linux", + openPath: async (path: string) => { opened.push(path); return "" }, + } + return { + opened, + canonical, + value, + } +} + +test("opens only the once-canonicalized path selected by exact slug", async () => { + const setup = options({ worktrees: [{ slug: "feature", directory: "C:\\repo\\mutable-feature" }] }) + await openManagedWorktreeDirectory(setup.value) + assert.deepEqual(setup.opened, [setup.canonical]) +}) + +test("rejects redirects, malformed responses, unknown slugs, and duplicate slugs", async () => { + for (const body of [null, {}, { worktrees: [{}] }, { worktrees: [{ slug: "other", directory: "C:\\repo" }] }, { + worktrees: [{ slug: "feature", directory: "C:\\one" }, { slug: "feature", directory: "C:\\two" }], + }]) await assert.rejects(openManagedWorktreeDirectory(options(body).value)) + + const redirected = options({ worktrees: [] }) + redirected.value.fetch = async () => response({}, { status: 302, url: "http://127.0.0.1:43123/login" }) + await assert.rejects(openManagedWorktreeDirectory(redirected.value)) +}) + +test("rejects oversized declared and chunked lookup bodies while streaming", async () => { + const declared = options({ worktrees: [] }) + declared.value.fetch = async () => response({}, { contentLength: String(1024 * 1024 + 1) }) + await assert.rejects(openManagedWorktreeDirectory(declared.value), /Malformed worktree response/) + + let canceled = false + const chunked = options({ worktrees: [] }) + chunked.value.fetch = async () => response({}, { + chunks: [new Uint8Array(600_000), new Uint8Array(600_000)], + cancel: () => { canceled = true }, + }) + await assert.rejects(openManagedWorktreeDirectory(chunked.value), /Malformed worktree response/) + assert.equal(canceled, true) +}) + +test("aborts directory lookup bodies at idle and total deadlines", async () => { + for (const deadline of ["idle", "total"] as const) { + const setup = options({ worktrees: [] }) + let signal: AbortSignal | undefined + let canceled = false + const encoded = new TextEncoder().encode("{}") + let reads = 0 + setup.value.responseIdleTimeoutMs = deadline === "idle" ? 5 : 20 + setup.value.responseTotalTimeoutMs = deadline === "idle" ? 50 : 12 + setup.value.fetch = async (_url, init) => { + signal = init.signal + return { + status: 200, + redirected: false, + url: endpoint, + headers: { get: () => null }, + body: { getReader: () => ({ + read: async () => { + await new Promise((resolve) => setTimeout(resolve, deadline === "idle" ? 20 : 8)) + return reads++ < 3 ? { done: false, value: encoded } : { done: true } + }, + cancel: async () => { canceled = true }, + }) }, + } + } + await assert.rejects(openManagedWorktreeDirectory(setup.value), /timed out/) + assert.equal(signal?.aborted, true) + assert.equal(canceled, true) + } +}) + +test("starts the total directory lookup deadline before response headers", async () => { + const setup = options({ worktrees: [] }) + let signal: AbortSignal | undefined + setup.value.responseTotalTimeoutMs = 5 + setup.value.fetch = async (_url, init) => { + signal = init.signal + return await new Promise(() => {}) + } + await assert.rejects(openManagedWorktreeDirectory(setup.value), /Worktree lookup timed out/) + assert.equal(signal?.aborted, true) +}) + +test("directory identity compares exact bigint device and inode values", () => { + const roundedAsNumber = BigInt(Number(2n ** 60n + 1n)) + assert.notEqual(roundedAsNumber, 2n ** 60n + 1n) + assert.equal( + sameDirectoryIdentity({ dev: 1n, ino: 2n ** 60n + 1n }, { dev: 1n, ino: roundedAsNumber }), + false, + ) +}) + +test("rejects UNC paths before opening", async () => { + assert.equal(isNetworkPath("\\\\server\\share\\repo"), true) + assert.equal(isNetworkPath("//server/share/repo"), true) + assert.equal(isNetworkPath("\\\\?\\UNC\\server\\share"), true) + assert.equal(isNetworkPath("\\\\?\\C:\\repo"), false) + await assert.rejects(openManagedWorktreeDirectory(options({ + worktrees: [{ slug: "feature", directory: "\\\\server\\share\\repo" }], + }).value)) +}) + +test("rejects a canonical path on a mapped remote drive", async () => { + const setup = options({ worktrees: [{ slug: "feature", directory: "Z:\\repo\\feature" }] }) + setup.value.isRemoteDrive = async (path) => { + assert.equal(path, setup.canonical) + return true + } + await assert.rejects(openManagedWorktreeDirectory(setup.value), /Network worktrees/) + assert.deepEqual(setup.opened, []) +}) + +test("classifies DRIVE_REMOTE only on Windows", async () => { + assert.equal(await isRemoteWindowsDrive("Z:\\repo", "win32", async () => 4), true) + assert.equal(await isRemoteWindowsDrive("C:\\repo", "win32", async () => 3), false) + assert.equal(await isRemoteWindowsDrive("/repo", "linux", async () => { throw new Error("not called") }), false) +}) + +test("rechecks the original main frame and backend URL after async lookup", async () => { + for (const revoke of ["frame", "baseUrl"] as const) { + const setup = options({ worktrees: [{ slug: "feature", directory: "C:\\repo\\feature" }] }) + const originalFrame = { url: "http://127.0.0.1:43123/app" } + const webContents = { mainFrame: originalFrame as unknown, getURL: () => originalFrame.url } + const window = { isDestroyed: () => false, webContents } + const renderer = { sender: webContents, senderFrame: originalFrame } + let currentBaseUrl = setup.value.baseUrl + setup.value.fetch = async () => { + if (revoke === "frame") webContents.mainFrame = { url: originalFrame.url } + else currentBaseUrl = "http://127.0.0.1:43124" + return response({ worktrees: [{ slug: "feature", directory: "C:\\repo\\feature" }] }) + } + setup.value.beforeOpen = () => { + if (currentBaseUrl !== setup.value.baseUrl || !isManagedMainFrame(renderer, window, setup.value.baseUrl)) { + throw new Error("Directory opening is unavailable from this renderer") + } + } + await assert.rejects(openManagedWorktreeDirectory(setup.value), /unavailable from this renderer/) + assert.deepEqual(setup.opened, []) + } +}) + +test("requires the managed main frame", () => { + const frame = { url: "http://127.0.0.1:43123/?launch=desktop" } + const webContents = { mainFrame: frame, getURL: () => frame.url } + const window = { isDestroyed: () => false, webContents } + assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:43123"), true) + assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: { url: frame.url } }, window, "http://127.0.0.1:43123"), false) + assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:9999"), false) + frame.url = "http://127.0.0.1:43123/previews/token" + assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:43123"), false) +}) + +test("a held Windows directory identity rejects path replacement", { skip: process.platform !== "win32" }, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-")) + const directory = path.join(root, "feature") + const moved = path.join(root, "moved") + await fs.mkdir(directory) + const verified = await holdVerifiedDirectory(await fs.realpath(directory), "win32") + try { + await fs.rename(directory, moved) + await fs.mkdir(directory) + await assert.rejects(verified.revalidate(), /changed before it could be opened/) + } finally { + await verified.close() + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("a held POSIX directory identity rejects path replacement", { skip: process.platform === "win32" }, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-")) + const directory = path.join(root, "feature") + const moved = path.join(root, "moved") + await fs.mkdir(directory) + const verified = await holdVerifiedDirectory(await fs.realpath(directory), process.platform) + try { + await fs.rename(directory, moved) + await fs.mkdir(directory) + await assert.rejects(verified.revalidate(), /changed before it could be opened/) + } finally { + await verified.close() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/electron-app/electron/main/worktree-directory.ts b/packages/electron-app/electron/main/worktree-directory.ts new file mode 100644 index 000000000..a0a2b7cb8 --- /dev/null +++ b/packages/electron-app/electron/main/worktree-directory.ts @@ -0,0 +1,299 @@ +import { execFile } from "node:child_process" +import fs, { type FileHandle } from "node:fs/promises" +import net from "node:net" + +interface FetchResponse { + status: number + redirected: boolean + url: string + headers: { get(name: string): string | null } + body: { getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise } } | null +} + +interface RendererEvent { + sender: unknown + senderFrame: { url: string } | null +} + +interface RendererWindow { + isDestroyed(): boolean + webContents: { + mainFrame: unknown + getURL(): string + } +} + +export interface OpenManagedWorktreeOptions { + baseUrl: string + instanceId: string + worktreeSlug: string + fetch: (url: string, init: { credentials: "include"; redirect: "manual"; headers: { Accept: string }; signal: AbortSignal }) => Promise + openPath: (path: string) => Promise + canonicalize?: (path: string) => Promise + isDirectory?: (path: string) => Promise + isRemoteDrive?: (path: string) => Promise + beforeOpen?: () => void | Promise + platform?: NodeJS.Platform + responseIdleTimeoutMs?: number + responseTotalTimeoutMs?: number +} + +type DriveTypeLookup = (path: string) => Promise + +const DRIVE_REMOTE = 4 +const MAX_WORKTREE_RESPONSE_BYTES = 1024 * 1024 +const WORKTREE_RESPONSE_IDLE_TIMEOUT_MS = 5_000 +const WORKTREE_RESPONSE_TOTAL_TIMEOUT_MS = 15_000 + +interface VerifiedDirectory { + revalidate(): Promise + close(): Promise +} + +function isLoopbackHost(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase() + if (host === "localhost" || host === "::1") return true + return net.isIP(host) === 4 && host.split(".")[0] === "127" +} + +export function isNetworkPath(value: string): boolean { + const normalized = value.replace(/\//g, "\\").toLowerCase() + if (normalized.startsWith("\\\\?\\unc\\")) return true + if (/^\\\\\?\\[a-z]:\\/.test(normalized)) return false + return normalized.startsWith("\\\\") +} + +function windowsDriveType(value: string): Promise { + const normalized = value.replace(/\//g, "\\") + const drive = /^(?:\\\\\?\\)?([a-z]):\\/i.exec(normalized)?.[1] + if (!drive) return Promise.reject(new Error("Unable to classify Windows worktree drive")) + return new Promise((resolve, reject) => { + execFile("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `[int]([System.IO.DriveInfo]::new('${drive}:\\').DriveType)`, + ], { encoding: "utf8", windowsHide: true, timeout: 5_000 }, (error, stdout) => { + const driveType = Number.parseInt(stdout.trim(), 10) + if (error || !Number.isInteger(driveType)) reject(error ?? new Error("Unable to classify Windows worktree drive")) + else resolve(driveType) + }) + }) +} + +export async function isRemoteWindowsDrive( + value: string, + platform: NodeJS.Platform = process.platform, + lookup: DriveTypeLookup = windowsDriveType, +): Promise { + return platform === "win32" && await lookup(value) === DRIVE_REMOTE +} + +function managedEndpoint(baseUrl: string, instanceId: string): URL { + if (!instanceId || instanceId !== instanceId.trim()) throw new Error("Invalid workspace") + const base = new URL(baseUrl) + if (!(["http:", "https:"] as string[]).includes(base.protocol) || !isLoopbackHost(base.hostname)) { + throw new Error("Managed backend is unavailable") + } + return new URL(`/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, base.origin) +} + +export function isManagedMainFrame(event: RendererEvent, window: RendererWindow, baseUrl: string): boolean { + if (!event.senderFrame || window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) return false + try { + const managedOrigin = managedEndpoint(baseUrl, "authority-check").origin + const current = new URL(window.webContents.getURL()) + const sender = new URL(event.senderFrame.url) + return current.origin === managedOrigin && current.pathname === "/" + && sender.origin === managedOrigin && sender.pathname === "/" + } catch { + return false + } +} + +async function resolveManagedWorktreeDirectory(options: OpenManagedWorktreeOptions): Promise { + const { worktreeSlug } = options + if (!worktreeSlug || worktreeSlug !== worktreeSlug.trim()) throw new Error("Invalid worktree") + + const endpoint = managedEndpoint(options.baseUrl, options.instanceId) + const responseAbort = new AbortController() + const totalTimeoutMs = options.responseTotalTimeoutMs ?? WORKTREE_RESPONSE_TOTAL_TIMEOUT_MS + const deadlineAt = Date.now() + totalTimeoutMs + let responseTimer: ReturnType | undefined + let responseTimedOut = false + let response: FetchResponse + try { + response = await Promise.race([ + options.fetch(endpoint.href, { + credentials: "include", + redirect: "manual", + headers: { Accept: "application/json" }, + signal: responseAbort.signal, + }), + new Promise((_resolve, reject) => { + responseTimer = setTimeout(() => { + responseTimedOut = true + responseAbort.abort() + reject(new Error("Worktree lookup timed out")) + }, Math.max(1, totalTimeoutMs)) + }), + ]) + } catch (error) { + if (responseTimedOut) throw new Error("Worktree lookup timed out") + throw error + } finally { + if (responseTimer) clearTimeout(responseTimer) + } + // Electron documents Response.url as unreliable; manual redirects plus an exact 200 status are authoritative. + if (response.status !== 200 || response.redirected) { + throw new Error("Worktree lookup failed") + } + + const body = await readBoundedJson( + response, + responseAbort, + options.responseIdleTimeoutMs ?? WORKTREE_RESPONSE_IDLE_TIMEOUT_MS, + deadlineAt, + ) + if (!body || typeof body !== "object" || !Array.isArray((body as { worktrees?: unknown }).worktrees)) { + throw new Error("Malformed worktree response") + } + const worktrees = (body as { worktrees: unknown[] }).worktrees + const seen = new Set() + let directory: string | undefined + for (const entry of worktrees) { + if (!entry || typeof entry !== "object") throw new Error("Malformed worktree response") + const candidate = entry as { slug?: unknown; directory?: unknown } + if (typeof candidate.slug !== "string" || !candidate.slug || typeof candidate.directory !== "string" || !candidate.directory) { + throw new Error("Malformed worktree response") + } + if (seen.has(candidate.slug)) throw new Error("Malformed worktree response") + seen.add(candidate.slug) + if (candidate.slug === worktreeSlug) directory = candidate.directory + } + if (!directory) throw new Error("Worktree not found") + if (isNetworkPath(directory)) throw new Error("Network worktrees cannot be opened") + + const canonical = await (options.canonicalize ?? fs.realpath)(directory) + if (isNetworkPath(canonical)) throw new Error("Network worktrees cannot be opened") + if (await (options.isRemoteDrive ?? isRemoteWindowsDrive)(canonical)) throw new Error("Network worktrees cannot be opened") + const isDirectory = options.isDirectory ?? (async (target) => (await fs.stat(target)).isDirectory()) + if (!await isDirectory(canonical)) throw new Error("Worktree not found") + return canonical +} + +async function readBoundedJson( + response: FetchResponse, + abort: AbortController, + idleTimeoutMs: number, + deadlineAt: number, +): Promise { + const contentLength = response.headers.get("content-length") + if (contentLength !== null) { + const size = Number(contentLength) + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_WORKTREE_RESPONSE_BYTES) { + throw new Error("Malformed worktree response") + } + } + if (!response.body) throw new Error("Malformed worktree response") + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const remainingMs = deadlineAt - Date.now() + if (remainingMs <= 0) { + abort.abort() + void reader.cancel().catch(() => undefined) + throw new Error("Worktree lookup timed out") + } + const { done, value } = await readWithTimeout(reader, Math.min(idleTimeoutMs, remainingMs), abort) + if (done) break + if (!value) continue + size += value.byteLength + if (size > MAX_WORKTREE_RESPONSE_BYTES) { + await reader.cancel() + throw new Error("Malformed worktree response") + } + chunks.push(value) + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + try { + return JSON.parse(new TextDecoder().decode(bytes)) + } catch { + throw new Error("Malformed worktree response") + } +} + +async function readWithTimeout( + reader: ReturnType["getReader"]>, + timeoutMs: number, + abort: AbortController, +): Promise<{ done: boolean; value?: Uint8Array }> { + let timer: ReturnType | undefined + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + abort.abort() + void reader.cancel().catch(() => undefined) + reject(new Error("Worktree lookup timed out")) + }, Math.max(1, timeoutMs)) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +export function sameDirectoryIdentity(left: { dev: bigint; ino: bigint }, right: { dev: bigint; ino: bigint }): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +export async function holdVerifiedDirectory(path: string, platform: NodeJS.Platform = process.platform): Promise { + const handle = await fs.open(path, "r") + try { + const identity = await handle.stat({ bigint: true }) + const link = await fs.lstat(path) + if (!identity.isDirectory() || link.isSymbolicLink()) throw new Error("Worktree not found") + return { + revalidate: async () => { + const [held, current, currentLink, currentPath] = await Promise.all([ + handle.stat({ bigint: true }), + fs.stat(path, { bigint: true }), + fs.lstat(path), + fs.realpath(path), + ]) + if (!held.isDirectory() || !current.isDirectory() || currentLink.isSymbolicLink() + || (platform === "win32" ? currentPath.toLowerCase() !== path.toLowerCase() : currentPath !== path) + || !sameDirectoryIdentity(identity, held) || !sameDirectoryIdentity(identity, current)) { + throw new Error("Worktree changed before it could be opened") + } + }, + close: () => handle.close(), + } + } catch (error) { + await handle.close() + throw error + } +} + +export async function openManagedWorktreeDirectory(options: OpenManagedWorktreeOptions): Promise { + const canonical = await resolveManagedWorktreeDirectory(options) + const verified = await holdVerifiedDirectory(canonical, options.platform) + try { + await options.beforeOpen?.() + await verified.revalidate() + // ponytail: Electron's shell API accepts only a path, so replacement inside shell.openPath is the remaining unavoidable race. + const error = await options.openPath(canonical) + if (error) throw new Error(error) + } finally { + await verified.close() + } +} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0a35f6690..342cd4201 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -26,6 +26,7 @@ const localElectronAPI = { restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), + openDirectory: (accessToken, instanceId, worktreeSlug) => ipcRenderer.invoke("filesystem:openDirectory", accessToken, instanceId, worktreeSlug), getPathForFile: (file) => { try { return webUtils.getPathForFile(file) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 339f56e89..15b1c6629 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/worktree-directory.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d61b12f0e..87662f723 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -94,6 +94,8 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: string + /** Absolute directory as seen by the OpenCode process. */ + nativeDirectory?: string kind: WorktreeKind /** Optional VCS branch name when available. */ branch?: string @@ -113,12 +115,30 @@ export interface WorktreeCreateRequest { export interface WorktreeMap { version: 1 + /** Optimistic concurrency token. Missing legacy values are treated as zero. */ + revision?: number /** Default worktree to use for new sessions and as fallback. */ defaultWorktreeSlug: string /** Mapping of *parent* session IDs to a worktree slug. */ parentSessionWorktreeSlug: Record } +export interface WorktreeSessionMoveRequest { + worktreeSlug: string +} + +export interface WorktreeSessionLocation { + sessionId: string + directory: string + workspaceId: string | null +} + +export interface WorktreeSessionMoveResponse { + rootSessionId: string + worktreeSlug: string + sessions: WorktreeSessionLocation[] +} + export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" export interface WorktreeGitStatusEntry { diff --git a/packages/server/src/background-processes/manager.test.ts b/packages/server/src/background-processes/manager.test.ts index 636994bc0..71ab69ca0 100644 --- a/packages/server/src/background-processes/manager.test.ts +++ b/packages/server/src/background-processes/manager.test.ts @@ -8,6 +8,7 @@ import { BackgroundProcessManager } from "./manager" import type { WorkspaceManager } from "../workspaces/manager" import type { EventBus } from "../events/bus" import type { Logger } from "../logger" +import type { InstanceMutationGate } from "../server/instance-mutation-gate" const WORKSPACE_ID = "ws-test" const SESSION_ID = "sess-1" @@ -34,19 +35,37 @@ interface CapturedRequest { */ async function runCompletionPrompt( fetchImpl: (input: Request, init: RequestInit | undefined) => Promise, + mutationGate: Pick = { + enter: async () => () => {}, + acquireExclusive: async () => () => {}, + }, + getSessionLocation?: () => { directory?: string; workspaceID?: string; nativeDirectory?: string }, ): Promise<{ requests: CapturedRequest[]; warned: boolean; directory: string }> { const requests: CapturedRequest[] = [] const originalFetch = globalThis.fetch // Captured now but swapped in only inside the try below, so a failure during // setup (mkdtemp, manager construction) can't leak the mocked fetch. const fetchMock = (async (input: any, init: any) => { - const req = input instanceof Request ? input : new Request(String(input), init) + const req = input instanceof Request ? new Request(input, init) : new Request(String(input), init) requests.push({ method: req.method, url: req.url, headers: req.headers, body: await req.text(), }) + const url = new URL(req.url) + const location = getSessionLocation?.() ?? { directory: sessionDir, workspaceID: "session-workspace" } + const json = (value: unknown) => new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }) + if (req.method === "GET" && url.pathname === "/session") { + return json([{ id: SESSION_ID, directory: location.directory, workspaceID: location.workspaceID }]) + } + if (url.pathname === "/experimental/workspace/sync-list") return json([]) + if (req.method === "GET" && url.pathname === "/experimental/workspace") { + return json(location.workspaceID ? [{ id: location.workspaceID, directory: location.nativeDirectory ?? location.directory }] : []) + } return fetchImpl(input instanceof Request ? input : req, init) }) as typeof fetch @@ -88,9 +107,11 @@ async function runCompletionPrompt( get: () => ({ path: workspacePath }), getInstancePort: () => INSTANCE_PORT, getInstanceAuthorizationHeader: () => AUTH_HEADER, + getRepositoryMutationKey: () => "repo:shared", + resolveInstanceDirectory: async (_id: string, directory?: string) => directory ?? workspacePath, } as unknown as WorkspaceManager - const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger }) + const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger, mutationGate }) try { globalThis.fetch = fetchMock @@ -128,11 +149,11 @@ describe("BackgroundProcessManager.sendCompletionPrompt", () => { const promptCall = requests.find((r) => r.url.includes("/prompt_async")) assert.ok(promptCall, "expected a prompt_async request") assert.equal(promptCall.method, "POST") - assert.equal( - promptCall.url, - `http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`, - ) + const promptUrl = new URL(promptCall.url) + assert.equal(promptUrl.origin + promptUrl.pathname, `http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`) + assert.equal(promptUrl.searchParams.get("workspace"), "session-workspace") assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER) + assert.equal(promptCall.headers.get("x-codenomad-mutation-session"), null) // The prompt is scoped to the session's directory (a POST keeps the // directory as a header — the SDK only rewrites header→query for GET/HEAD). assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory)) @@ -150,4 +171,70 @@ describe("BackgroundProcessManager.sendCompletionPrompt", () => { ) assert.equal(warned, true) }) + + it("holds direct mutation admission through prompt completion", async () => { + const events: string[] = [] + const gate = { + enter: async () => { + events.push("enter") + return () => events.push("release") + }, + acquireExclusive: async () => { + events.push("enter") + return () => events.push("release") + }, + } + await runCompletionPrompt(async () => { + events.push("prompt") + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) + }, gate) + assert.deepEqual(events, ["enter", "enter", "prompt", "release", "release"]) + }) + + it("resolves a moved session after waiting for mutation admission", async () => { + let admit!: () => void + let markEntered!: () => void + const admission = new Promise((resolve) => { admit = resolve }) + const entered = new Promise((resolve) => { markEntered = resolve }) + let location = { directory: "C:/old-worktree", workspaceID: "old-workspace" } + const gate = { + enter: async () => { + markEntered() + await admission + return () => {} + }, + acquireExclusive: async () => () => {}, + } + const running = runCompletionPrompt( + async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + gate, + () => location, + ) + await entered + location = { directory: "C:/moved-worktree", workspaceID: "moved-workspace" } + admit() + const { requests } = await running + + const prompt = requests.find((request) => request.url.includes("/prompt_async")) + assert.ok(prompt) + assert.equal(prompt.headers.get("x-opencode-directory"), encodeURIComponent("C:/moved-worktree")) + assert.equal(new URL(prompt.url).searchParams.get("workspace"), "moved-workspace") + }) + + it("uses the native workspace when the session directory is stale", async () => { + const { requests } = await runCompletionPrompt( + async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + undefined, + () => ({ + directory: "C:/stale-worktree", + workspaceID: "moved-workspace", + nativeDirectory: "C:/moved-worktree", + }), + ) + + const prompt = requests.find((request) => request.url.includes("/prompt_async")) + assert.ok(prompt) + assert.equal(prompt.headers.get("x-opencode-directory"), encodeURIComponent("C:/moved-worktree")) + assert.equal(new URL(prompt.url).searchParams.get("workspace"), "moved-workspace") + }) }) diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index edcb53432..f3ff59247 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -7,6 +7,8 @@ import type { WorkspaceManager } from "../workspaces/manager" import { createInstanceClient } from "../workspaces/instance-client" import type { Logger } from "../logger" import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types" +import { enterWorkspaceMutationAdmission, type InstanceMutationGate } from "../server/instance-mutation-gate" +import { resolveNativeSessionLocation } from "../workspaces/native-session-location" const ROOT_DIR = ".codenomad/background_processes" const INDEX_FILE = "index.json" @@ -15,11 +17,13 @@ const STOP_TIMEOUT_MS = 2000 const EXIT_WAIT_TIMEOUT_MS = 5000 const MAX_OUTPUT_BYTES = 20 * 1024 const OUTPUT_PUBLISH_INTERVAL_MS = 1000 +const SESSION_LIST_LIMIT = 10_000 interface ManagerDeps { workspaceManager: WorkspaceManager eventBus: EventBus logger: Logger + mutationGate: Pick } interface RunningProcess { @@ -626,26 +630,52 @@ export class BackgroundProcessManager { const notify = record.notify if (!notify || !record.terminalReason) return - const client = createInstanceClient(this.deps.workspaceManager, workspaceId, { - directory: notify.directory, - }) - if (!client) { - throw new Error("Workspace instance is not ready") - } - - await client.session.promptAsync( - { - sessionID: notify.sessionID, - parts: [ - { - type: "text", - text: this.buildSyntheticCompletionPrompt(record), - synthetic: true, - }, - ], - }, - { throwOnError: true }, + const releaseMutation = await enterWorkspaceMutationAdmission( + this.deps.mutationGate, + workspaceId, + async () => this.deps.workspaceManager.get(workspaceId)?.path, ) + try { + const workspace = this.deps.workspaceManager.get(workspaceId) + const nativeRoot = await this.deps.workspaceManager.resolveInstanceDirectory(workspaceId) + const rootClient = createInstanceClient(this.deps.workspaceManager, workspaceId, { directory: nativeRoot }) + if (!workspace || !rootClient) throw new Error("Workspace instance is not ready") + const { data: listed = [] } = await rootClient.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory: nativeRoot }, + { throwOnError: true }, + ) + if (listed.length >= SESSION_LIST_LIMIT) throw new Error("Unable to verify the complete project session inventory") + const matches = listed.filter((session) => session.id === notify.sessionID) + if (matches.length !== 1) throw new Error("Background completion session location is missing or ambiguous") + const session = matches[0]! + + const scope = { directory: nativeRoot } + await rootClient.experimental.workspace.syncList(scope, { throwOnError: true }) + const { data: workspaces = [] } = await rootClient.experimental.workspace.list(scope, { throwOnError: true }) + const location = resolveNativeSessionLocation(nativeRoot, workspaces, session) + const targetClient = createInstanceClient(this.deps.workspaceManager, workspaceId, { directory: location.directory }) + if (!targetClient) throw new Error("Workspace instance is not ready") + + await targetClient.session.promptAsync( + { + sessionID: notify.sessionID, + ...(location.workspaceId ? { workspace: location.workspaceId } : {}), + parts: [ + { + type: "text", + text: this.buildSyntheticCompletionPrompt(record), + synthetic: true, + }, + ], + }, + { + throwOnError: true, + headers: { "x-codenomad-mutation-session": notify.sessionID }, + }, + ) + } finally { + await releaseMutation() + } } private buildCompletionPrompt(record: PersistedBackgroundProcess): string { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3993ed7a6..8f4439ab1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,6 +36,7 @@ import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShut import { AutoAcceptManager } from "./permissions/auto-accept-manager" import { createOpencodePermissionReplier } from "./permissions/opencode-replier" import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata" +import { InstanceMutationGate } from "./server/instance-mutation-gate" const require = createRequire(import.meta.url) @@ -366,6 +367,7 @@ async function main() { const settings = new SettingsService(configLocation, eventBus, configLogger) const binaryResolver = new BinaryResolver(settings) + const instanceMutationGate = new InstanceMutationGate() const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, settings, @@ -374,6 +376,7 @@ async function main() { logger: workspaceLogger, getServerBaseUrl: () => serverMeta.localUrl, nodeExtraCaCertsPath, + mutationGate: instanceMutationGate, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, @@ -388,11 +391,15 @@ async function main() { }) const previewManager = new PreviewManager() const yoloLogger = logger.child({ component: "yolo" }) - const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager) + const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager, undefined, instanceMutationGate) const yoloManager = new AutoAcceptManager({ eventBus, logger: yoloLogger, - replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }), + replier: createOpencodePermissionReplier({ + workspaceManager, + logger: yoloLogger, + mutationGate: instanceMutationGate, + }), persistence: sessionMetadataPersistence, }) yoloManager.start() @@ -499,6 +506,7 @@ async function main() { remoteProxySessionManager, yoloManager, sessionMetadataPersistence, + mutationGate: instanceMutationGate, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -528,6 +536,7 @@ async function main() { remoteProxySessionManager, yoloManager, sessionMetadataPersistence, + mutationGate: instanceMutationGate, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index d803587be..e78263abc 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -143,6 +143,24 @@ describe("AutoAcceptManager persistence", () => { assert.equal(changes.length, 1) }) + it("clears a cached workspace when a session returns to the project root", async () => { + const bus = new EventBus(noopLogger) + const writes: unknown[][] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, workspaceId: "workspace", yoloEnabled: false }] }, + async persist(...args) { writes.push(args) }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + await manager.hydrateInstance("inst") + + publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) + await manager.toggle("inst", "root") + + assert.deepEqual(writes, [["inst", "root", true, undefined]]) + manager.stop() + }) + it("serializes concurrent toggles", async () => { const bus = new EventBus(noopLogger) const writes: boolean[] = [] diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index 3fc7e7e60..c6216bc5a 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -48,8 +48,13 @@ export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo { } export interface AutoAcceptPersistence { - loadSessions(instanceId: string): Promise - persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise + loadSessions(instanceId: string, admission?: MutationAdmissionOptions): Promise + persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string, + admission?: MutationAdmissionOptions): Promise +} + +export interface MutationAdmissionOptions { + alreadyAdmitted?: boolean } const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) @@ -291,6 +296,8 @@ export class AutoAcceptManager { const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map() workspaces.set(session.id, session.workspaceID) this.sessionWorkspaces.set(instanceId, workspaces) + } else { + this.sessionWorkspaces.get(instanceId)?.delete(session.id) } this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId)) // Session ancestry may have changed (parent discovered, revert toggled). diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts new file mode 100644 index 000000000..2586f4d89 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -0,0 +1,236 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import os from "node:os" + +import type { Logger } from "../logger" +import type { AutoAcceptReply } from "./auto-accept-manager" +import { createOpencodePermissionReplier } from "./opencode-replier" +import { InstanceMutationGate } from "../server/instance-mutation-gate" +import { admitWorkspaceMutation } from "../server/instance-mutation-proxy" +import type { InstanceClientOptions } from "../workspaces/instance-client" +import { resolveRepositoryMutationKey } from "../workspaces/workspace-identity" + +const logger = {} as Logger +const legacyReply: AutoAcceptReply = { + instanceId: "instance", + permissionId: "permission", + sessionId: "session", + source: "legacy", + reply: "once", +} + +function createHarness( + sessions: Array<{ id: string; directory?: string; workspaceID?: string }>, + mutationGate: Pick = { + enterControl: async () => () => {}, + }, + replyWait: Promise = Promise.resolve(), + workspaces: Array<{ id: string; directory: string }> = [{ id: "workspace", directory: "/repo-workspace" }], + nativeRoot = "/repo", +) { + const listCalls: unknown[] = [] + const legacyCalls: unknown[] = [] + const legacyOptions: unknown[] = [] + const v2Calls: unknown[] = [] + const v2Options: unknown[] = [] + const clientOptions: Array = [] + const client = { + session: { + async list(parameters: unknown) { + listCalls.push(parameters) + return { data: sessions } + }, + }, + permission: { + async reply(parameters: unknown, options: unknown) { + legacyCalls.push(parameters) + legacyOptions.push(options) + await replyWait + }, + }, + v2: { + session: { + permission: { + async reply(parameters: unknown, options: unknown) { + v2Calls.push(parameters) + v2Options.push(options) + await replyWait + }, + }, + }, + }, + experimental: { + workspace: { + async syncList() { return { data: [] } }, + async list() { return { data: workspaces } }, + }, + }, + } + const replier = createOpencodePermissionReplier( + { + workspaceManager: { + get: () => ({ path: os.tmpdir() }), + getRepositoryMutationKey: () => "repo:shared", + resolveInstanceDirectory: async () => nativeRoot, + } as never, + logger, + mutationGate, + }, + (_manager, _instanceId, options) => { clientOptions.push(options); return client as never }, + ) + return { replier, listCalls, legacyCalls, legacyOptions, v2Calls, v2Options, clientOptions } +} + +describe("OpenCode permission replier", () => { + it("uses the legacy permission session's authoritative non-root workspace", async () => { + const harness = createHarness([ + { id: "other" }, + { id: "session", directory: "/stale-worktree", workspaceID: "workspace" }, + ]) + + await harness.replier(legacyReply) + + assert.deepEqual(harness.listCalls, [{ scope: "project", limit: 10_000, directory: "/repo" }]) + assert.deepEqual(harness.legacyCalls, [{ requestID: "permission", reply: "once", workspace: "workspace" }]) + assert.deepEqual(harness.legacyOptions, [{ + throwOnError: true, + headers: { "x-codenomad-mutation-session": "session" }, + }]) + }) + + it("omits workspace for an authoritative root session", async () => { + const harness = createHarness([{ id: "session", directory: "/repo" }]) + + await harness.replier(legacyReply) + + assert.deepEqual(harness.legacyCalls, [{ requestID: "permission", reply: "once" }]) + }) + + it("maps a directory-only legacy session to its native workspace", async () => { + const harness = createHarness([{ id: "session", directory: "/repo-workspace" }]) + + await harness.replier(legacyReply) + + assert.deepEqual(harness.legacyCalls, [{ requestID: "permission", reply: "once", workspace: "workspace" }]) + }) + + it("fails closed when the legacy permission session is missing or ambiguous", async () => { + for (const sessions of [[], [{ id: "session" }, { id: "session", workspaceID: "workspace" }]]) { + const harness = createHarness(sessions) + await assert.rejects(harness.replier(legacyReply), /is (missing|ambiguous)$/) + assert.equal(harness.legacyCalls.length, 0) + } + }) + + it("leaves V2 replies session-scoped", async () => { + const harness = createHarness([]) + + await harness.replier({ ...legacyReply, source: "v2" }) + + assert.equal(harness.listCalls.length, 0) + assert.deepEqual(harness.v2Calls, [{ sessionID: "session", requestID: "permission", reply: "once" }]) + assert.deepEqual(harness.v2Options, [{ + throwOnError: true, + headers: { "x-codenomad-mutation-session": "session" }, + }]) + }) + + it("constructs direct SDK clients with authoritative WSL-native scope", async () => { + const nativeRoot = "/home/dev/Repo" + const harness = createHarness([], undefined, undefined, undefined, nativeRoot) + + await harness.replier({ ...legacyReply, source: "v2" }) + + assert.deepEqual(harness.clientOptions, [{ directory: nativeRoot }]) + }) + + it("bypasses repository-exclusive admission for V2 Yolo replies", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markExclusiveStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markExclusiveStarted = resolve }) + const exclusive = gate.exclusive(await resolveRepositoryMutationKey(os.tmpdir()), async () => { + markExclusiveStarted() + await held + }) + await started + const harness = createHarness([], gate) + const reply = harness.replier({ ...legacyReply, source: "v2" }) + try { + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.v2Calls.length, 1) + } finally { + finishExclusive() + await exclusive + await reply + } + }) + + it("uses the control lane for a legacy reply after an exclusive operation queues behind its prompt", async () => { + const gate = new InstanceMutationGate() + const promptAdmission = await admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session/prompt", + resolveWorkspace: async () => ({ + port: 4321, + hostDirectory: os.tmpdir(), + nativeRootDirectory: "/repo", + }), + loadSessions: async () => [{ id: "session", directory: "/repo" }], + loadWorkspaces: async () => [], + }) + let finishReply!: () => void + const replyWait = new Promise((resolve) => { finishReply = resolve }) + const harness = createHarness([{ id: "session", directory: "/repo" }], gate, replyWait) + let exclusiveStarted = false + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) + const reply = harness.replier(legacyReply) + + try { + for (let attempt = 0; attempt < 20 && harness.legacyCalls.length === 0; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } + assert.equal(harness.legacyCalls.length, 1, "legacy reply deadlocked behind its prompt's repository admission") + + await promptAdmission.release() + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(exclusiveStarted, false) + + finishReply() + await reply + await exclusive + assert.equal(exclusiveStarted, true) + } finally { + finishReply() + await promptAdmission.release() + await reply.catch(() => undefined) + await exclusive.catch(() => undefined) + } + }) + + it("holds instance-shared admission for legacy and V2 Yolo replies", async () => { + for (const source of ["legacy", "v2"] as const) { + const gate = new InstanceMutationGate() + const releaseListener = await gate.enter("instance") + let finishReply!: () => void + const replyWait = new Promise((resolve) => { finishReply = resolve }) + const harness = createHarness([{ id: "session", directory: "/repo" }], gate, replyWait) + const pending = harness.replier({ ...legacyReply, source }) + while ((source === "legacy" ? harness.legacyCalls : harness.v2Calls).length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + let exclusiveStarted = false + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) + releaseListener() + await Promise.resolve() + assert.equal(exclusiveStarted, false) + finishReply() + await pending + await exclusive + assert.equal(exclusiveStarted, true) + } + }) +}) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 2ed9bfc9e..08332896a 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -1,11 +1,15 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { WorkspaceManager } from "../workspaces/manager" import type { Logger } from "../logger" -import { createInstanceClient } from "../workspaces/instance-client" +import { createInstanceClient, type InstanceClientOptions } from "../workspaces/instance-client" import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" +import type { InstanceMutationGate } from "../server/instance-mutation-gate" +import { resolveNativeSessionLocation } from "../workspaces/native-session-location" interface OpencodeReplierDeps { workspaceManager: WorkspaceManager logger: Logger + mutationGate: Pick } /** @@ -16,32 +20,61 @@ interface OpencodeReplierDeps { * Uses `createInstanceClient` so routes and body shapes are always correct * for the installed SDK version — no hand-assembled URLs. */ -export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { +export function createOpencodePermissionReplier( + deps: OpencodeReplierDeps, + createClient: ( + manager: WorkspaceManager, + instanceId: string, + options?: InstanceClientOptions, + ) => OpencodeClient | null = createInstanceClient, +): PermissionReplier { return async (reply: AutoAcceptReply) => { - const client = createInstanceClient(deps.workspaceManager, reply.instanceId) - if (!client) { - throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) - } - - const opts = { throwOnError: true } as const + const releaseMutation = await deps.mutationGate.enterControl(reply.instanceId) + try { + const workspace = deps.workspaceManager.get(reply.instanceId) + if (!workspace) throw new Error(`Yolo: instance ${reply.instanceId} is not ready`) + const nativeRoot = await deps.workspaceManager.resolveInstanceDirectory(reply.instanceId) + const client = createClient(deps.workspaceManager, reply.instanceId, { directory: nativeRoot }) + if (!client) throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + const opts = { + throwOnError: true, + headers: { "x-codenomad-mutation-session": reply.sessionId }, + } as const - if (reply.source === "v2") { - await client.v2.session.permission.reply( - { - sessionID: reply.sessionId, - requestID: reply.permissionId, - reply: reply.reply, - }, - opts, - ) - } else { - await client.permission.reply( - { - requestID: reply.permissionId, - reply: reply.reply, - }, - opts, - ) + if (reply.source === "v2") { + await client.v2.session.permission.reply( + { + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } else { + // The prompting mutation may hold repository admission; instance admission still serializes moves and deletion. + const { data: sessions } = await client.session.list( + { scope: "project", limit: 10_000, directory: nativeRoot }, + { throwOnError: true }, + ) + const matches = (sessions ?? []).filter((session) => session.id === reply.sessionId) + if (matches.length !== 1) { + throw new Error(`Yolo: legacy permission session ${reply.sessionId} is ${matches.length === 0 ? "missing" : "ambiguous"}`) + } + const scope = { directory: nativeRoot } + await client.experimental.workspace.syncList(scope, { throwOnError: true }) + const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) + const location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) + await client.permission.reply( + { + requestID: reply.permissionId, + reply: reply.reply, + ...(location.workspaceId ? { workspace: location.workspaceId } : {}), + }, + opts, + ) + } + } finally { + releaseMutation() } } } diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index d31d56523..151cdb9eb 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -1,6 +1,13 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata" +import os from "node:os" +import { InstanceMutationGate } from "../server/instance-mutation-gate" +import { + createOpencodeYoloPersistence, + hasPersistedYolo, + mergePersistedWorktreeSlug, + mergePersistedYolo, +} from "./opencode-yolo-metadata" describe("OpenCode Yolo metadata", () => { it("preserves unrelated metadata while replacing Yolo state", () => { @@ -20,27 +27,44 @@ describe("OpenCode Yolo metadata", () => { assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false) }) + it("clears only the legacy worktree marker", () => { + assert.deepEqual( + mergePersistedWorktreeSlug({ thirdParty: true, codenomad: { version: 1, worktreeSlug: "feature", keep: true } }, null), + { thirdParty: true, codenomad: { version: 1, keep: true } }, + ) + }) + it("uses the session workspace for metadata updates", async () => { const calls: Array> = [] + const options: unknown[] = [] const client = { session: { async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] } }, async get(parameters: Record) { calls.push(parameters); return { data: { metadata: {} } } }, - async update(parameters: Record) { calls.push(parameters); return { data: {} } }, + async update(parameters: Record, requestOptions: unknown) { + calls.push(parameters) + options.push(requestOptions) + return { data: {} } + }, }, } - const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + const persistence = createOpencodeYoloPersistence({ resolveInstanceDirectory: async () => "/repo" } as never, () => client as never) const [session] = await persistence.loadSessions("instance") await persistence.persist("instance", "root", true, session?.workspaceId) assert.equal(session?.workspaceId, "workspace") assert.equal(calls[0]?.workspace, "workspace") assert.equal(calls[1]?.workspace, "workspace") + assert.deepEqual(options, [{ + throwOnError: true, + headers: { "x-codenomad-mutation-session": "root" }, + }]) }) it("serializes Yolo and worktree metadata writes across instances", async () => { let metadata: Record = { thirdParty: true } const client = { session: { + async list() { return { data: [{ id: "root" }] } }, async get() { return { data: { metadata } } }, async update(parameters: Record) { metadata = parameters.metadata as Record @@ -48,7 +72,7 @@ describe("OpenCode Yolo metadata", () => { }, }, } - const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + const persistence = createOpencodeYoloPersistence({ resolveInstanceDirectory: async () => "/repo" } as never, () => client as never) await Promise.all([ persistence.persist("instance-a", "root", true), persistence.setWorktreeSlug("instance-b", "root", "feature"), @@ -58,4 +82,86 @@ describe("OpenCode Yolo metadata", () => { codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" }, }) }) + + it("gates persisted metadata access with instance and repository admission", async () => { + const gate = new InstanceMutationGate() + let lists = 0 + const client = { + session: { + async list() { lists += 1; return { data: [] } }, + }, + } + const manager = { get: () => ({ path: os.tmpdir() }), resolveInstanceDirectory: async () => os.tmpdir() } + let release!: () => void + let started!: () => void + const held = new Promise((resolve) => { release = resolve }) + const entered = new Promise((resolve) => { started = resolve }) + const exclusive = gate.exclusive("instance", async () => { started(); await held }) + await entered + const load = createOpencodeYoloPersistence(manager as never, () => client as never, gate).loadSessions("instance") + await Promise.resolve() + assert.equal(lists, 0) + release() + await exclusive + await load + assert.equal(lists, 1) + }) + + it("resolves a moved session workspace after queued mutation admission", async () => { + const gate = new InstanceMutationGate() + let workspaceID = "workspace-a" + let lists = 0 + const scopes: Array> = [] + const client = { + session: { + async list() { + lists += 1 + return { data: [{ id: "root", workspaceID, metadata: {} }] } + }, + async get(parameters: Record) { + scopes.push(parameters) + return { data: { metadata: {} } } + }, + async update(parameters: Record) { + scopes.push(parameters) + return { data: { metadata: parameters.metadata } } + }, + }, + } + const manager = { get: () => ({ path: os.tmpdir() }), resolveInstanceDirectory: async () => "/repo" } + const persistence = createOpencodeYoloPersistence(manager as never, () => client as never, gate) + let releaseMove!: () => void + let markMoveStarted!: () => void + const moveHeld = new Promise((resolve) => { releaseMove = resolve }) + const moveStarted = new Promise((resolve) => { markMoveStarted = resolve }) + const move = gate.exclusive("instance", async () => { markMoveStarted(); await moveHeld }) + await moveStarted + + const toggle = persistence.persist("instance", "root", true, "workspace-a") + await Promise.resolve() + assert.equal(lists, 0) + workspaceID = "workspace-b" + releaseMove() + await move + await toggle + + assert.equal(lists, 1) + assert.deepEqual(scopes.map((scope) => scope.workspace), ["workspace-b", "workspace-b"]) + }) + + it("allows transaction-owned metadata cleanup without re-entering its exclusive gate", async () => { + const gate = new InstanceMutationGate() + const client = { + session: { + async list() { return { data: [{ id: "root" }] } }, + async get() { return { data: { metadata: {} } } }, + async update(parameters: Record) { return { data: { metadata: parameters.metadata } } }, + }, + } + const manager = { getRepositoryMutationKey: () => "repo:shared", resolveInstanceDirectory: async () => "/repo" } + const persistence = createOpencodeYoloPersistence(manager as never, () => client as never, gate) + + await gate.exclusive("instance", () => gate.exclusive("repo:shared", () => + persistence.setWorktreeSlug("instance", "root", null, { alreadyAdmitted: true }))) + }) }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index bca929015..91d897bd7 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -1,7 +1,8 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { WorkspaceManager } from "../workspaces/manager" -import { createInstanceClient } from "../workspaces/instance-client" -import type { AutoAcceptPersistence, PersistedAutoAcceptSession } from "./auto-accept-manager" +import { createInstanceClient, type InstanceClientOptions } from "../workspaces/instance-client" +import { enterWorkspaceMutationAdmission, type InstanceMutationGate } from "../server/instance-mutation-gate" +import type { AutoAcceptPersistence, MutationAdmissionOptions, PersistedAutoAcceptSession } from "./auto-accept-manager" const CODENOMAD_METADATA_VERSION = 1 const SESSION_LIST_LIMIT = 10_000 @@ -9,8 +10,9 @@ const SESSION_LIST_LIMIT = 10_000 type Metadata = Record export interface OpencodeYoloPersistence extends AutoAcceptPersistence { - hasProjectSession(instanceId: string, sessionId: string): Promise - setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise + hasProjectSession(instanceId: string, sessionId: string, admission?: MutationAdmissionOptions): Promise + setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string | null, + admission?: MutationAdmissionOptions): Promise } function record(value: unknown): Metadata { @@ -38,24 +40,45 @@ export function mergePersistedYolo(metadata: unknown, rootSessionId: string, ena } } -export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string): Metadata { +export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string | null): Metadata { const current = record(metadata) const codenomad = record(current.codenomad) + if (worktreeSlug === null) delete codenomad.worktreeSlug return { ...current, - codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug }, + codenomad: { + ...codenomad, + version: CODENOMAD_METADATA_VERSION, + ...(worktreeSlug === null ? {} : { worktreeSlug }), + }, } } export function createOpencodeYoloPersistence( workspaceManager: WorkspaceManager, - createClient: (manager: WorkspaceManager, instanceId: string) => OpencodeClient | null = createInstanceClient, + createClient: (manager: WorkspaceManager, instanceId: string, options?: InstanceClientOptions) => OpencodeClient | null = createInstanceClient, + mutationGate?: Pick, ): OpencodeYoloPersistence { const writes = new Map>() - const clientFor = (instanceId: string) => { - const client = createClient(workspaceManager, instanceId) + const clientFor = async (instanceId: string) => { + const directory = await workspaceManager.resolveInstanceDirectory(instanceId) + const client = createClient(workspaceManager, instanceId, { directory }) if (!client) throw new Error(`Yolo: instance ${instanceId} has no open port`) - return client + return { client, directory } + } + const admitted = async (instanceId: string, admission: MutationAdmissionOptions | undefined, + operation: () => Promise): Promise => { + if (!mutationGate || admission?.alreadyAdmitted) return operation() + const release = await enterWorkspaceMutationAdmission( + mutationGate, + instanceId, + async () => workspaceManager.get(instanceId)?.path, + ) + try { + return await operation() + } finally { + await release() + } } const updateMetadata = ( instanceId: string, @@ -65,11 +88,14 @@ export function createOpencodeYoloPersistence( ): Promise => { const writeKey = sessionId const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => { - const client = clientFor(instanceId) + const { client } = await clientFor(instanceId) const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) } const { data: session } = await client.session.get(scope, { throwOnError: true }) const metadata = update(session.metadata) - const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true }) + const { data } = await client.session.update({ ...scope, metadata }, { + throwOnError: true, + headers: { "x-codenomad-mutation-session": sessionId }, + }) return record(data?.metadata ?? metadata) }) const settled = write.finally(() => { @@ -79,33 +105,58 @@ export function createOpencodeYoloPersistence( return settled } return { - async loadSessions(instanceId): Promise { - const { data } = await clientFor(instanceId).session.list( - { scope: "project", limit: SESSION_LIST_LIMIT }, - { throwOnError: true }, - ) - return (data ?? []).map((session) => ({ - id: session.id, - parentId: session.parentID ?? null, - revert: session.revert, - workspaceId: session.workspaceID, - yoloEnabled: hasPersistedYolo(session.id, session.metadata), - })) + loadSessions(instanceId, admission): Promise { + return admitted(instanceId, admission, async () => { + const { client, directory } = await clientFor(instanceId) + const { data } = await client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory }, + { throwOnError: true }, + ) + return (data ?? []).map((session) => ({ + id: session.id, + parentId: session.parentID ?? null, + revert: session.revert, + workspaceId: session.workspaceID, + yoloEnabled: hasPersistedYolo(session.id, session.metadata), + })) + }) }, - persist(instanceId, rootSessionId, enabled, workspaceId): Promise { - return updateMetadata(instanceId, rootSessionId, workspaceId, - (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined) + persist(instanceId, rootSessionId, enabled, _workspaceId, admission): Promise { + return admitted(instanceId, admission, async () => { + const { client, directory } = await clientFor(instanceId) + const { data } = await client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory }, + { throwOnError: true }, + ) + const matches = (data ?? []).filter((session) => session.id === rootSessionId) + if (matches.length !== 1) throw new Error(`Session ${rootSessionId} location is missing or ambiguous`) + await updateMetadata(instanceId, rootSessionId, matches[0].workspaceID, + (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)) + }) }, - async hasProjectSession(instanceId, sessionId): Promise { - const { data } = await clientFor(instanceId).session.list( - { scope: "project", limit: SESSION_LIST_LIMIT }, - { throwOnError: true }, - ) - return (data ?? []).some((session) => session.id === sessionId) + hasProjectSession(instanceId, sessionId, admission): Promise { + return admitted(instanceId, admission, async () => { + const { client, directory } = await clientFor(instanceId) + const { data } = await client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory }, + { throwOnError: true }, + ) + return (data ?? []).some((session) => session.id === sessionId) + }) }, - setWorktreeSlug(instanceId, sessionId, worktreeSlug): Promise { - return updateMetadata(instanceId, sessionId, undefined, - (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + setWorktreeSlug(instanceId, sessionId, worktreeSlug, admission): Promise { + return admitted(instanceId, admission, async () => { + const { client, directory } = await clientFor(instanceId) + return client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory }, + { throwOnError: true }, + ).then(({ data }) => { + const matches = (data ?? []).filter((session) => session.id === sessionId) + if (matches.length !== 1) throw new Error(`Session ${sessionId} location is missing or ambiguous`) + return updateMetadata(instanceId, sessionId, matches[0].workspaceID, + (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + }) + }) }, } } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 44e855b27..0f3953dd8 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -6,9 +6,10 @@ import fs from "fs" import { connect as connectTcp, type Socket } from "net" import path from "path" import { connect as connectTls, type TLSSocket } from "tls" -import { fetch, type Headers } from "undici" +import { fetch, request as requestUpstream, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" +import { createInstanceClient } from "../workspaces/instance-client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -47,6 +48,14 @@ import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" import { createOpenCodeUpdateService } from "../opencode-update/service" +import type { InstanceMutationGate } from "./instance-mutation-gate" +import { + admitWorkspaceMutation, + MUTATION_SESSION_HEADER, + openUpstreamMutation, + ProxyMutationTracker, + WorkspaceMutationConflictError, +} from "./instance-mutation-proxy" interface HttpServerDeps { bindHost: string @@ -71,6 +80,7 @@ interface HttpServerDeps { remoteProxySessionManager: RemoteProxySessionManager yoloManager: AutoAcceptManager sessionMetadataPersistence: OpencodeYoloPersistence + mutationGate: InstanceMutationGate uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -100,6 +110,9 @@ export function createHttpServer(deps: HttpServerDeps) { const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) + const instanceMutationGate = deps.mutationGate + const proxyMutations = new ProxyMutationTracker() + const shutdownMutations = new AbortController() const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -204,6 +217,7 @@ export function createHttpServer(deps: HttpServerDeps) { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger.child({ component: "background-processes" }), + mutationGate: instanceMutationGate, }) registerAuthRoutes(app, { authManager: deps.authManager }) @@ -247,6 +261,9 @@ export function createHttpServer(deps: HttpServerDeps) { } } + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } sendUnauthorized(request, reply) return } @@ -281,7 +298,11 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) - registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) + registerWorkspaceRoutes(app, { + workspaceManager: deps.workspaceManager, + mutationGate: instanceMutationGate, + shutdownSignal: shutdownMutations.signal, + }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerOpenCodeUpdateRoutes(app, { service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager), @@ -299,6 +320,8 @@ export function createHttpServer(deps: HttpServerDeps) { registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager, sessionMetadataPersistence: deps.sessionMetadataPersistence, + mutationGate: instanceMutationGate, + shutdownSignal: shutdownMutations.signal, }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, @@ -332,7 +355,13 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerYoloRoutes(app, { yoloManager: deps.yoloManager }) - registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) + registerInstanceProxyRoutes(app, { + workspaceManager: deps.workspaceManager, + logger: proxyLogger, + mutationGate: instanceMutationGate, + proxyMutations, + shutdownSignal: shutdownMutations.signal, + }) if (deps.uiDevServerUrl) { @@ -387,9 +416,12 @@ export function createHttpServer(deps: HttpServerDeps) { return { port: actualPort, url: serverUrl, displayHost } }, - stop: () => { + stop: async () => { closeSseClients() - return app.close() + const reason = new Error("CodeNomad server is shutting down") + shutdownMutations.abort(reason) + await proxyMutations.abortAndDrain(reason) + await app.close() }, } } @@ -397,6 +429,9 @@ export function createHttpServer(deps: HttpServerDeps) { interface InstanceProxyDeps { workspaceManager: WorkspaceManager logger: Logger + mutationGate: InstanceMutationGate + proxyMutations: ProxyMutationTracker + shutdownSignal: AbortSignal } interface SideCarProxyDeps { @@ -523,7 +558,7 @@ function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocket }) } -function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { +export function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() instance.addContentTypeParser("*", (req, body, done) => done(null, body)) @@ -538,6 +573,9 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe workspaceManager: deps.workspaceManager, pathSuffix: "", logger: deps.logger, + mutationGate: deps.mutationGate, + proxyMutations: deps.proxyMutations, + shutdownSignal: deps.shutdownSignal, }) } @@ -551,6 +589,9 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe workspaceManager: deps.workspaceManager, pathSuffix: request.params["*"] ?? "", logger: deps.logger, + mutationGate: deps.mutationGate, + proxyMutations: deps.proxyMutations, + shutdownSignal: deps.shutdownSignal, }) } @@ -560,12 +601,99 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe } const INSTANCE_PROXY_HOST = "127.0.0.1" +const UI_ATTACHMENT_LIMIT_BYTES = 5 * 1024 * 1024 +const UI_ATTACHMENT_LIMIT_COUNT = 8 +export const MUTATION_BODY_LIMIT_BYTES = Math.ceil(UI_ATTACHMENT_LIMIT_BYTES / 3) * 4 * UI_ATTACHMENT_LIMIT_COUNT + 1024 * 1024 +const MUTATION_UPLOAD_IDLE_MS = 15_000 +const MUTATION_UPLOAD_DEADLINE_MS = 60_000 + +export class MutationBodyLimitError extends Error {} +export class MutationBodyTimeoutError extends Error {} + +export function closeMutationUploadAfterResponse( + request: { resume: () => unknown; destroy: () => unknown }, + response: { setHeader: (name: string, value: string) => unknown; once: (event: "finish", listener: () => void) => unknown }, +): void { + response.setHeader("connection", "close") + request.resume() + response.once("finish", () => request.destroy()) +} + +export function authoritativeMutationHeaders( + headers: FastifyRequest["headers"], + directory: string, + workspaceId: string | null, + authorization?: string, +): Record { + const result = { ...headers } + const blocked = new Set(["host", "connection", "transfer-encoding", "expect", MUTATION_SESSION_HEADER, + "x-opencode-directory", "x-opencode-workspace"]) + for (const name of Object.keys(result)) { + if (blocked.has(name.toLowerCase())) delete result[name] + } + result["x-opencode-directory"] = encodeURIComponent(directory) + if (workspaceId) result["x-opencode-workspace"] = workspaceId + if (authorization) result.authorization = authorization + return result +} + +export async function readMutationBody( + body: unknown, + contentLength: string | undefined, + signal: AbortSignal, + limits: { bytes?: number; idleMs?: number; deadlineMs?: number } = {}, +): Promise { + const byteLimit = limits.bytes ?? MUTATION_BODY_LIMIT_BYTES + const idleMs = limits.idleMs ?? MUTATION_UPLOAD_IDLE_MS + const deadlineMs = limits.deadlineMs ?? MUTATION_UPLOAD_DEADLINE_MS + const declaredLength = Number(contentLength) + if (Number.isFinite(declaredLength) && declaredLength > byteLimit) throw new MutationBodyLimitError() + if (!body || typeof (body as any)[Symbol.asyncIterator] !== "function") { + const size = Buffer.isBuffer(body) ? body.length : typeof body === "string" ? Buffer.byteLength(body) : 0 + if (size > byteLimit) throw new MutationBodyLimitError() + return body + } + + const stream = body as NodeJS.ReadableStream & AsyncIterable + const iterator = stream[Symbol.asyncIterator]() + const chunks: Buffer[] = [] + let size = 0 + const deadline = Date.now() + deadlineMs + while (true) { + signal.throwIfAborted() + const remaining = deadline - Date.now() + if (remaining <= 0) throw new MutationBodyTimeoutError() + let timer: ReturnType | undefined + let abort = () => {} + const next = await Promise.race([ + iterator.next(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new MutationBodyTimeoutError()), Math.min(remaining, idleMs)) + }), + new Promise((_resolve, reject) => { + abort = () => reject(signal.reason) + signal.addEventListener("abort", abort, { once: true }) + }), + ]).finally(() => { + if (timer) clearTimeout(timer) + signal.removeEventListener("abort", abort) + }) + if (next.done) return Buffer.concat(chunks) + const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value) + size += chunk.length + if (size > byteLimit) throw new MutationBodyLimitError() + chunks.push(chunk) + } +} async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply workspaceManager: WorkspaceManager logger: Logger + mutationGate: InstanceMutationGate + proxyMutations: ProxyMutationTracker + shutdownSignal: AbortSignal pathSuffix?: string }) { const { request, reply, workspaceManager, logger } = args @@ -635,20 +763,37 @@ async function proxyWorkspaceRequest(args: { } if (!workspace) { + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } reply.code(404).send({ error: "Workspace not found" }) return } const port = workspaceManager.getInstancePort(workspaceId) if (!port) { + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } reply.code(502).send({ error: "Workspace instance is not ready" }) return } const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") - const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" - const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` + let search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" + if (request.method === "GET" && normalizedSuffix === "/experimental/workspace") { + try { + const scope = new URLSearchParams(search) + scope.set("directory", await workspaceManager.resolveInstanceDirectory(workspaceId)) + search = `?${scope.toString()}` + } catch (error) { + logger.error({ err: error, workspaceId }, "Failed to resolve native workspace-list scope") + reply.code(502).send({ error: "Workspace instance proxy failed" }) + return + } + } + let targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance") @@ -656,48 +801,176 @@ async function proxyWorkspaceRequest(args: { logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload") } - return reply.from(targetUrl, { - rewriteRequestHeaders: (_originalRequest, headers) => { - if (instanceAuthHeader) { - headers.authorization = instanceAuthHeader + if (request.method !== "GET" && request.method !== "HEAD") { + const downstream = new AbortController() + const abortDownstream = () => downstream.abort(new Error("Downstream mutation request disconnected")) + const abortShutdown = () => downstream.abort(args.shutdownSignal.reason) + const abortClosedReply = () => { if (!reply.raw.writableEnded) abortDownstream() } + request.raw.once("aborted", abortDownstream) + reply.raw.once("close", abortClosedReply) + args.shutdownSignal.addEventListener("abort", abortShutdown, { once: true }) + if (args.shutdownSignal.aborted) abortShutdown() + let admitted: Awaited> | undefined + let admissionHandedOff = false + try { + const mutationBody = await readMutationBody( + request.body, + Array.isArray(request.headers["content-length"]) + ? request.headers["content-length"][0] + : request.headers["content-length"], + downstream.signal, + ) + admitted = await admitWorkspaceMutation({ + gate: args.mutationGate, + workspaceId, + method: request.method, + pathSuffix: args.pathSuffix, + rawUrl: request.raw.url, + sessionContext: Array.isArray(request.headers[MUTATION_SESSION_HEADER]) + ? request.headers[MUTATION_SESSION_HEADER][0] + : request.headers[MUTATION_SESSION_HEADER], + signal: downstream.signal, + resolveWorkspace: async () => { + const current = workspaceManager.get(workspaceId) + const currentPort = workspaceManager.getInstancePort(workspaceId) + if (!current || !currentPort) return undefined + return { + port: currentPort, + authorization: workspaceManager.getInstanceAuthorizationHeader(workspaceId), + hostDirectory: current.path, + nativeRootDirectory: await workspaceManager.resolveInstanceDirectory(workspaceId), + } + }, + loadSessions: async () => { + const directory = await workspaceManager.resolveInstanceDirectory(workspaceId) + const client = createInstanceClient(workspaceManager, workspaceId, { directory }) + if (!client) throw new WorkspaceMutationConflictError("Workspace instance is no longer ready") + const { data = [] } = await client.session.list( + { scope: "project", limit: 10_000, directory }, + { throwOnError: true }, + ) + return data + }, + loadWorkspaces: async () => { + const current = workspaceManager.get(workspaceId) + const directory = await workspaceManager.resolveInstanceDirectory(workspaceId) + const client = createInstanceClient(workspaceManager, workspaceId, { directory }) + if (!current || !client) throw new WorkspaceMutationConflictError("Workspace instance is no longer ready") + const scope = { directory } + await client.experimental.workspace.syncList(scope, { throwOnError: true }) + const { data = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) + return data + }, + }) + targetUrl = `http://${INSTANCE_PROXY_HOST}:${admitted.port}${normalizedSuffix}${admitted.search}` + const requestHeaders = authoritativeMutationHeaders( + request.headers, + admitted.directory, + admitted.workspaceId, + admitted.authorization, + ) + if (Buffer.isBuffer(mutationBody)) requestHeaders["content-length"] = String(mutationBody.length) + downstream.signal.throwIfAborted() + admissionHandedOff = true + const upstream = await openUpstreamMutation>>({ + release: async () => { + request.raw.removeListener("aborted", abortDownstream) + reply.raw.removeListener("close", abortClosedReply) + args.shutdownSignal.removeEventListener("abort", abortShutdown) + await admitted!.release() + }, + downstreamSignal: downstream.signal, + tracker: args.proxyMutations, + start: (signal) => requestUpstream(targetUrl, { + method: request.method as any, + headers: requestHeaders as any, + body: mutationBody as any, + signal, + headersTimeout: 0, + bodyTimeout: 0, + maxRedirections: 0, + }), + }) + reply.code(upstream.statusCode) + for (const [name, value] of Object.entries(upstream.headers)) { + if (value !== undefined && !["connection", "keep-alive", "transfer-encoding", "upgrade"].includes(name.toLowerCase())) { + reply.header(name, value as any) + } } + try { + return reply.send(upstream.body) + } catch (error) { + upstream.body.destroy(error as Error) + throw error + } + } catch (error) { + if (admitted && !admissionHandedOff) await admitted.release() + logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace mutation") + request.raw.removeListener("aborted", abortDownstream) + reply.raw.removeListener("close", abortClosedReply) + args.shutdownSignal.removeEventListener("abort", abortShutdown) + if (!request.raw.complete && !reply.sent) closeMutationUploadAfterResponse(request.raw, reply.raw) + if (error instanceof MutationBodyLimitError && !reply.sent) { + return reply.code(413).send({ error: "Mutation request body is too large" }) + } + if (error instanceof MutationBodyTimeoutError && !reply.sent) { + return reply.code(408).send({ error: "Mutation request body timed out" }) + } + if (error instanceof WorkspaceMutationConflictError && !reply.sent) { + return reply.code(409).send({ error: error.message }) + } + if (!reply.sent) return reply.code(502).send({ error: "Workspace instance proxy failed" }) + return + } + } - if (logger.isLevelEnabled("trace")) { - const outgoing: Record = {} - for (const [key, value] of Object.entries(headers as Record)) { - outgoing[key] = value + try { + return await reply.from(targetUrl, { + rewriteRequestHeaders: (_originalRequest, headers) => { + delete headers[MUTATION_SESSION_HEADER] + if (instanceAuthHeader) { + headers.authorization = instanceAuthHeader } - // Redact sensitive headers. - for (const key of Object.keys(outgoing)) { - const lower = key.toLowerCase() - if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { - outgoing[key] = "" + if (logger.isLevelEnabled("trace")) { + const outgoing: Record = {} + for (const [key, value] of Object.entries(headers as Record)) { + outgoing[key] = value } - } - logger.trace( - { - workspaceId, - method: request.method, - targetUrl, - contentType: request.headers["content-type"], - body: bodyToJson(request.body), - headers: outgoing, - }, - "Proxy -> OpenCode request", - ) - } + // Redact sensitive headers. + for (const key of Object.keys(outgoing)) { + const lower = key.toLowerCase() + if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { + outgoing[key] = "" + } + } - return headers - }, - onError: (proxyReply, { error }) => { - logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") - if (!proxyReply.sent) { - proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) - } - }, - }) + logger.trace( + { + workspaceId, + method: request.method, + targetUrl, + contentType: request.headers["content-type"], + body: bodyToJson(request.body), + headers: outgoing, + }, + "Proxy -> OpenCode request", + ) + } + + return headers + }, + onError: (proxyReply, { error }) => { + logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") + if (!proxyReply.sent) { + proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) + } + }, + }) + } catch (error) { + throw error + } } function normalizeInstanceSuffix(pathSuffix: string | undefined) { diff --git a/packages/server/src/server/instance-mutation-body.test.ts b/packages/server/src/server/instance-mutation-body.test.ts new file mode 100644 index 000000000..c01c696e7 --- /dev/null +++ b/packages/server/src/server/instance-mutation-body.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict" +import { createServer, request as httpRequest } from "node:http" +import { Readable } from "node:stream" +import { describe, it } from "node:test" +import { + authoritativeMutationHeaders, + closeMutationUploadAfterResponse, + MutationBodyLimitError, + MutationBodyTimeoutError, + readMutationBody, +} from "./http-server" + +async function uploadResponse(options: { + chunks: Buffer[] + bytes: number + idleMs?: number + shutdownAfterMs?: number + immediateStatus?: number +}) { + const server = createServer(async (request, response) => { + if (options.immediateStatus !== undefined) { + closeMutationUploadAfterResponse(request, response) + response.statusCode = options.immediateStatus + response.setHeader("content-type", "application/json") + response.end(JSON.stringify({ error: response.statusCode })) + return + } + const controller = new AbortController() + const shutdown = options.shutdownAfterMs === undefined + ? undefined + : setTimeout(() => controller.abort(new Error("shutdown")), options.shutdownAfterMs) + try { + await readMutationBody(request, request.headers["content-length"], controller.signal, { + bytes: options.bytes, + idleMs: options.idleMs ?? 100, + deadlineMs: 500, + }) + response.end("ok") + } catch (error) { + closeMutationUploadAfterResponse(request, response) + response.statusCode = error instanceof MutationBodyLimitError ? 413 : error instanceof MutationBodyTimeoutError ? 408 : 502 + response.setHeader("content-type", "application/json") + response.end(JSON.stringify({ error: response.statusCode })) + } finally { + if (shutdown) clearTimeout(shutdown) + } + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("HTTP test server did not bind") + try { + return await new Promise<{ statusCode?: number; body: string; connection?: string }>((resolve, reject) => { + const request = httpRequest({ host: "127.0.0.1", port: address.port, method: "POST" }, (response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + response.on("end", () => resolve({ + statusCode: response.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + connection: response.headers.connection, + })) + }) + request.on("error", reject) + for (const chunk of options.chunks) request.write(chunk) + }) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +describe("mutation request body admission", () => { + it("overwrites caller-controlled OpenCode scope", () => { + const headers = authoritativeMutationHeaders({ + "x-opencode-directory": "untrusted", + "x-opencode-workspace": "untrusted", + "x-codenomad-mutation-session": "private", + expect: "100-continue", + }, "/native/repo", "admitted", "Basic trusted") + assert.equal(headers["x-opencode-directory"], encodeURIComponent("/native/repo")) + assert.equal(headers["x-opencode-workspace"], "admitted") + assert.equal(headers["x-codenomad-mutation-session"], undefined) + assert.equal(headers.expect, undefined) + assert.equal(headers.authorization, "Basic trusted") + }) + + it("rejects declared and streamed bodies over the cap", async () => { + const signal = new AbortController().signal + await assert.rejects(readMutationBody(undefined, "11", signal, { bytes: 10 }), MutationBodyLimitError) + await assert.rejects( + readMutationBody(Readable.from([Buffer.alloc(6), Buffer.alloc(5)]), undefined, signal, { bytes: 10 }), + MutationBodyLimitError, + ) + }) + + it("leaves the upload stream open so the HTTP error response can flush", async () => { + const stream = Readable.from([Buffer.alloc(6), Buffer.alloc(5)]) + let destroyedBeforeResponse = false + stream.once("close", () => { destroyedBeforeResponse = true }) + await assert.rejects( + readMutationBody(stream, undefined, new AbortController().signal, { bytes: 10 }), + MutationBodyLimitError, + ) + assert.equal(destroyedBeforeResponse, false) + }) + + it("bounds an idle upload before mutation admission", async () => { + const stream = new Readable({ read() {} }) + await assert.rejects( + readMutationBody(stream, undefined, new AbortController().signal, { idleMs: 5, deadlineMs: 50 }), + MutationBodyTimeoutError, + ) + }) + + it("flushes 413 and 408 responses before closing streamed upload sockets", async () => { + const limited = await uploadResponse({ chunks: [Buffer.alloc(6), Buffer.alloc(5)], bytes: 10 }) + assert.deepEqual(limited, { statusCode: 413, body: '{"error":413}', connection: "close" }) + const timedOut = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, idleMs: 5 }) + assert.deepEqual(timedOut, { statusCode: 408, body: '{"error":408}', connection: "close" }) + }) + + it("flushes a shutdown error before closing an incomplete upload socket", async () => { + const shutdown = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, shutdownAfterMs: 5 }) + assert.deepEqual(shutdown, { statusCode: 502, body: '{"error":502}', connection: "close" }) + }) + + it("flushes 401 before closing an unauthorized incomplete upload socket", async () => { + const unauthorized = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, immediateStatus: 401 }) + assert.deepEqual(unauthorized, { statusCode: 401, body: '{"error":401}', connection: "close" }) + }) +}) diff --git a/packages/server/src/server/instance-mutation-gate.test.ts b/packages/server/src/server/instance-mutation-gate.test.ts new file mode 100644 index 000000000..5529ee4ac --- /dev/null +++ b/packages/server/src/server/instance-mutation-gate.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { describe, it } from "node:test" +import { holdMutationLeaseUntilUpstreamSettles, InstanceMutationGate } from "./instance-mutation-gate" + +describe("InstanceMutationGate", () => { + it("keeps a proxy lease after downstream close until delayed upstream completion", () => { + const downstream = new EventEmitter() + const upstream = new EventEmitter() + let releases = 0 + holdMutationLeaseUntilUpstreamSettles(upstream, () => { releases += 1 }) + + downstream.emit("close") + assert.equal(releases, 0) + upstream.emit("end") + upstream.emit("close") + assert.equal(releases, 1) + }) + + it("drains admitted mutations and blocks new mutations during an exclusive operation", async () => { + const gate = new InstanceMutationGate() + const releaseFirst = await gate.enter("workspace") + const events: string[] = [] + let releaseExclusive!: () => void + const exclusivePending = new Promise((resolve) => { releaseExclusive = resolve }) + const exclusive = gate.exclusive("workspace", async () => { + events.push("exclusive") + await exclusivePending + }) + const second = gate.enter("workspace").then((release) => { + events.push("second") + release() + }) + + await Promise.resolve() + assert.deepEqual(events, []) + releaseFirst() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(events, ["exclusive"]) + releaseExclusive() + await exclusive + await second + assert.deepEqual(events, ["exclusive", "second"]) + }) + + it("keeps mutation admission closed for a queued exclusive operation", async () => { + const gate = new InstanceMutationGate() + let releaseFirst!: () => void + let releaseSecond!: () => void + let secondStarted!: () => void + const firstPending = new Promise((resolve) => { releaseFirst = resolve }) + const secondPending = new Promise((resolve) => { releaseSecond = resolve }) + const started = new Promise((resolve) => { secondStarted = resolve }) + const first = gate.exclusive("workspace", () => firstPending) + const second = gate.exclusive("workspace", async () => { + secondStarted() + await secondPending + }) + + releaseFirst() + await first + await started + let admitted = false + const mutation = gate.enter("workspace").then((release) => { + admitted = true + release() + }) + await Promise.resolve() + assert.equal(admitted, false) + releaseSecond() + await second + await mutation + assert.equal(admitted, true) + }) + + it("admits control work into an active shared lane ahead of a queued exclusive operation", async () => { + const gate = new InstanceMutationGate() + const releasePrompt = await gate.enter("workspace") + let exclusiveStarted = false + const exclusive = gate.exclusive("workspace", async () => { exclusiveStarted = true }) + + const releaseControl = await gate.enterControl("workspace") + releasePrompt() + await Promise.resolve() + assert.equal(exclusiveStarted, false) + releaseControl() + await exclusive + assert.equal(exclusiveStarted, true) + }) + + it("blocks control work while an exclusive operation is active", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markStarted = resolve }) + const exclusive = gate.exclusive("workspace", async () => { markStarted(); await held }) + await started + + let admitted = false + const control = gate.enterControl("workspace").then((release) => { admitted = true; release() }) + await Promise.resolve() + assert.equal(admitted, false) + finishExclusive() + await exclusive + await control + assert.equal(admitted, true) + }) + + it("removes an aborted admission waiter", async () => { + const gate = new InstanceMutationGate() + let releaseExclusive!: () => void + let markStarted!: () => void + const held = new Promise((resolve) => { releaseExclusive = resolve }) + const started = new Promise((resolve) => { markStarted = resolve }) + const exclusive = gate.exclusive("workspace", async () => { markStarted(); await held }) + await started + const controller = new AbortController() + const waiting = gate.enter("workspace", controller.signal) + controller.abort(new Error("cancelled")) + await assert.rejects(waiting, /cancelled/) + releaseExclusive() + await exclusive + + await gate.exclusive("workspace", async () => undefined) + }) +}) diff --git a/packages/server/src/server/instance-mutation-gate.ts b/packages/server/src/server/instance-mutation-gate.ts new file mode 100644 index 000000000..47eb59e3d --- /dev/null +++ b/packages/server/src/server/instance-mutation-gate.ts @@ -0,0 +1,147 @@ +interface GateState { + active: number + exclusive: boolean + changed: Set<() => void> +} + +export interface UpstreamLifecycle { + once(event: "end" | "close" | "error" | "aborted", listener: () => void): unknown + removeListener(event: "end" | "close" | "error" | "aborted", listener: () => void): unknown +} + +export function holdMutationLeaseUntilUpstreamSettles(upstream: UpstreamLifecycle, release: () => void): () => void { + let settled = false + const settle = () => { + if (settled) return + settled = true + upstream.removeListener("end", settle) + upstream.removeListener("close", settle) + upstream.removeListener("error", settle) + upstream.removeListener("aborted", settle) + release() + } + upstream.once("end", settle) + upstream.once("close", settle) + upstream.once("error", settle) + upstream.once("aborted", settle) + return settle +} + +export async function enterWorkspaceMutationAdmission( + gate: Pick, + instanceId: string, + resolveWorkspaceFolder: string | undefined | (() => Promise), + signal?: AbortSignal, +): Promise<() => Promise> { + const releaseInstance = await gate.enter(instanceId, signal) + try { + const resolve = typeof resolveWorkspaceFolder === "function" + ? resolveWorkspaceFolder + : async () => resolveWorkspaceFolder + const workspaceFolder = await resolve() + if (!workspaceFolder) throw new Error(`Workspace ${instanceId} has no repository admission path`) + const repository = await acquireRepositoryMutation({ workspaceFolder, gate, signal }) + if (await resolve() !== workspaceFolder) { + releaseInstance() + await repository.release() + throw new Error(`Workspace ${instanceId} changed while mutation admission was queued`) + } + return async () => { + releaseInstance() + await repository.release() + } + } catch (error) { + releaseInstance() + throw error + } +} + +export class InstanceMutationGate { + // ponytail: workspace IDs are process-scoped and few; retained states avoid wake-up cleanup races. + private readonly states = new Map() + + async enter(instanceId: string, signal?: AbortSignal): Promise<() => void> { + return this.enterShared(instanceId, false, signal) + } + + async enterControl(instanceId: string, signal?: AbortSignal): Promise<() => void> { + return this.enterShared(instanceId, true, signal) + } + + private async enterShared(instanceId: string, joinActive: boolean, signal?: AbortSignal): Promise<() => void> { + const state = this.state(instanceId) + signal?.throwIfAborted() + while (state.exclusive && (!joinActive || state.active === 0)) await this.changed(state, signal) + signal?.throwIfAborted() + state.active += 1 + let released = false + return () => { + if (released) return + released = true + state.active -= 1 + this.notify(state) + } + } + + async acquireExclusive(instanceId: string, signal?: AbortSignal): Promise<() => void> { + const state = this.state(instanceId) + signal?.throwIfAborted() + while (state.exclusive) await this.changed(state, signal) + signal?.throwIfAborted() + state.exclusive = true + try { + while (state.active > 0) await this.changed(state, signal) + } catch (error) { + state.exclusive = false + this.notify(state) + throw error + } + let released = false + return () => { + if (released) return + released = true + state.exclusive = false + this.notify(state) + } + } + + async exclusive(instanceId: string, operation: () => Promise, signal?: AbortSignal): Promise { + const release = await this.acquireExclusive(instanceId, signal) + try { + return await operation() + } finally { + release() + } + } + + private state(instanceId: string): GateState { + const existing = this.states.get(instanceId) + if (existing) return existing + const state = { active: 0, exclusive: false, changed: new Set<() => void>() } + this.states.set(instanceId, state) + return state + } + + private changed(state: GateState, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const wake = () => { + signal?.removeEventListener("abort", abort) + resolve() + } + const abort = () => { + state.changed.delete(wake) + reject(signal?.reason) + } + state.changed.add(wake) + signal?.addEventListener("abort", abort, { once: true }) + if (signal?.aborted) abort() + }) + } + + private notify(state: GateState): void { + const waiters = [...state.changed] + state.changed.clear() + for (const resolve of waiters) resolve() + } +} +import { acquireRepositoryMutation } from "../workspaces/repository-mutation-lock" diff --git a/packages/server/src/server/instance-mutation-proxy.test.ts b/packages/server/src/server/instance-mutation-proxy.test.ts new file mode 100644 index 000000000..bda38f884 --- /dev/null +++ b/packages/server/src/server/instance-mutation-proxy.test.ts @@ -0,0 +1,453 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { describe, it } from "node:test" +import { InstanceMutationGate } from "./instance-mutation-gate" +import { + admitWorkspaceMutation, + isInstanceControlMutation, + openUpstreamMutation, + ProxyMutationTracker, + WorkspaceMutationConflictError, +} from "./instance-mutation-proxy" + +describe("openUpstreamMutation", () => { + it("has no unconditional mutation deadline", async () => { + const body = new EventEmitter() + let signal!: AbortSignal + let releases = 0 + await openUpstreamMutation({ + start: async (value) => { signal = value; return { body } }, + release: () => { releases += 1 }, + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + assert.equal(signal.aborted, false) + assert.equal(releases, 0) + body.emit("end") + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(releases, 1) + }) + + it("aborts on downstream disconnect and releases after delayed upstream failure", async () => { + const downstream = new AbortController() + let failUpstream!: (error: Error) => void + let upstreamAborted = false + let releases = 0 + const pending = openUpstreamMutation({ + start: async (signal) => new Promise((_resolve, reject) => { + failUpstream = reject + signal.addEventListener("abort", () => { upstreamAborted = true }) + }), + release: () => { releases += 1 }, + downstreamSignal: downstream.signal, + }) + + downstream.abort(new Error("client disconnected")) + assert.equal(upstreamAborted, true) + assert.equal(releases, 0) + failUpstream(new Error("delayed upstream failure")) + await assert.rejects(pending, /delayed upstream failure/) + assert.equal(releases, 1) + }) + + it("aborts a bounded upstream request before releasing admission", async () => { + const events: string[] = [] + await assert.rejects(() => openUpstreamMutation({ + start: async (signal) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + events.push("abort") + reject(signal.reason) + }) + }), + release: () => { events.push("release") }, + timeoutMs: 1, + }), /timed out/) + assert.deepEqual(events, ["abort", "release"]) + }) + + it("fails bounded shutdown drain without manually releasing a hung upstream", async () => { + const tracker = new ProxyMutationTracker() + let signal!: AbortSignal + let releases = 0 + const pending = openUpstreamMutation({ + start: async (value) => { + signal = value + return new Promise(() => undefined) + }, + release: () => { releases += 1 }, + tracker, + }) + while (!signal) await Promise.resolve() + + await assert.rejects(tracker.abortAndDrain(new Error("shutdown"), 5), /Timed out draining/) + assert.equal(signal.aborted, true) + assert.equal(releases, 0) + void pending + }) + + it("rejects mutations admitted after shutdown quiescing begins", async () => { + const tracker = new ProxyMutationTracker() + await tracker.abortAndDrain(new Error("shutdown")) + let releases = 0 + await assert.rejects(openUpstreamMutation({ + start: async (signal) => { + signal.throwIfAborted() + return { body: new EventEmitter() } + }, + release: () => { releases += 1 }, + tracker, + }), /shutdown/) + assert.equal(releases, 1) + }) + + it("keeps shutdown drain pending until asynchronous admission release completes", async () => { + const tracker = new ProxyMutationTracker() + const body = new EventEmitter() + let finishRelease!: () => void + const release = new Promise((resolve) => { finishRelease = resolve }) + await openUpstreamMutation({ start: async () => ({ body }), release: () => release, tracker }) + body.emit("end") + let drained = false + const drain = tracker.abortAndDrain(new Error("shutdown")).then(() => { drained = true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(drained, false) + finishRelease() + await drain + assert.equal(drained, true) + }) + + it("retries failed admission cleanup without retaining tracker or instance admission", async () => { + const gate = new InstanceMutationGate() + const releaseInstance = await gate.enter("instance") + const tracker = new ProxyMutationTracker() + const body = new EventEmitter() + let attempts = 0 + await openUpstreamMutation({ + start: async () => ({ body }), + release: async () => { + attempts += 1 + try { + if (attempts === 1) throw new Error("transient repository release failure") + } finally { + releaseInstance() + } + }, + tracker, + }) + + body.emit("end") + await tracker.abortAndDrain(new Error("shutdown")) + await gate.exclusive("instance", async () => undefined) + assert.equal(attempts, 2) + }) + + it("retains settlement and retries after cleanup remains unavailable past the immediate attempts", async () => { + const tracker = new ProxyMutationTracker() + const body = new EventEmitter() + let attempts = 0 + let releaseAvailable = false + await openUpstreamMutation({ + start: async () => ({ body }), + release: async () => { + attempts += 1 + if (!releaseAvailable) throw new Error("temporary release failure") + }, + tracker, + }) + body.emit("end") + while (attempts < 4) await new Promise((resolve) => setTimeout(resolve, 10)) + + let drained = false + const drain = tracker.abortAndDrain(new Error("shutdown"), 1_000).then(() => { drained = true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(drained, false) + releaseAvailable = true + await drain + assert.ok(attempts >= 5) + }) +}) + +describe("admitWorkspaceMutation", () => { + const state = { port: 4321, hostDirectory: process.cwd(), nativeRootDirectory: "/repo" } + const workspaces = [{ id: "old", directory: "/old" }, { id: "new", directory: "/new" }] + + it("allowlists only permission and question replies, rejects, and authoritative session abort", () => { + for (const suffix of [ + "permission/request/reply", + "question/request/reply", + "question/request/reject", + "session/session/permissions/request", + "api/session/session/permission/request/reply", + "api/session/session/question/request/reply", + "api/session/session/question/request/reject", + "session/session/abort", + "api/session/session/abort", + ]) assert.equal(isInstanceControlMutation("POST", suffix), true, suffix) + for (const [method, suffix] of [ + ["GET", "permission/request/reply"], + ["POST", "session/session/prompt"], + ["POST", "permission/request/reply/extra"], + ["POST", "api/session/session/permission/request/reject"], + ["POST", "session/abort"], + ["POST", "abort"], + ]) assert.equal(isInstanceControlMutation(method, suffix), false, `${method} ${suffix}`) + }) + + it("re-resolves a moved session after waiting for instance admission", async () => { + const gate = new InstanceMutationGate() + let releaseExclusive!: () => void + let exclusiveStarted!: () => void + const held = new Promise((resolve) => { releaseExclusive = resolve }) + const started = new Promise((resolve) => { exclusiveStarted = resolve }) + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted(); await held }) + await started + let workspaceID = "old" + const admission = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + pathSuffix: "session/session-id/prompt_async", + rawUrl: "/session/session-id/prompt_async?workspace=old&mode=test&directory=C%3A%5Crepo", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", workspaceID }], + loadWorkspaces: async () => workspaces, + }) + + workspaceID = "new" + releaseExclusive() + await exclusive + const admitted = await admission + assert.equal(admitted.search, "?workspace=new&mode=test&directory=%2Fnew") + admitted.release() + }) + + it("removes stale workspace scope when a session moved to the root", async () => { + const admitted = await admitWorkspaceMutation({ + gate: new InstanceMutationGate(), + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/abort", + rawUrl: "/session/session-id/abort?workspace=old", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: state.nativeRootDirectory }], + loadWorkspaces: async () => workspaces, + }) + assert.equal(admitted.search, "?directory=%2Frepo") + admitted.release() + }) + + it("uses explicit context for legacy mutations without a session path", async () => { + const admitted = await admitWorkspaceMutation({ + gate: new InstanceMutationGate(), + workspaceId: "instance", + method: "POST", + pathSuffix: "permission/request/reply", + rawUrl: "/permission/request/reply?workspace=old", + sessionContext: "session-id", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", workspaceID: "new", directory: "/old" }], + loadWorkspaces: async () => workspaces, + }) + assert.equal(admitted.search, "?workspace=new&directory=%2Fnew") + admitted.release() + }) + + it("validates session creation workspace after admission", async () => { + const admitted = await admitWorkspaceMutation({ + gate: new InstanceMutationGate(), + workspaceId: "instance", + pathSuffix: "session", + rawUrl: "/session?workspace=new", + resolveWorkspace: async () => state, + loadSessions: async () => [], + loadWorkspaces: async () => workspaces, + }) + assert.equal(admitted.search, "?workspace=new&directory=%2Fnew") + admitted.release() + + await assert.rejects(() => admitWorkspaceMutation({ + gate: new InstanceMutationGate(), + workspaceId: "instance", + pathSuffix: "session", + rawUrl: "/session?workspace=deleted", + resolveWorkspace: async () => state, + loadSessions: async () => [], + loadWorkspaces: async () => workspaces, + }), WorkspaceMutationConflictError) + }) + + it("maps directory-only legacy sessions through native workspace inventory", async () => { + const admitted = await admitWorkspaceMutation({ + gate: new InstanceMutationGate(), + workspaceId: "instance", + method: "POST", + pathSuffix: "question/request/reply", + rawUrl: "/question/request/reply", + sessionContext: "session-id", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/new" }], + loadWorkspaces: async () => workspaces, + }) + assert.equal(admitted.search, "?workspace=new&directory=%2Fnew") + admitted.release() + }) + + it("routes abort authoritatively without reacquiring the repository lock held by its prompt", async () => { + const gate = new InstanceMutationGate() + const prompt = await admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/prompt", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: state.nativeRootDirectory }], + loadWorkspaces: async () => workspaces, + }) + const controller = new AbortController() + let abort: Awaited> | undefined + let abortError: unknown + const pendingAbort = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/abort", + rawUrl: "/session/session-id/abort?workspace=old", + signal: controller.signal, + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", workspaceID: "new" }], + loadWorkspaces: async () => workspaces, + }).then((value) => { abort = value }, (error) => { abortError = error }) + try { + await new Promise((resolve) => setImmediate(resolve)) + assert.ifError(abortError) + assert.ok(abort, "abort reacquired the repository lock held by its prompt") + assert.equal(abort.search, "?workspace=new&directory=%2Fnew") + await abort.release() + abort = undefined + } finally { + controller.abort(new Error("test cleanup")) + await pendingAbort + await abort?.release() + await prompt.release() + } + }) + + it("releases instance admission while repository cleanup keeps retrying", async () => { + let repositoryReleaseAttempts = 0 + const gate = { + enter: async (key: string) => key === "instance" + ? () => { instanceReleased = true } + : () => {}, + enterControl: async () => () => {}, + acquireExclusive: async () => async () => { + repositoryReleaseAttempts += 1 + if (repositoryReleaseAttempts <= 4) throw new Error("transient repository release failure") + }, + } + let instanceReleased = false + const admitted = await admitWorkspaceMutation({ + gate, + workspaceId: "instance", + pathSuffix: "session/session-id/abort", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }) + + const releasing = admitted.release() + await Promise.resolve() + assert.equal(instanceReleased, true) + await releasing + assert.ok(repositoryReleaseAttempts >= 5) + }) + + it("retries repository cleanup when admission discovery fails", async () => { + let instanceReleased = false + let repositoryReleaseAttempts = 0 + const gate = { + enter: async () => () => { instanceReleased = true }, + enterControl: async () => () => {}, + acquireExclusive: async () => async () => { + repositoryReleaseAttempts += 1 + if (repositoryReleaseAttempts === 1) throw new Error("transient repository release failure") + }, + } + + await assert.rejects(admitWorkspaceMutation({ + gate, + workspaceId: "instance", + pathSuffix: "session/session-id/abort", + resolveWorkspace: async () => state, + loadSessions: async () => { throw new Error("discovery failed") }, + loadWorkspaces: async () => [], + }), /discovery failed/) + assert.equal(instanceReleased, true) + assert.ok(repositoryReleaseAttempts >= 2) + }) + + it("admits a permission reply after move or deletion queues behind its prompt", async () => { + const gate = new InstanceMutationGate() + const admission = (pathSuffix: string, sessionContext?: string, signal?: AbortSignal) => admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix, + sessionContext, + signal, + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }) + const prompt = await admission("session/session-id/prompt") + let exclusiveStarted = false + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) + const controller = new AbortController() + let reply: Awaited> | undefined + let replyError: unknown + const pendingReply = admission("permission/request/reply", "session-id", controller.signal) + .then((value) => { reply = value }, (error) => { replyError = error }) + try { + await new Promise((resolve) => setImmediate(resolve)) + assert.ifError(replyError) + assert.ok(reply, "permission reply did not join the prompt lane") + await prompt.release() + await Promise.resolve() + assert.equal(exclusiveStarted, false) + await reply.release() + reply = undefined + await exclusive + assert.equal(exclusiveStarted, true) + } finally { + controller.abort(new Error("test cleanup")) + await pendingReply + await reply?.release() + await prompt.release() + await exclusive + } + }) + + it("keeps control replies behind instance-exclusive admission", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markExclusiveStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markExclusiveStarted = resolve }) + const exclusive = gate.exclusive("instance", async () => { markExclusiveStarted(); await held }) + await started + let admitted = false + const pending = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "api/session/session-id/question/request/reject", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }).then((value) => { admitted = true; return value }) + await Promise.resolve() + assert.equal(admitted, false) + finishExclusive() + await exclusive + const control = await pending + await control.release() + }) +}) diff --git a/packages/server/src/server/instance-mutation-proxy.ts b/packages/server/src/server/instance-mutation-proxy.ts new file mode 100644 index 000000000..840bb3637 --- /dev/null +++ b/packages/server/src/server/instance-mutation-proxy.ts @@ -0,0 +1,259 @@ +import { + holdMutationLeaseUntilUpstreamSettles, + type InstanceMutationGate, + type UpstreamLifecycle, +} from "./instance-mutation-gate" +import { resolveNativeSessionLocation, type NativeWorkspaceLocation } from "../workspaces/native-session-location" +import { acquireRepositoryMutation } from "../workspaces/repository-mutation-lock" + +const SESSION_LIST_LIMIT = 10_000 +const RELEASE_RETRY_MS = 40 +export const MUTATION_SESSION_HEADER = "x-codenomad-mutation-session" + +export function isInstanceControlMutation(method: string | undefined, pathSuffix: string | undefined): boolean { + if (method?.toUpperCase() !== "POST") return false + const path = `/${(pathSuffix ?? "").replace(/^\/+|\/+$/g, "")}` + return /^\/(?:permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) + || /^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) + || /^\/(?:api\/)?session\/[^/]+\/(?:abort|permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) +} + +export class WorkspaceMutationConflictError extends Error { + constructor(message: string) { + super(message) + this.name = "WorkspaceMutationConflictError" + } +} + +interface MutationWorkspaceState { + port: number + authorization?: string + hostDirectory: string + nativeRootDirectory: string +} + +interface MutationSession { + id: string + workspaceID?: string + directory?: string +} + +async function retryRelease(release: () => void | Promise): Promise { + let failure: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await release() + return + } catch (error) { + failure = error + } + } + throw failure +} + +function waitForReleaseRetry(): Promise { + return new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)) +} + +async function releaseUntilSuccessful(release: () => void | Promise): Promise { + while (true) { + try { + await retryRelease(release) + return + } catch { + await waitForReleaseRetry() + } + } +} + +export async function admitWorkspaceMutation(params: { + gate: Pick + workspaceId: string + method?: string + pathSuffix?: string + rawUrl?: string + resolveWorkspace: () => Promise + loadSessions: () => Promise + loadWorkspaces: () => Promise + sessionContext?: string + signal?: AbortSignal +}): Promise Promise }> { + const control = isInstanceControlMutation(params.method, params.pathSuffix) + const releaseInstance = control + ? await params.gate.enterControl(params.workspaceId, params.signal) + : await params.gate.enter(params.workspaceId, params.signal) + let releaseRepository: (() => Promise) | undefined + try { + const queued = await params.resolveWorkspace() + if (!queued) throw new WorkspaceMutationConflictError("Workspace changed while the mutation was queued") + if (!control) { + const repository = await acquireRepositoryMutation({ + workspaceFolder: queued.hostDirectory, + gate: params.gate, + signal: params.signal, + }) + releaseRepository = repository.release + } + const current = await params.resolveWorkspace() + if (!current || current.hostDirectory !== queued.hostDirectory + || current.nativeRootDirectory !== queued.nativeRootDirectory) { + throw new WorkspaceMutationConflictError("Workspace changed while the mutation was queued") + } + params.signal?.throwIfAborted() + const location = await authoritativeMutationLocation({ + pathSuffix: params.pathSuffix, + rawUrl: params.rawUrl, + rootDirectory: current.nativeRootDirectory, + sessionContext: params.sessionContext, + loadSessions: params.loadSessions, + loadWorkspaces: params.loadWorkspaces, + }) + return { + ...current, + ...location, + release: async () => { + releaseInstance() + await releaseRepository?.() + }, + } + } catch (error) { + releaseInstance() + if (releaseRepository) await releaseRepository() + throw error + } +} + +async function authoritativeMutationLocation(params: { + pathSuffix?: string + rawUrl?: string + rootDirectory: string + sessionContext?: string + loadSessions: () => Promise + loadWorkspaces: () => Promise +}): Promise<{ search: string; directory: string; workspaceId: string | null }> { + const queryIndex = (params.rawUrl ?? "").indexOf("?") + const search = new URLSearchParams(queryIndex >= 0 ? (params.rawUrl ?? "").slice(queryIndex + 1) : "") + const match = /^\/?(?:api\/)?session\/([^/]+)(?:\/|$)/.exec(params.pathSuffix ?? "") + const pathSessionId = match ? decodeURIComponent(match[1]) : undefined + if (pathSessionId && params.sessionContext && pathSessionId !== params.sessionContext) { + throw new WorkspaceMutationConflictError("Mutation session context does not match the request path") + } + const sessionId = pathSessionId ?? params.sessionContext + if (!sessionId) { + const selectedWorkspace = search.get("workspace") + if (selectedWorkspace) { + const workspaces = await params.loadWorkspaces() + const workspace = workspaces.find((candidate) => candidate.id === selectedWorkspace) + if (!workspace?.directory) { + throw new WorkspaceMutationConflictError("Queued mutation workspace is no longer available") + } + search.set("directory", workspace.directory) + const value = search.toString() + return { search: value ? `?${value}` : "", directory: workspace.directory, workspaceId: workspace.id } + } + search.set("directory", params.rootDirectory) + const value = search.toString() + return { search: value ? `?${value}` : "", directory: params.rootDirectory, workspaceId: null } + } + + const sessions = await params.loadSessions() + const workspaces = await params.loadWorkspaces() + if (sessions.length >= SESSION_LIST_LIMIT) { + throw new WorkspaceMutationConflictError("Unable to verify the complete project session inventory") + } + const matches = sessions.filter((session) => session.id === sessionId) + if (matches.length !== 1) { + throw new WorkspaceMutationConflictError("Queued mutation session location is missing or ambiguous") + } + let location + try { + location = resolveNativeSessionLocation(params.rootDirectory, workspaces, matches[0]) + } catch (error) { + throw new WorkspaceMutationConflictError(error instanceof Error ? error.message : "Unable to resolve mutation session") + } + if (location.workspaceId) search.set("workspace", location.workspaceId) + else search.delete("workspace") + search.set("directory", location.directory) + const value = search.toString() + return { + search: value ? `?${value}` : "", + directory: location.directory, + workspaceId: location.workspaceId, + } +} + +export class ProxyMutationTracker { + private readonly active = new Set() + private readonly drained = new Set<() => void>() + private closingReason?: Error + + track(controller: AbortController): () => void { + this.active.add(controller) + if (this.closingReason) controller.abort(this.closingReason) + return () => { + this.active.delete(controller) + if (this.active.size === 0) { + for (const resolve of this.drained) resolve() + this.drained.clear() + } + } + } + + async abortAndDrain(reason: Error, timeoutMs = 10_000): Promise { + this.closingReason = reason + for (const controller of this.active) controller.abort(reason) + if (this.active.size === 0) return + let timeout: ReturnType | undefined + try { + await Promise.race([ + new Promise((resolve) => this.drained.add(resolve)), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out draining upstream mutations after ${timeoutMs}ms`)), timeoutMs) + }), + ]) + } finally { + if (timeout) clearTimeout(timeout) + } + } +} + +export async function openUpstreamMutation(params: { + start: (signal: AbortSignal) => Promise + release: () => void | Promise + timeoutMs?: number + downstreamSignal?: AbortSignal + tracker?: ProxyMutationTracker +}): Promise { + const controller = new AbortController() + const abortForDisconnect = () => controller.abort(params.downstreamSignal?.reason) + params.downstreamSignal?.addEventListener("abort", abortForDisconnect, { once: true }) + let timeout: ReturnType | undefined + let untrack = () => {} + let settlement: Promise | undefined + const settle = (): Promise => { + if (settlement) return settlement + settlement = Promise.resolve().then(async () => { + if (timeout) clearTimeout(timeout) + params.downstreamSignal?.removeEventListener("abort", abortForDisconnect) + try { + await releaseUntilSuccessful(params.release) + } finally { + untrack() + } + }) + return settlement + } + untrack = params.tracker?.track(controller) ?? untrack + if (params.timeoutMs !== undefined) { + timeout = setTimeout(() => controller.abort(new Error("Instance mutation proxy timed out")), params.timeoutMs) + } + if (params.downstreamSignal?.aborted) abortForDisconnect() + try { + const upstream = await params.start(controller.signal) + holdMutationLeaseUntilUpstreamSettles(upstream.body, () => { void settle() }) + return upstream + } catch (error) { + await settle() + throw error + } +} diff --git a/packages/server/src/server/instance-workspace-list-proxy.test.ts b/packages/server/src/server/instance-workspace-list-proxy.test.ts new file mode 100644 index 000000000..f3673ec70 --- /dev/null +++ b/packages/server/src/server/instance-workspace-list-proxy.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict" +import http from "node:http" +import { describe, it } from "node:test" +import replyFrom from "@fastify/reply-from" +import Fastify from "fastify" +import type { Logger } from "../logger" +import type { WorkspaceManager } from "../workspaces/manager" +import { InstanceMutationGate } from "./instance-mutation-gate" +import { ProxyMutationTracker } from "./instance-mutation-proxy" +import { registerInstanceProxyRoutes } from "./http-server" + +describe("instance workspace-list proxy", () => { + for (const [name, hostDirectory, nativeDirectory] of [ + ["default DrvFS", String.raw`C:\Repo`, "/mnt/c/Repo"], + ["custom DrvFS", String.raw`C:\Repo`, "/windows/c/Repo"], + ["WSL UNC /home", String.raw`\\wsl.localhost\Ubuntu\home\Dev\Repo`, "/home/Dev/Repo"], + ] as const) { + it(`scopes workspace-list GET in native coordinates for ${name}`, async () => { + await withUpstream(async (port, requests) => { + const app = Fastify({ logger: false }) + await app.register(replyFrom) + registerInstanceProxyRoutes(app, { + workspaceManager: { + get: () => ({ id: "workspace", path: hostDirectory, status: "ready" }), + getInstancePort: () => port, + getInstanceAuthorizationHeader: () => undefined, + resolveInstanceDirectory: async () => nativeDirectory, + } as unknown as WorkspaceManager, + logger: stubLogger(), + mutationGate: new InstanceMutationGate(), + proxyMutations: new ProxyMutationTracker(), + shutdownSignal: new AbortController().signal, + }) + + try { + const response = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/experimental/workspace?directory=${encodeURIComponent(hostDirectory)}&limit=7`, + }) + assert.equal(response.statusCode, 200) + const workspaceList = new URL(requests[0], "http://127.0.0.1") + assert.equal(workspaceList.pathname, "/experimental/workspace") + assert.equal(workspaceList.searchParams.get("directory"), nativeDirectory) + assert.equal(workspaceList.searchParams.get("limit"), "7") + + await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/session?directory=${encodeURIComponent(hostDirectory)}`, + }) + const ordinaryGet = new URL(requests[1], "http://127.0.0.1") + assert.equal(ordinaryGet.searchParams.get("directory"), hostDirectory) + } finally { + await app.close() + } + }) + }) + } +}) + +async function withUpstream(operation: (port: number, requests: string[]) => Promise): Promise { + const requests: string[] = [] + const server = http.createServer((request, response) => { + requests.push(request.url ?? "") + response.writeHead(200, { "content-type": "application/json" }).end("[]") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + try { + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing upstream port") + await operation(address.port, requests) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + } +} + +function stubLogger(): Logger { + const logger = { + debug() {}, error() {}, trace() {}, isLevelEnabled: () => false, + child: () => logger, + } + return logger as unknown as Logger +} diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts index e115c5b7a..904889f6e 100644 --- a/packages/server/src/server/routes/workspaces.test.ts +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -1,10 +1,14 @@ import assert from "node:assert/strict" +import { mkdtempSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" import { describe, it } from "node:test" import Fastify from "fastify" import type { WorkspaceDescriptor } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorkspaceRoutes } from "./workspaces" +import { InstanceMutationGate } from "../instance-mutation-gate" describe("workspace routes", () => { it("forwards a validated explicit binary path when creating a workspace", async () => { @@ -31,7 +35,7 @@ describe("workspace routes", () => { calls.push(["cancel", requestId]) }, } as unknown as WorkspaceManager - registerWorkspaceRoutes(app, { workspaceManager }) + registerWorkspaceRoutes(app, { workspaceManager, mutationGate: new InstanceMutationGate() }) const response = await app.inject({ method: "POST", @@ -46,11 +50,14 @@ describe("workspace routes", () => { }) assert.equal(response.statusCode, 201) - assert.deepEqual(calls, [["C:/work", "Work", { + assert.deepEqual(calls[0]?.slice(0, 2), ["C:/work", "Work"]) + assert.deepEqual({ ...(calls[0]?.[2] as Record), signal: undefined }, { binaryPath: "C:/tools/opencode.exe", requestId: "restore-request", forceNew: true, - }]]) + signal: undefined, + }) + assert.ok((calls[0]?.[2] as { signal?: unknown }).signal instanceof AbortSignal) const released = await app.inject({ method: "POST", @@ -103,7 +110,7 @@ describe("workspace routes", () => { return true }, } as unknown as WorkspaceManager - registerWorkspaceRoutes(app, { workspaceManager }) + registerWorkspaceRoutes(app, { workspaceManager, mutationGate: new InstanceMutationGate() }) const cancellation = app.inject({ method: "POST", @@ -123,4 +130,32 @@ describe("workspace routes", () => { assert.equal((await cancellation).statusCode, 204) await app.close() }) + + it("rejects every clone into an occupied destination, including an existing empty folder", async () => { + const app = Fastify({ logger: false }) + const destinationPath = mkdtempSync(path.join(os.tmpdir(), "codenomad-clone-occupied-")) + const workspaceManager = { + hasWorkspaceBlocker: async () => true, + } as unknown as WorkspaceManager + registerWorkspaceRoutes(app, { workspaceManager, mutationGate: new InstanceMutationGate() }) + + try { + for (const cleanup of [undefined, true]) { + const response = await app.inject({ + method: "POST", + url: "/api/workspaces/clone", + payload: { + repositoryUrl: "https://example.invalid/repository.git", + destinationPath, + ...(cleanup ? { cleanup } : {}), + }, + }) + assert.equal(response.statusCode, 409) + assert.match(response.json().error, /live workspace/i) + } + } finally { + await app.close() + rmSync(destinationPath, { recursive: true, force: true }) + } + }) }) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index e7f052136..10d3bd271 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,4 +1,4 @@ -import { FastifyInstance, FastifyReply } from "fastify" +import { FastifyInstance, FastifyReply, type FastifyRequest } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" @@ -6,11 +6,17 @@ import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageW import { cloneGitRepository, isGitCloneError } from "../../workspaces/git-clone" import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory" +import { enterWorkspaceMutationAdmission, type InstanceMutationGate } from "../instance-mutation-gate" +import { withRepositoryMutation } from "../../workspaces/repository-mutation-lock" interface RouteDeps { workspaceManager: WorkspaceManager + mutationGate: InstanceMutationGate + shutdownSignal?: AbortSignal } +class WorkspaceMutationConflictError extends Error {} + const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), @@ -67,18 +73,39 @@ const WorkspaceFileSearchQuerySchema = z.object({ .transform((value) => (value === undefined ? undefined : value === "true")), }) +function disconnectSignal(request: FastifyRequest, reply: FastifyReply, shutdownSignal?: AbortSignal) { + const controller = new AbortController() + const abort = () => controller.abort(new Error("Mutation request disconnected")) + const close = () => { if (!reply.raw.writableEnded) abort() } + const shutdown = () => controller.abort(shutdownSignal?.reason) + request.raw.once("aborted", abort) + reply.raw.once("close", close) + shutdownSignal?.addEventListener("abort", shutdown, { once: true }) + if (shutdownSignal?.aborted) shutdown() + return { + signal: controller.signal, + cleanup: () => { + request.raw.removeListener("aborted", abort) + reply.raw.removeListener("close", close) + shutdownSignal?.removeEventListener("abort", shutdown) + }, + } +} + export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/workspaces", async () => { return deps.workspaceManager.list() }) app.post("/api/workspaces", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) const result = await deps.workspaceManager.create(body.path, body.name, { binaryPath: body.binaryPath, requestId: body.requestId, forceNew: body.forceNew, + signal: cancellation.signal, }) reply.code(201) return result.created ? result.workspace : { ...result.workspace, reused: true as const } @@ -86,17 +113,32 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { request.log.error({ err: error }, "Failed to create workspace") const message = error instanceof Error ? error.message : "Failed to create workspace" reply.code(400).type("text/plain").send(message) + } finally { + cancellation.cleanup() } }) app.post("/api/workspaces/clone", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorkspaceCloneSchema.parse(request.body ?? {}) - const result = await cloneGitRepository(body) + const result = await withRepositoryMutation({ + workspaceFolder: body.destinationPath, + gate: deps.mutationGate, + signal: cancellation.signal, + operation: async () => { + if (await deps.workspaceManager.hasWorkspaceBlocker(body.destinationPath)) { + throw new WorkspaceMutationConflictError("Clone destination is used by a live workspace") + } + return cloneGitRepository(body) + }, + }) reply.code(201) return result } catch (error) { return handleWorkspaceError(error, reply) + } finally { + cancellation.cleanup() } }) @@ -110,8 +152,13 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { - await deps.workspaceManager.delete(request.params.id) - reply.code(204) + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) + try { + await deps.workspaceManager.delete(request.params.id, cancellation.signal) + reply.code(204) + } finally { + cancellation.cleanup() + } }) app.post("/api/workspaces/creation/cancel", async (request, reply) => { @@ -187,20 +234,25 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { Params: { id: string } Querystring: { path?: string; worktree?: string } }>("/api/workspaces/:id/files/content", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) const body = WorkspaceFileContentBodySchema.parse(request.body ?? {}) - if (query.worktree && query.worktree !== "root") { - const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply) - if (!directory) return - deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents) + return await withWorkspaceMutation(deps, request.params.id, cancellation.signal, async () => { + if (query.worktree && query.worktree !== "root") { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply) + if (!directory) return + deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents) + reply.code(204) + return + } + deps.workspaceManager.writeFile(request.params.id, query.path, body.contents) reply.code(204) - return - } - deps.workspaceManager.writeFile(request.params.id, query.path, body.contents) - reply.code(204) + }) } catch (error) { return handleWorkspaceError(error, reply) + } finally { + cancellation.cleanup() } }) @@ -241,15 +293,19 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { Params: { id: string; slug: string } Body: { paths: string[] } }>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) - const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) - if (!directory) return - - await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) - return { ok: true as const } + return await withWorkspaceMutation(deps, request.params.id, cancellation.signal, async () => { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + }) } catch (error) { return handleWorkspaceError(error, reply) + } finally { + cancellation.cleanup() } }) @@ -257,15 +313,19 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { Params: { id: string; slug: string } Body: { paths: string[] } }>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) - const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) - if (!directory) return - - await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) - return { ok: true as const } + return await withWorkspaceMutation(deps, request.params.id, cancellation.signal, async () => { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + }) } catch (error) { return handleWorkspaceError(error, reply) + } finally { + cancellation.cleanup() } }) @@ -273,19 +333,43 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { Params: { id: string; slug: string } Body: { message: string } }>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => { + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorktreeGitCommitBodySchema.parse(request.body ?? {}) - const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) - if (!directory) return - - const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) - return { ok: true as const, ...result } + return await withWorkspaceMutation(deps, request.params.id, cancellation.signal, async () => { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) + return { ok: true as const, ...result } + }) } catch (error) { return handleWorkspaceError(error, reply) + } finally { + cancellation.cleanup() } }) } +async function withWorkspaceMutation( + deps: RouteDeps, + workspaceId: string, + signal: AbortSignal, + operation: () => Promise, +): Promise { + const release = await enterWorkspaceMutationAdmission( + deps.mutationGate, + workspaceId, + async () => deps.workspaceManager.get(workspaceId)?.path, + signal, + ) + try { + signal.throwIfAborted() + return await operation() + } finally { + await release() + } +} + async function resolveGitWorktreeDirectory( workspaceManager: WorkspaceManager, workspaceId: string, @@ -331,6 +415,10 @@ async function resolveGitWorktreeDirectory( function handleWorkspaceError(error: unknown, reply: FastifyReply) { + if (error instanceof WorkspaceMutationConflictError) { + reply.code(409) + return { error: error.message } + } if (isGitCloneError(error)) { reply.code(error.statusCode) return { error: error.message } diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts new file mode 100644 index 000000000..ce0633d94 --- /dev/null +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -0,0 +1,385 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import Fastify from "fastify" +import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata" +import { InstanceMutationGate } from "../instance-mutation-gate" +import type { WorkspaceManager } from "../../workspaces/manager" +import { registerWorktreeRoutes } from "./worktrees" +import { repositoryMutationKey } from "../../workspaces/workspace-identity" +import { WorktreeRollbackIncompleteError } from "../../workspaces/worktree-session-move" + +function setup(options: { + metadataError?: Error + resolveNativeDirectory?: (directory: string, root: string) => string +} = {}) { + const folder = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-routes-")) + execFileSync("git", ["init", "-b", "main"], { cwd: folder }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: folder }) + execFileSync("git", ["config", "user.name", "Test"], { cwd: folder }) + writeFileSync(path.join(folder, "README.md"), "test\n") + execFileSync("git", ["add", "README.md"], { cwd: folder }) + execFileSync("git", ["commit", "-m", "initial"], { cwd: folder }) + + const app = Fastify({ logger: false }) + const gate = new InstanceMutationGate() + const metadataWrites: Array = [] + const siblingFolder = path.join(folder, "nested") + mkdirSync(siblingFolder) + let workspaces = [{ id: "workspace", path: folder, status: "ready" }] + let getCalls = 0 + const repositoryKey = repositoryMutationKey(path.join(folder, ".git")) + const workspaceManager = { + get: (id: string) => { getCalls += 1; return workspaces.find((workspace) => workspace.id === id) }, + list: () => workspaces, + getRepositoryMutationKey: (id: string) => workspaces.some((workspace) => workspace.id === id) + ? repositoryKey + : undefined, + hasRepositoryBlocker: (key: string, excludingId?: string) => key === repositoryKey + && workspaces.some((workspace) => workspace.id !== excludingId), + resolveInstanceDirectory: async (_id: string, directory = folder) => ( + options.resolveNativeDirectory?.(directory, folder) ?? directory + ), + } as unknown as WorkspaceManager + const sessionMetadataPersistence = { + hasProjectSession: async () => { + if (options.metadataError) throw options.metadataError + return true + }, + setWorktreeSlug: async (_id: string, _sessionId: string, slug: string | null) => { + metadataWrites.push(slug) + return {} + }, + } as unknown as OpencodeYoloPersistence + registerWorktreeRoutes(app, { workspaceManager, sessionMetadataPersistence, mutationGate: gate }) + return { + app, + folder, + gate, + repositoryKey, + metadataWrites, + addSibling: () => { + workspaces = [...workspaces, { id: "workspace-2", path: siblingFolder, status: "ready" }] + }, + removeWorkspace: () => { workspaces = workspaces.filter((workspace) => workspace.id !== "workspace") }, + getCalls: () => getCalls, + close: async () => { + await app.close() + rmSync(folder, { recursive: true, force: true }) + }, + } +} + +describe("worktree mutation routes", () => { + for (const [name, nativeRoot] of [ + ["default DrvFS", "/mnt/c/Repo"], + ["custom DrvFS", "/windows/c/Repo"], + ["WSL UNC /home", "/home/Dev/Repo"], + ] as const) { + it(`lists host and native root/feature directories for ${name}`, async () => { + const test = setup({ + resolveNativeDirectory: (directory, root) => { + const relative = path.relative(root, directory).replace(/\\/g, "/") + return relative ? `${nativeRoot}/${relative}` : nativeRoot + }, + }) + try { + const created = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + assert.equal(created.statusCode, 201) + const response = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees" }) + assert.equal(response.statusCode, 200) + const worktrees = response.json().worktrees as Array<{ slug: string; directory: string; nativeDirectory: string }> + const root = worktrees.find((worktree) => worktree.slug === "root") + const feature = worktrees.find((worktree) => worktree.slug === "feature") + assert.equal(root?.directory, test.folder) + assert.equal(root?.nativeDirectory, nativeRoot) + assert.equal(path.resolve(feature?.directory ?? ""), path.resolve(created.json().directory)) + assert.equal(feature?.nativeDirectory, `${nativeRoot}/.codenomad/worktrees/feature`) + } finally { + await test.close() + } + }) + } + + it("gates binding writes and rejects missing non-null slugs while admitted", async () => { + const test = setup() + try { + const created = await test.app.inject({ method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" } }) + assert.equal(created.statusCode, 201) + let release!: () => void + let started!: () => void + const pending = new Promise((resolve) => { release = resolve }) + const exclusiveStarted = new Promise((resolve) => { started = resolve }) + const exclusive = test.gate.exclusive("workspace", async () => { started(); await pending }) + await exclusiveStarted + let settled = false + const binding = test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/sessions/session", + payload: { worktreeSlug: "feature" }, + }).then((response) => { settled = true; return response }) + await Promise.resolve() + assert.equal(settled, false) + release() + await exclusive + assert.equal((await binding).statusCode, 200) + const missing = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/sessions/session", + payload: { worktreeSlug: "missing" }, + }) + assert.equal(missing.statusCode, 404) + assert.deepEqual(test.metadataWrites, ["feature"]) + } finally { + await test.close() + } + }) + + it("serializes creation behind an exclusive deletion-cleanup lane", async () => { + const test = setup() + try { + let release!: () => void + let started!: () => void + const pending = new Promise((resolve) => { release = resolve }) + const exclusiveStarted = new Promise((resolve) => { started = resolve }) + const cleanup = test.gate.exclusive(test.repositoryKey, async () => { started(); await pending }) + await exclusiveStarted + let settled = false + const creation = test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }).then((response) => { settled = true; return response }) + await Promise.resolve() + assert.equal(settled, false) + release() + await cleanup + assert.equal((await creation).statusCode, 201) + } finally { + await test.close() + } + }) + + it("types duplicate worktree creation as a conflict", async () => { + const test = setup() + try { + const first = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + const duplicate = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + assert.equal(first.statusCode, 201) + assert.equal(duplicate.statusCode, 409) + } finally { + await test.close() + } + }) + + for (const operation of ["create", "map"] as const) { + it(`refuses a stale instance queued for ${operation}`, async () => { + const test = setup() + try { + let release!: () => void + let markStarted!: () => void + const held = new Promise((resolve) => { release = resolve }) + const started = new Promise((resolve) => { markStarted = resolve }) + const exclusive = test.gate.exclusive("workspace", async () => { markStarted(); await held }) + await started + const request = test.app.inject(operation === "create" ? { + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + } : { + method: "PUT", url: "/api/workspaces/workspace/worktrees/map", + payload: { version: 1, revision: 0, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }, + }) + while (test.getCalls() === 0) await new Promise((resolve) => setImmediate(resolve)) + test.removeWorkspace() + release() + await exclusive + + assert.equal((await request).statusCode, 409) + assert.equal(existsSync(path.join(test.folder, ".git", "worktrees", "feature")), false) + } finally { + await test.close() + } + }) + } + + it("rejects a stale full-map update without restoring concurrent bindings", async () => { + const test = setup() + try { + assert.equal((await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + })).statusCode, 201) + const first = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/map", + payload: { + version: 1, + revision: 0, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { first: "feature" }, + }, + }) + assert.equal(first.statusCode, 200) + assert.equal(first.json().revision, 1) + const stale = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/map", + payload: { + version: 1, + revision: 0, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { stale: "feature" }, + }, + }) + assert.equal(stale.statusCode, 409) + const current = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees/map" }) + assert.deepEqual(current.json().parentSessionWorktreeSlug, { first: "feature" }) + } finally { + await test.close() + } + }) + + it("types map references to removed worktrees as a conflict", async () => { + const test = setup() + try { + const response = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/map", + payload: { + version: 1, + revision: 0, + defaultWorktreeSlug: "missing", + parentSessionWorktreeSlug: {}, + }, + }) + assert.equal(response.statusCode, 409) + } finally { + await test.close() + } + }) + + it("rejects deletion when another live instance shares the canonical repository", async () => { + const test = setup() + try { + assert.equal((await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + })).statusCode, 201) + test.addSibling() + const response = await test.app.inject({ + method: "DELETE", url: "/api/workspaces/workspace/worktrees/feature", + }) + assert.equal(response.statusCode, 409) + assert.match(response.json().error, /another live workspace/i) + } finally { + await test.close() + } + }) + + it("serializes map CAS across sibling instance ids sharing a repository", async () => { + const test = setup() + try { + assert.equal((await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + })).statusCode, 201) + test.addSibling() + let release!: () => void + let started!: () => void + const pending = new Promise((resolve) => { release = resolve }) + const held = new Promise((resolve) => { started = resolve }) + const lock = test.gate.exclusive(test.repositoryKey, async () => { started(); await pending }) + await held + let settled = false + const first = test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace-2/worktrees/map", + payload: { + version: 1, revision: 0, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { first: "feature" }, + }, + }).then((response) => { settled = true; return response }) + await Promise.resolve() + assert.equal(settled, false) + release() + await lock + assert.equal((await first).statusCode, 200) + const stale = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/map", + payload: { + version: 1, revision: 0, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { stale: "feature" }, + }, + }) + assert.equal(stale.statusCode, 409) + } finally { + await test.close() + } + }) + + it("preserves malformed map contents instead of overwriting fallback state", async () => { + const test = setup() + try { + const mapPath = path.join(test.folder, ".codenomad", "worktreeMap.json") + mkdirSync(path.dirname(mapPath), { recursive: true }) + writeFileSync(mapPath, "{ malformed", "utf-8") + const read = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees/map" }) + assert.equal(read.statusCode, 500) + assert.match(read.json().error, /malformed worktree map/i) + const response = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/map", + payload: { version: 1, revision: 0, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }, + }) + assert.equal(response.statusCode, 500) + assert.equal(readFileSync(mapPath, "utf-8"), "{ malformed") + } finally { + await test.close() + } + }) + + it("returns a typed conflict when linked map sources disagree", async () => { + const test = setup() + try { + const created = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + assert.equal(created.statusCode, 201) + const canonicalPath = path.join(test.folder, ".codenomad", "worktreeMap.json") + const legacyPath = path.join(created.json().directory, ".codenomad", "worktreeMap.json") + mkdirSync(path.dirname(canonicalPath), { recursive: true }) + mkdirSync(path.dirname(legacyPath), { recursive: true }) + writeFileSync(canonicalPath, JSON.stringify({ + version: 1, revision: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { session: "root" }, + })) + writeFileSync(legacyPath, JSON.stringify({ + version: 1, revision: 2, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { session: "feature" }, + })) + + const response = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees/map" }) + assert.equal(response.statusCode, 409) + assert.match(response.json().error, /conflicting worktree binding/i) + } finally { + await test.close() + } + }) + + it("returns 500 for typed rollback-incomplete failures", async () => { + const test = setup({ + metadataError: new WorktreeRollbackIncompleteError([new Error("restore failed")], "Rollback was incomplete"), + }) + try { + const response = await test.app.inject({ + method: "PUT", + url: "/api/workspaces/workspace/worktrees/sessions/session", + payload: { worktreeSlug: null }, + }) + assert.equal(response.statusCode, 500) + assert.match(response.json().error, /rollback was incomplete/i) + } finally { + await test.close() + } + }) +}) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index d48d9ddb1..6c4fe8624 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance, FastifyReply } from "fastify" +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" import { @@ -6,19 +6,39 @@ import { listWorktrees, isValidWorktreeSlug, createManagedWorktree, - removeWorktree, + WorktreeDeletionConflictError, + WorktreeCreationConflictError, } from "../../workspaces/git-worktrees" -import type { WorktreeListResponse, WorktreeMap } from "../../api-types" +import type { WorktreeListResponse, WorktreeMap, WorktreeSessionMoveResponse, WorkspaceDescriptor } from "../../api-types" import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata" -import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map" +import { + ensureCodenomadGitExclude, + readWorktreeMapStrict, + WorktreeMapConflictError, + WorktreeMapReadError, + WorktreeMapRevisionConflictError, + writeWorktreeMap, +} from "../../workspaces/worktree-map" +import { createInstanceClient } from "../../workspaces/instance-client" +import { deleteWorktreeTransaction } from "../../workspaces/worktree-deletion" +import { + moveSessionFamilyTransaction, + WorktreeRollbackIncompleteError, + WorktreeSessionBusyError, +} from "../../workspaces/worktree-session-move" +import type { InstanceMutationGate } from "../instance-mutation-gate" +import { withRepositoryMutation } from "../../workspaces/repository-mutation-lock" interface RouteDeps { workspaceManager: WorkspaceManager sessionMetadataPersistence: OpencodeYoloPersistence + mutationGate: InstanceMutationGate + shutdownSignal?: AbortSignal } const WorktreeMapSchema = z.object({ version: z.literal(1), + revision: z.number().int().nonnegative().default(0), defaultWorktreeSlug: z.string().min(1).default("root"), parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}), }) @@ -28,30 +48,120 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) -const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) +const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug).nullable() }) +const WorktreeSessionMoveSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) + +class WorktreeMapUpdateConflictError extends Error {} +class WorktreeRepositoryInUseError extends Error {} +class WorktreeWorkspaceChangedError extends Error {} +class WorktreeNonGitError extends Error {} + +async function withRepositoryExclusive( + deps: RouteDeps, + workspaceId: string, + operation: (repositoryKey: string) => Promise, + signal?: AbortSignal, +): Promise { + const workspace = deps.workspaceManager.get(workspaceId) + if (!workspace) throw new WorktreeWorkspaceChangedError("Workspace changed while the operation was queued") + return withRepositoryMutation({ + workspaceFolder: workspace.path, + gate: deps.mutationGate, + signal, + operation: async (key) => { + const current = deps.workspaceManager.get(workspaceId) + if (!current || current.path !== workspace.path) { + throw new WorktreeWorkspaceChangedError("Workspace changed while the operation was queued") + } + return operation(key) + }, + }) +} + +async function withWorkspaceRepositoryExclusive( + deps: RouteDeps, + workspaceId: string, + operation: (workspace: WorkspaceDescriptor) => Promise, + signal?: AbortSignal, +): Promise { + const releaseInstance = await deps.mutationGate.enter(workspaceId, signal) + try { + const workspace = deps.workspaceManager.get(workspaceId) + if (!workspace) throw new WorktreeWorkspaceChangedError("Workspace changed while the operation was queued") + return withRepositoryExclusive(deps, workspaceId, async () => { + const current = deps.workspaceManager.get(workspaceId) + if (!current || current.path !== workspace.path) { + throw new WorktreeWorkspaceChangedError("Workspace changed while the operation was queued") + } + return operation(current) + }, signal) + } finally { + releaseInstance() + } +} + +function disconnectSignal(request: FastifyRequest, reply: FastifyReply, shutdownSignal?: AbortSignal) { + const controller = new AbortController() + const abort = () => controller.abort(new Error("Mutation request disconnected")) + const close = () => { if (!reply.raw.writableEnded) abort() } + const shutdown = () => controller.abort(shutdownSignal?.reason) + request.raw.once("aborted", abort) + reply.raw.once("close", close) + shutdownSignal?.addEventListener("abort", shutdown, { once: true }) + if (shutdownSignal?.aborted) shutdown() + return { + signal: controller.signal, + cleanup: () => { + request.raw.removeListener("aborted", abort) + reply.raw.removeListener("close", close) + shutdownSignal?.removeEventListener("abort", shutdown) + }, + } +} export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { app.put<{ Params: { id: string; sessionId: string }; Body: unknown }>( "/api/workspaces/:id/worktrees/sessions/:sessionId", async (request, reply) => { - if (!deps.workspaceManager.get(request.params.id)) { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { reply.code(404) return { error: "Workspace not found" } } + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) + const releaseMutation = await deps.mutationGate.enter(workspace.id, cancellation.signal) try { const body = WorktreeSessionSchema.parse(request.body) - if (!await deps.sessionMetadataPersistence.hasProjectSession(request.params.id, request.params.sessionId)) { - reply.code(404) - return { error: "Session not found" } - } - const metadata = await deps.sessionMetadataPersistence.setWorktreeSlug( - request.params.id, - request.params.sessionId, - body.worktreeSlug, - ) - return { metadata } + return await withRepositoryExclusive(deps, workspace.id, async () => { + const { repoRoot } = await resolveRepoRoot(workspace.path, request.log) + if (body.worktreeSlug) { + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + if (!worktrees.some((worktree) => worktree.slug === body.worktreeSlug)) { + reply.code(404) + return { error: "Worktree not found" } + } + } + if (!await deps.sessionMetadataPersistence.hasProjectSession( + request.params.id, + request.params.sessionId, + { alreadyAdmitted: true }, + )) { + reply.code(404) + return { error: "Session not found" } + } + const metadata = await deps.sessionMetadataPersistence.setWorktreeSlug( + request.params.id, + request.params.sessionId, + body.worktreeSlug, + { alreadyAdmitted: true }, + ) + return { metadata } + }, cancellation.signal) } catch (error) { return handleError(error, reply) + } finally { + releaseMutation() + cancellation.cleanup() } }, ) @@ -64,7 +174,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const hostWorktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await Promise.all(hostWorktrees.map(async (worktree) => ({ + ...worktree, + nativeDirectory: await deps.workspaceManager.resolveInstanceDirectory(workspace.id, worktree.directory), + }))) const response: WorktreeListResponse = { worktrees, isGitRepo } return response }) @@ -75,6 +189,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(404) return { error: "Workspace not found" } } + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const body = WorktreeCreateSchema.parse(request.body ?? {}) @@ -94,92 +209,127 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } } - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - if (!isGitRepo) { - reply.code(400) - return { error: "Workspace is not a Git repository" } - } - - await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined) - - const created = await createManagedWorktree({ - repoRoot, - workspaceFolder: workspace.path, - slug, - logger: request.log, - }) + const created = await withWorkspaceRepositoryExclusive(deps, workspace.id, async (current) => { + const { repoRoot, isGitRepo } = await resolveRepoRoot(current.path, request.log) + if (!isGitRepo) throw new WorktreeNonGitError("Workspace is not a Git repository") + await ensureCodenomadGitExclude(current.path, request.log).catch(() => undefined) + return createManagedWorktree({ + repoRoot, + workspaceFolder: current.path, + slug, + logger: request.log, + }) + }, cancellation.signal) reply.code(201) return created } catch (error) { return handleError(error, reply) + } finally { + cancellation.cleanup() } }) app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>( "/api/workspaces/:id/worktrees/:slug", async (request, reply) => { - const workspace = deps.workspaceManager.get(request.params.id) - if (!workspace) { - reply.code(404) - return { error: "Workspace not found" } - } + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } - const slug = (request.params.slug ?? "").trim() - if (!isValidWorktreeSlug(slug) || slug === "root") { - reply.code(400) - return { error: "Invalid worktree slug" } - } + const slug = (request.params.slug ?? "").trim() + if (!isValidWorktreeSlug(slug) || slug === "root") { + reply.code(400) + return { error: "Invalid worktree slug" } + } - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - if (!isGitRepo) { - reply.code(400) - return { error: "Workspace is not a Git repository" } - } + const force = (request.query?.force ?? "").toString().toLowerCase() === "true" + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) - const force = (request.query?.force ?? "").toString().toLowerCase() === "true" + try { + await deps.mutationGate.exclusive(workspace.id, async () => { + await withRepositoryExclusive(deps, workspace.id, async (repoKey) => { + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new WorktreeNonGitError("Workspace is not a Git repository") + if (await deps.workspaceManager.hasRepositoryBlocker(repoKey, workspace.id)) { + throw new WorktreeRepositoryInUseError("Another live workspace instance uses this Git repository") + } + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const match = worktrees.find((wt) => wt.slug === slug) + if (!match || match.kind === "root") throw new Error("Worktree not found") + const nativeWorkspaceFolder = await deps.workspaceManager.resolveInstanceDirectory(workspace.id) + const client = createInstanceClient(deps.workspaceManager, workspace.id, { directory: nativeWorkspaceFolder }) + if (!client) throw new Error("Workspace instance is not ready") + await deleteWorktreeTransaction({ + instanceId: workspace.id, + workspaceFolder: workspace.path, + nativeWorkspaceFolder, + nativeTargetDirectory: await deps.workspaceManager.resolveInstanceDirectory(workspace.id, match.directory), + target: match, + force, + client, + sessionMetadataPersistence: deps.sessionMetadataPersistence, + logger: request.log, + signal: cancellation.signal, + }) + }, cancellation.signal) + }, cancellation.signal) - try { - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) - const match = worktrees.find((wt) => wt.slug === slug) - if (!match || match.kind === "root") { - reply.code(404) - return { error: "Worktree not found" } + reply.code(204) + } catch (error) { + return handleError(error, reply) + } finally { + cancellation.cleanup() } + }, + ) - await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) - - // Best-effort: prune any mappings that point at the deleted worktree. - const current = await readWorktreeMap(workspace.path, request.log) - let changed = false - const nextMapping: Record = { ...(current.parentSessionWorktreeSlug ?? {}) } - for (const [sessionId, mapped] of Object.entries(nextMapping)) { - if (mapped === slug) { - delete nextMapping[sessionId] - changed = true - } - } - const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug - if (nextDefault !== current.defaultWorktreeSlug) { - changed = true + app.post<{ Params: { id: string; sessionId: string }; Body: unknown }>( + "/api/workspaces/:id/worktrees/sessions/:sessionId/move", + async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } } - if (changed) { - await writeWorktreeMap( - workspace.path, - { - version: 1, - defaultWorktreeSlug: nextDefault, - parentSessionWorktreeSlug: nextMapping, - }, - request.log, - ) + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) + try { + const body = WorktreeSessionMoveSchema.parse(request.body) + // ponytail: one exclusive move lane per instance; split into family locks only if contention is measured. + return await deps.mutationGate.exclusive(workspace.id, async () => { + return withRepositoryExclusive(deps, workspace.id, async () => { + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new WorktreeNonGitError("Workspace is not a Git repository") + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const target = worktrees.find((worktree) => worktree.slug === body.worktreeSlug) + if (!target) throw new Error("Worktree not found") + const nativeWorkspaceFolder = await deps.workspaceManager.resolveInstanceDirectory(workspace.id) + const client = createInstanceClient(deps.workspaceManager, workspace.id, { directory: nativeWorkspaceFolder }) + if (!client) throw new Error("Workspace instance is not ready") + return moveSessionFamilyTransaction({ + instanceId: workspace.id, + client, + hostWorkspaceFolder: workspace.path, + nativeScope: { + rootDirectory: nativeWorkspaceFolder, + targetDirectory: await deps.workspaceManager.resolveInstanceDirectory(workspace.id, target.directory), + }, + sessionId: request.params.sessionId, + target, + sessionMetadataPersistence: deps.sessionMetadataPersistence, + logger: request.log, + signal: cancellation.signal, + }) + }, cancellation.signal) + }, cancellation.signal) + } catch (error) { + return handleError(error, reply) + } finally { + cancellation.cleanup() } - - reply.code(204) - } catch (error) { - return handleError(error, reply) - } - }, + }, ) app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => { @@ -188,7 +338,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(404) return { error: "Workspace not found" } } - return await readWorktreeMap(workspace.path, request.log) + try { + return (await readWorktreeMapStrict(workspace.path, request.log)).map + } catch (error) { + return handleError(error, reply) + } }) app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => { @@ -197,6 +351,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(404) return { error: "Workspace not found" } } + const cancellation = disconnectSignal(request, reply, deps.shutdownSignal) try { const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap @@ -210,15 +365,67 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { return { error: "Invalid worktree slug in mapping" } } } - await writeWorktreeMap(workspace.path, parsed, request.log) - reply.code(204) + return await withWorkspaceRepositoryExclusive(deps, workspace.id, async (currentWorkspace) => { + const { repoRoot } = await resolveRepoRoot(currentWorkspace.path, request.log) + const current = (await readWorktreeMapStrict(currentWorkspace.path, request.log)).map + if ((parsed.revision ?? 0) !== (current.revision ?? 0)) { + throw new WorktreeMapUpdateConflictError("Worktree map revision is stale") + } + const available = new Set((await listWorktrees({ + repoRoot, + workspaceFolder: currentWorkspace.path, + logger: request.log, + })).map((worktree) => worktree.slug)) + if (!available.has(parsed.defaultWorktreeSlug) + || Object.values(parsed.parentSessionWorktreeSlug).some((slug) => !available.has(slug))) { + throw new WorktreeMapUpdateConflictError("Worktree map references a missing worktree") + } + const next: WorktreeMap = { ...parsed, revision: (current.revision ?? 0) + 1 } + await writeWorktreeMap(currentWorkspace.path, next, request.log, parsed.revision ?? 0) + return next + }, cancellation.signal) } catch (error) { return handleError(error, reply) + } finally { + cancellation.cleanup() } }) } function handleError(error: unknown, reply: FastifyReply) { - reply.code(400) + if (error instanceof WorktreeRollbackIncompleteError) { + reply.code(500) + return { error: error.message } + } + if (error instanceof WorktreeSessionBusyError + || error instanceof WorktreeMapConflictError + || error instanceof WorktreeMapUpdateConflictError + || error instanceof WorktreeMapRevisionConflictError + || error instanceof WorktreeRepositoryInUseError + || error instanceof WorktreeWorkspaceChangedError) { + reply.code(409) + return { error: error.message } + } + if (error instanceof WorktreeDeletionConflictError || error instanceof WorktreeCreationConflictError) { + reply.code(409) + return { error: error.message } + } + if (error instanceof WorktreeNonGitError) { + reply.code(400) + return { error: error.message } + } + if (error instanceof WorktreeMapReadError) { + reply.code(500) + return { error: error.message } + } + if (error instanceof Error && (error.message === "Worktree not found" || error.message === "Session not found")) { + reply.code(404) + return { error: error.message } + } + if (error instanceof z.ZodError) { + reply.code(400) + return { error: error.message } + } + reply.code(500) return { error: error instanceof Error ? error.message : "Unable to fulfill request" } } diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts index 446e77918..98f352851 100644 --- a/packages/server/src/shutdown.test.ts +++ b/packages/server/src/shutdown.test.ts @@ -24,7 +24,7 @@ describe("server shutdown orchestration", () => { stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, stopHttpServers: () => { calls.push("http") }, }), logger) - assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"]) + assert.deepEqual(calls, ["remote-proxy", "http", "workspaces-1", "workspaces-2"]) }) it("closes remaining resources and aggregates the concrete current error", async () => { @@ -59,6 +59,15 @@ describe("server shutdown orchestration", () => { releasePreliminary() await shutdown }) + + it("starts HTTP quiescing before workspace cleanup", async () => { + const calls: string[] = [] + await orchestrateServerShutdown(operations({ + stopHttpServers: () => { calls.push("http") }, + stopWorkspaces: () => { calls.push("workspaces") }, + }), logger) + assert.deepEqual(calls.slice(0, 2), ["http", "workspaces"]) + }) }) describe("server shutdown signal boundary", () => { diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts index 3324aa255..aa0f91594 100644 --- a/packages/server/src/shutdown.ts +++ b/packages/server/src/shutdown.ts @@ -75,6 +75,11 @@ export async function orchestrateServerShutdown( } } + const preliminaryShutdown = settle([ + ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], + ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], + ["stopHttpServers", operations.stopHttpServers], + ]) const workspaceShutdown = (async () => { const attempts = Math.max(1, Math.floor(workspaceAttempts)) for (let attempt = 1; attempt <= attempts; attempt += 1) { @@ -89,13 +94,7 @@ export async function orchestrateServerShutdown( logger.error({ err: error, attempts }, "Workspace manager shutdown failed") } })() - await Promise.all([ - settle([ - ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], - ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], - ]), - workspaceShutdown, - ]) - await settle([["stopHttpServers", operations.stopHttpServers], ["stopReleaseMonitor", operations.stopReleaseMonitor]]) + await Promise.all([preliminaryShutdown, workspaceShutdown]) + await settle([["stopReleaseMonitor", operations.stopReleaseMonitor]]) if (errors.length) throw new AggregateError(errors, "Server shutdown failed") } diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index bc6382a1c..c95f5c5c9 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -1,37 +1,33 @@ import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { execFileSync } from "node:child_process" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { listWorktrees } from "../git-worktrees" +import { listWorktrees, parseWorktreePorcelain } from "../git-worktrees" +import { stripGitLineTerminator } from "../git-output" describe("listWorktrees", () => { + it("preserves newlines in Git path output and NUL-delimited worktree inventory", () => { + const directory = "/repo/line\nbreak" + assert.equal(stripGitLineTerminator(`${directory}\n`), directory) + assert.deepEqual(parseWorktreePorcelain([ + `worktree ${directory}`, + "HEAD abcdef1234567890", + "detached", + "", + "", + ].join("\0")), [{ worktree: directory, head: "abcdef1234567890", detached: true }]) + }) + it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) - const binDir = path.join(temp, "bin") const repoRoot = path.join(temp, "repo") const workspaceFolder = path.join(repoRoot, "proj-1") - const originalPath = process.env.PATH try { - mkdirSync(binDir, { recursive: true }) mkdirSync(workspaceFolder, { recursive: true }) - - const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") - const porcelain = [ - `worktree ${repoRoot}`, - "HEAD 1111111", - "branch refs/heads/main", - "", - ].join("\n") - - if (process.platform === "win32") { - writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) - } else { - writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) - } - - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot }) const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) @@ -41,7 +37,6 @@ describe("listWorktrees", () => { assert.equal(worktrees[0]?.branch, "main") assert.notEqual(worktrees[0]?.directory, repoRoot) } finally { - process.env.PATH = originalPath rmSync(temp, { recursive: true, force: true }) } }) diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index d11d8a66d..9077d23c4 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { buildWindowsSpawnSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" +import { buildWindowsSpawnSpec, parseWslUncPath, resolveWslNativePath, resolveWslWorkingDirectory } from "../spawn" describe("parseWslUncPath", () => { it("parses WSL UNC paths into distro and linux path", () => { @@ -49,6 +49,22 @@ describe("resolveWslWorkingDirectory", () => { }) }) +describe("resolveWslNativePath", () => { + it("returns the Linux namespace for matching WSL UNC paths", async () => { + assert.equal( + await resolveWslNativePath(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "ubuntu"), + "/home/dev/workspace", + ) + }) + + it("normalizes extended UNC paths before WSL detection", () => { + assert.deepEqual(parseWslUncPath(String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev`), { + distro: "Ubuntu", + linuxPath: "/home/dev", + }) + }) +}) + describe("buildWindowsSpawnSpec", () => { it("classifies native executables separately from script and shell wrappers", () => { assert.equal(buildWindowsSpawnSpec("opencode.exe", []).processKind, "windows-direct") diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index ccf8e6ca4..a3d998895 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -7,7 +8,18 @@ import pino from "pino" import { EventBus } from "../../events/bus" import { WorkspaceManager } from "../manager" -import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" +import { queryGitRepositoryPaths, stripGitLineTerminator } from "../git-output" +import { + canonicalFilesystemIdentity, + nativeWorkspacePathsEqual, + normalizeWorkspaceIdentityPath, + resolveRepositoryIdentity, + resolveRepositoryMutationKey, + resolveWorkspaceIdentity, + sharedRelativePathIdentity, + workspaceIdentityPathsEqual, +} from "../workspace-identity" +import { parseWslWindowsDriveMounts } from "../wsl-windows-drive" const temporaryDirectories: string[] = [] const runtimeResult = (pid = 123) => ({ @@ -76,10 +88,185 @@ async function createSharedLaunch() { } describe("workspace identity", () => { + it("runs Git path probes sequentially and strips only Git's LF terminator", async () => { + const calls: string[] = [] + let topLevelComplete = false + const result = await queryGitRepositoryPaths("/workspace", async (_cwd, argument) => { + calls.push(argument) + if (argument === "--show-toplevel") { + await new Promise((resolve) => setImmediate(resolve)) + topLevelComplete = true + return "/repository" + } + assert.equal(topLevelComplete, true) + return ".git" + }) + assert.deepEqual(result, { topLevel: "/repository", commonDir: ".git" }) + assert.deepEqual(calls, ["--show-toplevel", "--git-common-dir"]) + assert.equal(stripGitLineTerminator("/repository\r\n"), "/repository\r") + + calls.length = 0 + await assert.rejects(queryGitRepositoryPaths("/workspace", async (_cwd, argument) => { + calls.push(argument) + throw new Error("top-level failed") + }), /top-level failed/) + assert.deepEqual(calls, ["--show-toplevel"]) + }) + it("normalizes Windows paths without affecting POSIX case", () => { assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/") + assert.equal(workspaceIdentityPathsEqual( + String.raw`\\?\C:\Projects\CodeNomad`, + String.raw`C:\Projects\CodeNomad`, + "win32", + ), true) + assert.equal(workspaceIdentityPathsEqual( + String.raw`\\?\UNC\Server\Share\Repo`, + String.raw`\\Server\Share\Repo`, + "win32", + ), true) + }) + + it("normalizes WSL distro identity without folding Linux path case", () => { + const sources = { resolveWslPath: () => undefined } + assert.equal(workspaceIdentityPathsEqual( + String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, + String.raw`\\wsl$\ubuntu\home\dev\Repo\.`, + "win32", + sources, + ), true) + assert.equal(workspaceIdentityPathsEqual( + String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, + String.raw`\\wsl$\ubuntu\home\dev\repo`, + "win32", + sources, + ), false) + assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo/feature/..", "/home/dev/Repo"), true) + assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo", "/home/dev/repo"), false) + assert.equal(canonicalFilesystemIdentity( + String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev\Repo`, + "win32", + undefined, + sources, + ), "wsl:ubuntu:/home/dev/Repo") + assert.equal( + sharedRelativePathIdentity( + String.raw`\\wsl.localhost\Ubuntu\home\dev\Projects\MissingRepo`, + String.raw`\\wsl.localhost\Ubuntu\home\dev`, + "win32", + undefined, + sources, + ), + sharedRelativePathIdentity("/home/dev/Projects/MissingRepo", "/home/dev", "linux"), + ) + }) + + it("shares Windows-drive identities with WSL drive mounts regardless of case", () => { + const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /mnt/c rw - 9p C:\134 rw`) + const sources = { + mounts, + resolveWslPath: (_distribution: string, linuxPath: string) => + `C:\\${linuxPath.replace(/^\/mnt\/c\/?/i, "").replace(/\//g, "\\")}`, + } + const windowsIdentity = canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32") + assert.equal( + windowsIdentity, + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", wslRelease, sources), + ) + assert.equal( + windowsIdentity, + canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, "win32", undefined, sources), + ) + assert.equal( + sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), + sharedRelativePathIdentity("/mnt/c/Projects/CodeNomad", "/mnt/c/Projects", "linux", wslRelease, sources), + ) + assert.equal( + sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), + sharedRelativePathIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects`, + "win32", + undefined, + sources, + ), + ) + assert.notEqual( + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", "6.8.0-linux"), + canonicalFilesystemIdentity("/mnt/c/projects/codenomad", "linux", "6.8.0-linux"), + ) + }) + + it("uses the default WSL drive identity only when Windows-side resolution fails", () => { + const failedResolution = { resolveWslPath: () => undefined } + const windowsIdentity = canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32") + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + "win32", + undefined, + failedResolution, + ), + windowsIdentity, + ) + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\windows\c\Projects\CodeNomad`, + "win32", + undefined, + failedResolution, + ), + "wsl:ubuntu:/windows/c/Projects/CodeNomad", + ) + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + "win32", + undefined, + { resolveWslPath: () => String.raw`\\server\share\CodeNomad` }, + ), + "wsl:ubuntu:/mnt/c/Projects/CodeNomad", + ) + }) + + it("uses WSL mount metadata for custom automount roots", () => { + const release = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /windows/c rw - 9p C:\134 rw`) + const sources = { + mounts, + resolveWslPath: (_distribution: string, linuxPath: string) => + `C:\\${linuxPath.replace(/^\/windows\/c\/?/i, "").replace(/\//g, "\\")}`, + } + assert.deepEqual(mounts, [{ mountPoint: "/windows/c", drive: "c", sourcePrefix: "" }]) + assert.equal( + canonicalFilesystemIdentity("/windows/c/Projects/CodeNomad", "linux", release, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + assert.equal( + canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Custom\windows\c\Projects\CodeNomad`, "win32", undefined, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + assert.notEqual( + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", release, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + }) + + it("includes a custom mount's Windows source prefix in its identity", () => { + const release = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /work rw - 9p C:\134Projects rw`) + assert.deepEqual(mounts, [{ mountPoint: "/work", drive: "c", sourcePrefix: "Projects" }]) + assert.equal( + canonicalFilesystemIdentity("/work/repo", "linux", release, { mounts }), + canonicalFilesystemIdentity(String.raw`C:\Projects\repo`, "win32"), + ) + assert.notEqual( + canonicalFilesystemIdentity("/work/repo", "linux", release, { mounts }), + canonicalFilesystemIdentity(String.raw`C:\repo`, "win32"), + ) }) it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { @@ -93,8 +280,39 @@ describe("workspace identity", () => { assert.equal(linkResult.identityKey, targetResult.identityKey) assert.equal(linkResult.workspacePath, targetResult.workspacePath) + assert.equal(workspaceIdentityPathsEqual(link, target), true) + assert.equal(canonicalFilesystemIdentity(link), canonicalFilesystemIdentity(target)) assert.equal(missing.workspacePath, expectedMissing) assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing)) + + const missingThroughLink = path.join(link, "not-created", "workspace") + const missingThroughTarget = path.join(target, "not-created", "workspace") + assert.equal(canonicalFilesystemIdentity(missingThroughLink), canonicalFilesystemIdentity(missingThroughTarget)) + assert.equal( + (await resolveWorkspaceIdentity(missingThroughLink, root)).workspacePath, + missingThroughTarget, + ) + }) + + it("uses the canonical Git common directory for real linked worktrees", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-repository-identity-")) + temporaryDirectories.push(root) + const repository = path.join(root, "repository") + const linked = path.join(root, "linked") + await mkdir(repository) + execFileSync("git", ["init", "-b", "main"], { cwd: repository }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repository }) + execFileSync("git", ["config", "user.name", "Test"], { cwd: repository }) + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd: repository }) + execFileSync("git", ["worktree", "add", "-b", "linked", linked], { cwd: repository }) + + const [primaryIdentity, linkedIdentity] = await Promise.all([ + resolveRepositoryIdentity(repository), + resolveRepositoryIdentity(linked), + ]) + assert.equal(primaryIdentity.commonDir, linkedIdentity.commonDir) + assert.equal(primaryIdentity.mutationKey, linkedIdentity.mutationKey) + assert.equal(await resolveRepositoryMutationKey(repository), linkedIdentity.mutationKey) }) it("deduplicates active canonical aliases", async () => { diff --git a/packages/server/src/workspaces/__tests__/worktree-map.test.ts b/packages/server/src/workspaces/__tests__/worktree-map.test.ts new file mode 100644 index 000000000..675c56f6c --- /dev/null +++ b/packages/server/src/workspaces/__tests__/worktree-map.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { promises as fsp, type PathLike, type RmOptions } from "node:fs" +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" + +import type { WorktreeMap } from "../../api-types" +import { + deleteWorktreeMap, + readWorktreeMapStrict, + WorktreeMapReadError, + worktreeMapExists, + writeWorktreeMap, +} from "../worktree-map" + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) +}) + +async function createLinkedRepository() { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-map-")) + temporaryDirectories.push(root) + const repository = path.join(root, "repository") + const linked = path.join(root, "linked") + await mkdir(repository) + execFileSync("git", ["init", "-b", "main"], { cwd: repository }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repository }) + execFileSync("git", ["config", "user.name", "Test"], { cwd: repository }) + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd: repository }) + execFileSync("git", ["worktree", "add", "-b", "linked", linked], { cwd: repository }) + return { repository, linked } +} + +function addLinkedWorktree(repository: string, name: string): string { + const linked = path.join(path.dirname(repository), name) + execFileSync("git", ["worktree", "add", "-b", name, linked], { cwd: repository }) + return linked +} + +function legacyMapPath(worktree: string): string { + return path.join(worktree, ".codenomad", "worktreeMap.json") +} + +function map(revision: number, session: string): WorktreeMap { + return { + version: 1, + revision, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { [session]: "linked" }, + } +} + +describe("worktree map identity", () => { + it("shares writes and revision visibility across real linked worktrees", async () => { + const { repository, linked } = await createLinkedRepository() + await writeWorktreeMap(repository, map(1, "first")) + assert.deepEqual((await readWorktreeMapStrict(linked)).map, map(1, "first")) + + const staleRevision = (await readWorktreeMapStrict(repository)).map.revision + await writeWorktreeMap(linked, map(2, "second")) + const current = (await readWorktreeMapStrict(repository)).map + assert.deepEqual(current, map(2, "second")) + assert.notEqual(staleRevision, current.revision) + + await access(path.join(repository, ".codenomad", "worktreeMap.json")) + await assert.rejects(access(path.join(linked, ".codenomad", "worktreeMap.json")), { code: "ENOENT" }) + assert.match(await readFile(path.join(repository, ".git", "info", "exclude"), "utf-8"), /\.codenomad\/worktreeMap\.json/) + assert.equal(await worktreeMapExists(linked), true) + await deleteWorktreeMap(linked) + assert.equal(await worktreeMapExists(repository), false) + }) + + it("stores maps inside a nonstandard common directory", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-map-git-dir-")) + temporaryDirectories.push(root) + const repository = path.join(root, "repository") + const commonDir = path.join(root, "git-data") + await mkdir(repository) + execFileSync("git", ["init", "--separate-git-dir", commonDir], { cwd: repository }) + + await writeWorktreeMap(repository, map(3, "separate")) + await access(path.join(commonDir, "codenomad", "worktreeMap.json")) + await assert.rejects(access(path.join(repository, ".codenomad", "worktreeMap.json")), { code: "ENOENT" }) + }) + + it("migrates a valid current-checkout legacy map to the shared path", async () => { + const { repository, linked } = await createLinkedRepository() + const legacyPath = path.join(linked, ".codenomad", "worktreeMap.json") + const canonicalPath = path.join(repository, ".codenomad", "worktreeMap.json") + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(4, "legacy")), "utf-8") + + assert.deepEqual((await readWorktreeMapStrict(linked)).map, map(4, "legacy")) + assert.deepEqual(JSON.parse(await readFile(canonicalPath, "utf-8")), map(4, "legacy")) + await assert.rejects(access(legacyPath), { code: "ENOENT" }) + }) + + it("merges every linked worktree map without lowering the canonical revision", async () => { + const { repository, linked } = await createLinkedRepository() + const secondLinked = addLinkedWorktree(repository, "linked-two") + await writeWorktreeMap(repository, map(8, "canonical")) + const firstLegacyPath = legacyMapPath(linked) + const secondLegacyPath = legacyMapPath(secondLinked) + await Promise.all([ + mkdir(path.dirname(firstLegacyPath), { recursive: true }), + mkdir(path.dirname(secondLegacyPath), { recursive: true }), + ]) + await Promise.all([ + writeFile(firstLegacyPath, JSON.stringify(map(2, "stale")), "utf-8"), + writeFile(secondLegacyPath, JSON.stringify({ + ...map(5, "canonical"), + parentSessionWorktreeSlug: { canonical: "linked", second: "linked-two" }, + }), "utf-8"), + ]) + + const expected = { + ...map(9, "canonical"), + parentSessionWorktreeSlug: { canonical: "linked", stale: "linked", second: "linked-two" }, + } + assert.deepEqual((await readWorktreeMapStrict(linked)).map, expected) + assert.deepEqual(JSON.parse(await readFile(legacyMapPath(repository), "utf-8")), expected) + await assert.rejects(access(firstLegacyPath), { code: "ENOENT" }) + await assert.rejects(access(secondLegacyPath), { code: "ENOENT" }) + }) + + it("keeps an equivalent migration retry at the published revision", async () => { + const { repository, linked } = await createLinkedRepository() + await writeWorktreeMap(repository, map(8, "canonical")) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(5, "legacy")), "utf-8") + + const published = (await readWorktreeMapStrict(repository)).map + assert.equal(published.revision, 9) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(5, "legacy")), "utf-8") + + assert.deepEqual((await readWorktreeMapStrict(repository)).map, published) + }) + + it("blocks writes and deletes while a published legacy source cannot be retired", async (test) => { + const { repository, linked } = await createLinkedRepository() + const canonicalPath = legacyMapPath(repository) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(4, "legacy")), "utf-8") + const originalRm = fsp.rm.bind(fsp) + test.mock.method(fsp, "rm", async (filePath: PathLike, options?: RmOptions) => { + if (path.resolve(filePath.toString()) === path.resolve(legacyPath)) throw new Error("injected rm failure") + return originalRm(filePath, options) + }) + + assert.deepEqual((await readWorktreeMapStrict(repository)).map, map(4, "legacy")) + await assert.rejects(writeWorktreeMap(repository, map(5, "replacement"), undefined, 4), /retire legacy/) + await assert.rejects(deleteWorktreeMap(repository), /retire legacy/) + assert.deepEqual(JSON.parse(await readFile(canonicalPath, "utf-8")), map(4, "legacy")) + assert.deepEqual(JSON.parse(await readFile(legacyPath, "utf-8")), map(4, "legacy")) + }) + + it("parses every linked map before publishing or removing any source", async () => { + const { repository, linked } = await createLinkedRepository() + const secondLinked = addLinkedWorktree(repository, "linked-two") + const legacyPath = legacyMapPath(linked) + const malformedPath = legacyMapPath(secondLinked) + await Promise.all([ + mkdir(path.dirname(legacyPath), { recursive: true }), + mkdir(path.dirname(malformedPath), { recursive: true }), + ]) + await Promise.all([ + writeFile(legacyPath, JSON.stringify(map(4, "valid")), "utf-8"), + writeFile(malformedPath, "{ malformed", "utf-8"), + ]) + + await assert.rejects(readWorktreeMapStrict(linked), WorktreeMapReadError) + assert.deepEqual(JSON.parse(await readFile(legacyPath, "utf-8")), map(4, "valid")) + assert.equal(await readFile(malformedPath, "utf-8"), "{ malformed") + await assert.rejects(access(legacyMapPath(repository)), { code: "ENOENT" }) + }) + + it("rejects binding conflicts without changing canonical or legacy maps", async () => { + const { repository, linked } = await createLinkedRepository() + const canonical = map(8, "shared") + const conflicting = { + ...map(9, "shared"), + parentSessionWorktreeSlug: { shared: "other" }, + } + await writeWorktreeMap(repository, canonical) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(conflicting), "utf-8") + + await assert.rejects(readWorktreeMapStrict(linked), /Conflicting worktree binding/) + assert.deepEqual(JSON.parse(await readFile(legacyMapPath(repository), "utf-8")), canonical) + assert.deepEqual(JSON.parse(await readFile(legacyPath, "utf-8")), conflicting) + }) + + it("rejects default-worktree conflicts without changing any map", async () => { + const { repository, linked } = await createLinkedRepository() + const canonical = map(8, "canonical") + const conflicting = { ...map(9, "legacy"), defaultWorktreeSlug: "linked" } + await writeWorktreeMap(repository, canonical) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(conflicting), "utf-8") + + await assert.rejects(readWorktreeMapStrict(repository), /Conflicting default worktree slug/) + assert.deepEqual(JSON.parse(await readFile(legacyMapPath(repository), "utf-8")), canonical) + assert.deepEqual(JSON.parse(await readFile(legacyPath, "utf-8")), conflicting) + }) +}) diff --git a/packages/server/src/workspaces/git-output.ts b/packages/server/src/workspaces/git-output.ts new file mode 100644 index 000000000..26097e759 --- /dev/null +++ b/packages/server/src/workspaces/git-output.ts @@ -0,0 +1,23 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +export function stripGitLineTerminator(output: string): string { + if (output.endsWith("\n")) return output.slice(0, -1) + return output +} + +async function queryGitPath(cwd: string, argument: "--show-toplevel" | "--git-common-dir"): Promise { + const { stdout } = await execFileAsync("git", ["rev-parse", argument], { cwd, windowsHide: true }) + return stripGitLineTerminator(stdout) +} + +export async function queryGitRepositoryPaths( + cwd: string, + query: typeof queryGitPath = queryGitPath, +): Promise<{ topLevel: string; commonDir: string }> { + const topLevel = await query(cwd, "--show-toplevel") + const commonDir = await query(cwd, "--git-common-dir") + return { topLevel, commonDir } +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 087009015..1001c29f0 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -2,12 +2,27 @@ import path from "path" import { spawn } from "child_process" import type { WorktreeDescriptor } from "../api-types" import { promises as fsp } from "fs" +import { stripGitLineTerminator } from "./git-output" export interface LogLike { debug?: (obj: any, msg?: string) => void warn?: (obj: any, msg?: string) => void } +export class WorktreeDeletionConflictError extends Error { + constructor(message: string) { + super(message) + this.name = "WorktreeDeletionConflictError" + } +} + +export class WorktreeCreationConflictError extends Error { + constructor(message: string) { + super(message) + this.name = "WorktreeCreationConflictError" + } +} + type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } function isGitUnavailableResult(result: GitResult): boolean { @@ -46,10 +61,14 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise throw new Error("Git is not installed or not available in PATH") } if (!result.ok) { - logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") - return { repoRoot: folder, isGitRepo: false } + const message = result.stderr ?? result.error.message + if (/not a git repository/i.test(message)) { + logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") + return { repoRoot: folder, isGitRepo: false } + } + throw result.error } - const repoRoot = result.stdout.trim() + const repoRoot = stripGitLineTerminator(result.stdout) if (!repoRoot) { return { repoRoot: folder, isGitRepo: false } } @@ -61,26 +80,30 @@ export async function isGitAvailable(folder: string): Promise { return result.ok || !isGitUnavailableResult(result) } -function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { +export function parseWorktreePorcelain(output: string): Array<{ + worktree: string + branch?: string + head?: string + detached?: boolean +}> { const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] - const lines = output.split(/\r?\n/) let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {} const flush = () => { if (current.worktree) { - records.push({ worktree: current.worktree, branch: current.branch }) + records.push({ ...current, worktree: current.worktree }) } current = {} } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { + for (const field of output.split("\0")) { + if (!field) { flush() continue } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() + const separator = field.indexOf(" ") + const key = separator === -1 ? field : field.slice(0, separator) + const value = separator === -1 ? "" : field.slice(separator + 1) if (key === "worktree") { current.worktree = value } else if (key === "branch") { @@ -103,11 +126,13 @@ export async function listWorktrees(params: { }): Promise { const { repoRoot, workspaceFolder, logger } = params - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) + const result = await runGit(["worktree", "list", "--porcelain", "-z"], workspaceFolder) if (!result.ok) { - const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" } - logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") - return [rootDescriptor] + const message = result.stderr ?? result.error.message + if (/not a git repository/i.test(message)) { + return [{ slug: "root", directory: workspaceFolder, kind: "root" }] + } + throw result.error } const records = parseWorktreePorcelain(result.stdout) @@ -201,7 +226,7 @@ export async function createManagedWorktree(params: { try { const stat = await fsp.stat(targetDir) if (stat.isDirectory()) { - throw new Error("Worktree directory already exists") + throw new WorktreeCreationConflictError("Worktree directory already exists") } } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -225,6 +250,10 @@ export async function createManagedWorktree(params: { if (second.ok) { return { slug: branch, directory: targetDir, branch } } + const secondMessage = second.stderr?.toLowerCase() ?? second.error.message.toLowerCase() + if (secondMessage.includes("already checked out") || secondMessage.includes("already used by worktree")) { + throw new WorktreeCreationConflictError(second.error.message) + } throw second.error } @@ -252,6 +281,10 @@ export async function removeWorktree(params: { const result = await runGit(args, workspaceFolder) if (!result.ok) { + const message = result.stderr?.trim() || result.error.message + if (/modified or untracked files|is dirty|contains modified files/i.test(message)) { + throw new WorktreeDeletionConflictError(message) + } throw result.error } diff --git a/packages/server/src/workspaces/instance-client.test.ts b/packages/server/src/workspaces/instance-client.test.ts index 636bc9f1a..d1760ee01 100644 --- a/packages/server/src/workspaces/instance-client.test.ts +++ b/packages/server/src/workspaces/instance-client.test.ts @@ -106,6 +106,22 @@ describe("createInstanceClient", () => { } }) + it("strips the private mutation context before loopback forwarding", async () => { + const { requests, restore } = installRecordingFetch() + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") + + await client!.session.abort( + { sessionID: "session" }, + { headers: { "x-codenomad-mutation-session": "session" } }, + ) + assert.equal(requests[0].headers.get("x-codenomad-mutation-session"), null) + } finally { + restore() + } + }) + it("scopes requests to the workspace directory", async () => { const { requests, restore } = installRecordingFetch() try { @@ -148,6 +164,21 @@ describe("createInstanceClient", () => { } }) + it("preserves case in an authoritative WSL-native scope", async () => { + const { requests, restore } = installRecordingFetch() + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: String.raw`\\wsl$\Ubuntu\home\dev\repo` }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { + directory: "/home/dev/Repo", + }) + + await client!.global.health() + assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/home/dev/Repo") + } finally { + restore() + } + }) + it("applies the loopback timeout and aborts a stuck instance", async () => { const original = globalThis.fetch // Never resolves on its own; only settles when the passed signal aborts, diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts index 5ee323f04..a4ea59608 100644 --- a/packages/server/src/workspaces/instance-client.ts +++ b/packages/server/src/workspaces/instance-client.ts @@ -3,8 +3,9 @@ import type { WorkspaceManager } from "./manager" import { LOOPBACK_HOST } from "./loopback" const LOOPBACK_TIMEOUT_MS = 10_000 +const MUTATION_SESSION_HEADER = "x-codenomad-mutation-session" -interface InstanceClientOptions { +export interface InstanceClientOptions { timeoutMs?: number /** * Directory the instance should scope the call to. Defaults to the @@ -53,11 +54,16 @@ export function createInstanceClient( return createOpencodeClient({ baseUrl: `http://${LOOPBACK_HOST}:${port}/`, headers, - fetch: (url, init) => - fetch(url, { + fetch: (url, init) => { + const requestHeaders = new Headers(url instanceof Request ? url.headers : undefined) + new Headers((init as RequestInit)?.headers).forEach((value, name) => requestHeaders.set(name, value)) + requestHeaders.delete(MUTATION_SESSION_HEADER) + return fetch(url, { ...(init as RequestInit), + headers: requestHeaders, signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(timeoutMs), - }), + }) + }, ...(directory ? { directory } : {}), }) } diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index e7f6061ac..911637448 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -1,8 +1,13 @@ import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" import { describe, it } from "node:test" import pino from "pino" import { EventBus } from "../events/bus" +import { InstanceMutationGate } from "../server/instance-mutation-gate" import { WorkspaceWindowsTreeCleanupIncompleteError, type ProcessExitInfo, @@ -15,6 +20,8 @@ import { WorkspaceManager, WorkspaceShutdownError, } from "./manager" +import { resolveRepositoryMutationKey } from "./workspace-identity" +import type { WorkspaceLifetimeLease } from "./workspace-lifetime-lease" function deferred() { let resolve!: (value: T) => void @@ -64,9 +71,12 @@ function createHarness(options: { launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ReturnType clearTimeout?: (timer: ReturnType) => void + acquireLifetimeLease?: (workspaceFolder: string, workspaceId: string) => Promise + withRepositoryMutation?: WorkspaceManagerConstructorOptions["withRepositoryMutation"] } = {}) { const { stubReadiness = true, ...managerOptions } = options const eventBus = new EventBus() + const mutationGate = new InstanceMutationGate() const runtime = new ControlledRuntime() const readiness = deferred() const started: string[] = [] @@ -81,6 +91,7 @@ function createHarness(options: { logger: pino({ level: "silent" }), getServerBaseUrl: () => "http://127.0.0.1:4000", runtime, + mutationGate, ...managerOptions, }) if (stubReadiness) { @@ -93,11 +104,46 @@ function createHarness(options: { }), ]) } - return { manager, runtime, readiness, started, stopped } + return { manager, runtime, readiness, started, stopped, mutationGate } } -async function createReady(harness: ReturnType) { - const creation = harness.manager.create(process.cwd()) +type WorkspaceManagerConstructorOptions = ConstructorParameters[0] + +function blockRepositoryAdmission() { + const entered = deferred() + const release = deferred() + const aborted = deferred() + const withRepositoryMutation: NonNullable = async ({ signal, operation }) => { + entered.resolve() + await new Promise((resolve, reject) => { + let settled = false + const finish = (result: () => void) => { + if (settled) return + settled = true + signal?.removeEventListener("abort", abort) + result() + } + const abort = () => finish(() => { + aborted.resolve(signal?.reason) + reject(signal?.reason) + }) + signal?.addEventListener("abort", abort, { once: true }) + release.promise.then(() => finish(resolve)) + if (signal?.aborted) abort() + }) + return operation("blocked-repository") + } + return { entered, release, aborted, withRepositoryMutation } +} + +async function waitForCreationOwners(manager: WorkspaceManager, count: number) { + while ([...(manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== count) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +async function createReady(harness: ReturnType, folder = process.cwd()) { + const creation = harness.manager.create(folder) const workspaceId = await harness.runtime.launchCalled.promise harness.runtime.resolveLaunch() harness.readiness.resolve(undefined) @@ -106,6 +152,182 @@ async function createReady(harness: ReturnType) { } describe("workspace manager lifecycle", () => { + it("aborts and cleans a blocked launch when its sole owner disconnects", async () => { + const admission = blockRepositoryAdmission() + const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) + const creatorController = new AbortController() + const creation = harness.manager.create(process.cwd(), undefined, { + requestId: "disconnected-owner", + signal: creatorController.signal, + }) + await admission.entered.promise + + creatorController.abort(new Error("creator disconnected")) + const reason = await admission.aborted.promise + + assert.ok(reason instanceof WorkspaceLaunchCancelledError) + await assert.rejects(creation, (error) => error === reason) + assert.equal(harness.runtime.active.size, 0) + assert.deepEqual(harness.manager.list(), []) + assert.equal((harness.manager as any).workspaces.size, 0) + }) + + it("keeps shared admission alive after one disconnect and aborts on final cancellation", async () => { + const admission = blockRepositoryAdmission() + const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) + const firstController = new AbortController() + const first = harness.manager.create(process.cwd(), undefined, { + requestId: "disconnected-one", + signal: firstController.signal, + }) + await admission.entered.promise + const second = harness.manager.create(process.cwd(), undefined, { requestId: "remaining-two" }) + const outcomes = Promise.allSettled([first, second]) + await waitForCreationOwners(harness.manager, 2) + + firstController.abort(new Error("first creator disconnected")) + const pending = [...(harness.manager as any).pendingWorkspaceCreations.values()][0] + assert.equal(pending.ownership.get("disconnected-one"), "cancelled") + assert.equal(pending.ownership.get("remaining-two"), "active") + + const finalCancellation = harness.manager.cancelCreationRequest("remaining-two") + const reason = await admission.aborted.promise + const results = await outcomes + await finalCancellation + + assert.ok(reason instanceof WorkspaceLaunchCancelledError) + assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]) + assert.ok(results.every((result) => result.status === "rejected" && result.reason === reason)) + assert.equal(harness.runtime.active.size, 0) + assert.deepEqual(harness.manager.list(), []) + assert.equal((harness.manager as any).workspaces.size, 0) + }) + + it("waits for repository admission before reserving and starting", async () => { + const harness = createHarness() + const repositoryKey = await resolveRepositoryMutationKey(process.cwd()) + let release!: () => void + let started!: () => void + const held = new Promise((resolve) => { release = resolve }) + const entered = new Promise((resolve) => { started = resolve }) + const exclusive = harness.mutationGate.exclusive(repositoryKey, async () => { started(); await held }) + await entered + const creation = harness.manager.create(process.cwd()) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.runtime.active.size, 0) + + release() + await exclusive + await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + }) + + it("applies the launch deadline while waiting for repository admission", async () => { + const harness = createHarness({ launchTimeoutMs: 10 }) + const repositoryKey = await resolveRepositoryMutationKey(process.cwd()) + let release!: () => void + let markStarted!: () => void + const held = new Promise((resolve) => { release = resolve }) + const started = new Promise((resolve) => { markStarted = resolve }) + const exclusive = harness.mutationGate.exclusive(repositoryKey, async () => { markStarted(); await held }) + await started + + await assert.rejects(harness.manager.create(process.cwd()), WorkspaceLaunchTimeoutError) + assert.equal(harness.runtime.active.size, 0) + release() + await exclusive + await harness.mutationGate.exclusive(repositoryKey, async () => undefined) + }) + + it("refreshes repository identity after Git topology changes", async () => { + const folder = mkdtempSync(path.join(tmpdir(), "codenomad-dynamic-repository-")) + try { + const harness = createHarness() + const creation = harness.manager.create(folder) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + const before = await harness.manager.getRepositoryMutationKey(workspaceId) + execFileSync("git", ["init"], { cwd: folder }) + const after = await harness.manager.getRepositoryMutationKey(workspaceId) + assert.notEqual(after, before) + assert.equal(after, await resolveRepositoryMutationKey(folder)) + await harness.manager.delete(workspaceId) + } finally { + rmSync(folder, { recursive: true, force: true }) + } + }) + + it("holds repository admission through the unpublished launch lifecycle", async () => { + const harness = createHarness() + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + const repositoryKey = (await harness.manager.getRepositoryMutationKey(workspaceId))! + let exclusiveStarted = false + let mutationAdmitted = false + const mutation = harness.mutationGate.enter(workspaceId).then((release) => { + mutationAdmitted = true + return release + }) + const exclusive = harness.mutationGate.exclusive(repositoryKey, async () => { exclusiveStarted = true }) + await Promise.resolve() + assert.equal(exclusiveStarted, false) + assert.equal(mutationAdmitted, false) + + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + const releaseMutation = await mutation + releaseMutation() + await exclusive + assert.equal(exclusiveStarted, true) + }) + + it("retains a ready runtime and lifetime lease when repository admission cleanup fails", async () => { + let leaseReleases = 0 + const releaseFailure = new Error("repository admission release failed") + const harness = createHarness({ + acquireLifetimeLease: async () => ({ + token: "release-failure-lease", + directoryKey: "release-failure-directory", + repositoryKey: "release-failure-repository", + release: async () => { leaseReleases += 1 }, + }), + withRepositoryMutation: async ({ operation }) => { + await operation("release-failure-repository") + throw releaseFailure + }, + }) + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + + await assert.rejects(creation, (error) => error === releaseFailure) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(harness.runtime.active.has(workspaceId), true) + assert.equal(leaseReleases, 0) + }) + + it("keeps unpublished stop failures as repository blockers", async () => { + const harness = createHarness() + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + const repositoryKey = (await harness.manager.getRepositoryMutationKey(workspaceId))! + assert.equal(await harness.manager.hasRepositoryBlocker(repositoryKey, "other"), true) + harness.runtime.failStops = 1 + harness.runtime.launchResult.reject(new Error("launch failed")) + + await assert.rejects(creation, /controlled stop failure/) + assert.equal(harness.manager.list().length, 0) + assert.equal(await harness.manager.hasRepositoryBlocker(repositoryKey, "other"), true) + await harness.manager.delete(workspaceId) + assert.equal(await harness.manager.hasRepositoryBlocker(repositoryKey, "other"), false) + }) + it("rejects a healthy workspace whose OpenCode configuration is invalid", async () => { const originalFetch = globalThis.fetch const requests: string[] = [] @@ -171,9 +393,46 @@ describe("workspace manager lifecycle", () => { }) } + for (const operation of ["cancel", "shutdown"] as const) { + it(`keeps ${operation} behind a paused lifetime lease acquisition`, async () => { + const leaseStarted = deferred() + const leaseReady = deferred() + let leaseReleases = 0 + const harness = createHarness({ + acquireLifetimeLease: async (_workspaceFolder, workspaceId) => { + leaseStarted.resolve(workspaceId) + return leaseReady.promise + }, + }) + const requestId = operation === "cancel" ? "paused-lease-cancel" : undefined + const creation = harness.manager.create(process.cwd(), undefined, requestId ? { requestId } : {}) + const workspaceId = await leaseStarted.promise + + const cleanup = operation === "cancel" + ? harness.manager.cancelCreationRequest(requestId!) + : harness.manager.shutdown() + await Promise.resolve() + assert.equal(harness.runtime.stopCalls, 0) + assert.equal(harness.runtime.active.size, 0) + + leaseReady.resolve({ + token: "paused-lease", + directoryKey: "paused-directory", + repositoryKey: "paused-repository", + release: async () => { leaseReleases += 1 }, + }) + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await cleanup + assert.equal(leaseReleases, 1) + assert.equal(harness.runtime.active.size, 0) + assert.equal(harness.manager.get(workspaceId), undefined) + }) + } + it("shares failed cleanup and allows a later delete retry", async () => { const harness = createHarness() const workspaceId = await createReady(harness) + const repositoryKey = (await harness.manager.getRepositoryMutationKey(workspaceId))! harness.runtime.failStops = 2 const first = harness.manager.delete(workspaceId) @@ -182,12 +441,64 @@ describe("workspace manager lifecycle", () => { const failures = await Promise.allSettled([first, concurrent]) assert.deepEqual(failures.map((result) => result.status), ["rejected", "rejected"]) assert.equal(harness.runtime.active.has(workspaceId), true) + harness.runtime.onExit?.({ workspaceId, code: 0, signal: null, requested: true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(await harness.manager.hasRepositoryBlocker(repositoryKey, "other"), false) await harness.manager.delete(workspaceId) assert.equal(harness.runtime.active.has(workspaceId), false) assert.equal(harness.manager.get(workspaceId), undefined) + assert.equal(await harness.manager.hasRepositoryBlocker(repositoryKey, "other"), false) }) + it("does not abort a ready workspace when cancellable deletion admission loses", async () => { + const harness = createHarness() + const workspaceId = await createReady(harness) + const repositoryKey = (await harness.manager.getRepositoryMutationKey(workspaceId))! + const held = deferred() + const entered = deferred() + const exclusive = harness.mutationGate.exclusive(repositoryKey, async () => { + entered.resolve() + await held.promise + }) + await entered.promise + const controller = new AbortController() + const reason = new Error("caller disconnected") + const deletion = harness.manager.delete(workspaceId, controller.signal) + await new Promise((resolve) => setImmediate(resolve)) + controller.abort(reason) + await assert.rejects(deletion, (error) => error === reason) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(harness.runtime.active.has(workspaceId), true) + held.resolve() + await exclusive + await harness.manager.delete(workspaceId) + }) + + for (const operation of ["delete", "cancel", "shutdown"] as const) { + it(`waits for admitted mutations before ${operation} process cleanup`, async () => { + const harness = createHarness() + const requestId = operation === "cancel" ? "cancel-owner" : undefined + const creation = harness.manager.create(process.cwd(), undefined, requestId ? { requestId } : {}) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + const releaseMutation = await harness.mutationGate.enter(workspaceId) + const cleanup = operation === "delete" + ? harness.manager.delete(workspaceId) + : operation === "cancel" + ? harness.manager.cancelCreationRequest(requestId!) + : harness.manager.shutdown() + await Promise.resolve() + assert.equal(harness.runtime.stopCalls, 0) + + releaseMutation() + await cleanup + assert.equal(harness.runtime.active.has(workspaceId), false) + }) + } + it("retries cancellation deletion for an already-cancelled request", async () => { const harness = createHarness() const creation = harness.manager.create(process.cwd(), undefined, { requestId: "retry-cancel" }) @@ -305,9 +616,7 @@ describe("workspace manager lifecycle", () => { const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" }) const workspaceId = await harness.runtime.launchCalled.promise const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" }) - while ([...(harness.manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) { - await new Promise((resolve) => setImmediate(resolve)) - } + await waitForCreationOwners(harness.manager, 2) if (boundary === "health readiness") { harness.runtime.resolveLaunch() await new Promise((resolve) => setImmediate(resolve)) @@ -326,24 +635,21 @@ describe("workspace manager lifecycle", () => { it("bounds shutdown instead of waiting forever", async () => { let fireDeadline!: () => void - let cleared = 0 const harness = createHarness({ shutdownTimeoutMs: 25, setTimeout: ((callback: () => void) => { fireDeadline = callback return {} as ReturnType }) as typeof setTimeout, - clearTimeout: () => { cleared += 1 }, + clearTimeout: () => undefined, } as never) - const workspaceId = await createReady(harness) - cleared = 0 + const workspaceId = await createReady(harness, mkdtempSync(path.join(tmpdir(), "codenomad-hung-shutdown-"))) harness.runtime.stop = () => new Promise(() => undefined) const shutdown = harness.manager.shutdown() fireDeadline() await assert.rejects(shutdown, WorkspaceCleanupTimeoutError) assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(cleared, 1) }) it("publishes stopped exactly once for normal exit, readiness failure, and manager cleanup", async () => { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 6d39c9192..1139ad7aa 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -12,6 +12,7 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" +import { InstanceMutationGate } from "../server/instance-mutation-gate" import { buildOpencodeConfigContent, getCodeNomadPluginUrl, @@ -24,9 +25,15 @@ import { OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth" -import { resolveWorkspaceIdentity } from "./workspace-identity" -import { parseWslUncPath } from "./spawn" +import { resolveRepositoryMutationKey, resolveWorkspaceIdentity } from "./workspace-identity" +import { parseWslUncPath, resolveWslNativePath } from "./spawn" import { LOOPBACK_HOST } from "./loopback" +import { withRepositoryMutation } from "./repository-mutation-lock" +import { + acquireWorkspaceLifetimeLease, + hasWorkspaceLifetimeBlocker, + type WorkspaceLifetimeLease, +} from "./workspace-lifetime-lease" const STARTUP_STABILITY_DELAY_MS = 1500 const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 @@ -68,6 +75,9 @@ interface WorkspaceManagerOptions { launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout clearTimeout?: (timer: ManagerTimeout) => void + mutationGate?: InstanceMutationGate + acquireLifetimeLease?: typeof acquireWorkspaceLifetimeLease + withRepositoryMutation?: typeof withRepositoryMutation } interface WorkspaceRecord extends WorkspaceDescriptor { @@ -83,6 +93,8 @@ interface WorkspaceState { deletePromise?: Promise published: boolean stoppedPublished: boolean + cleanupUnconfirmed: boolean + lifetimeLease?: WorkspaceLifetimeLease } export class WorkspaceLaunchCancelledError extends Error { constructor(workspaceId: string) { @@ -122,6 +134,7 @@ export interface WorkspaceCreateOptions { binaryPath?: string requestId?: string forceNew?: boolean + signal?: AbortSignal } type CreationRequestState = "active" | "cancelled" | "released" type WorkspaceCreationOwnership = Map @@ -135,14 +148,17 @@ interface WorkspaceReadiness { export class WorkspaceManager { private readonly workspaces = new Map() private readonly pendingWorkspaceCreations = new Map() + private readonly pendingWorkspaceAdmissions = new Map; resolve: () => void }>() private readonly cancelledCreationRequests = new Set() private shuttingDown = false private readonly runtime: Pick private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() + private readonly mutationGate: InstanceMutationGate constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger) + this.mutationGate = options.mutationGate ?? new InstanceMutationGate() this.codeNomadPluginUrl = getCodeNomadPluginUrl() } list(): WorkspaceDescriptor[] { @@ -164,6 +180,54 @@ export class WorkspaceManager { return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined } + async getRepositoryMutationKey(id: string): Promise { + const record = this.workspaces.get(id) + return record ? resolveRepositoryMutationKey(record.path) : undefined + } + + async resolveInstanceDirectory(id: string, directory?: string): Promise { + const record = this.workspaces.get(id) + if (!record) throw new Error("Workspace not found") + const wsl = parseWslUncPath(record.binaryId) + return wsl ? resolveWslNativePath(directory ?? record.path, wsl.distro) : (directory ?? record.path) + } + + async hasRepositoryBlocker(repositoryKey: string, excludingId?: string): Promise { + for (const record of this.workspaces.values()) { + if (record.id === excludingId || await resolveRepositoryMutationKey(record.path) !== repositoryKey) continue + const state = record[WORKSPACE_STATE] + if (state.cleanupUnconfirmed + || !state.published + || record.pid !== undefined + || record.port !== undefined + || record.status === "starting") return true + } + const excluding = excludingId ? this.workspaces.get(excludingId) : undefined + const workspaceFolder = excluding?.path + ?? Array.from(this.workspaces.values()).find( + (record) => record[WORKSPACE_STATE].lifetimeLease?.repositoryKey === repositoryKey, + )?.path + return workspaceFolder ? hasWorkspaceLifetimeBlocker({ + workspaceFolder, + repositoryKey, + excludingToken: excluding?.[WORKSPACE_STATE].lifetimeLease?.token, + }) : false + } + + async hasWorkspaceBlocker(workspaceFolder: string, excludingId?: string): Promise { + const { identityKey } = await resolveWorkspaceIdentity(workspaceFolder, this.options.rootDir) + const repositoryKey = await resolveRepositoryMutationKey(workspaceFolder) + for (const record of this.workspaces.values()) { + if (record.id === excludingId) continue + if (record.identityKey === identityKey || await resolveRepositoryMutationKey(record.path) === repositoryKey) return true + } + return hasWorkspaceLifetimeBlocker({ + workspaceFolder, + repositoryKey, + excludingToken: excludingId ? this.workspaces.get(excludingId)?.[WORKSPACE_STATE].lifetimeLease?.token : undefined, + }) + } + findReadyInstanceIdByBinary(binaryPath: string): string | undefined { const resolvedPath = this.resolveBinaryPath(binaryPath) return this.list().find((workspace) => { @@ -253,47 +317,87 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) - if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { - throw new Error(`Workspace creation request ${options.requestId} was cancelled`) + this.throwIfCreationRequestCancelled(options) + if (!options.forceNew) { + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + this.throwIfCreationRequestCancelled(options) + const owner = options.requestId ?? ORDINARY_CREATION_OWNER + if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") + this.syncOwnership(pending) + return this.awaitCreation(pending, options, false) + } } - if (this.shuttingDown) { - throw new Error("Workspace manager is shutting down") + let identityAdmission: { promise: Promise; resolve: () => void } | undefined + if (!options.forceNew) { + const existingAdmission = this.pendingWorkspaceAdmissions.get(identityKey) + if (existingAdmission) { + await existingAdmission.promise + this.throwIfCreationRequestCancelled(options) + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + const owner = options.requestId ?? ORDINARY_CREATION_OWNER + if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") + this.syncOwnership(pending) + return this.awaitCreation(pending, options, false) + } + } else { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + identityAdmission = { promise, resolve } + this.pendingWorkspaceAdmissions.set(identityKey, identityAdmission) + } } - if (options.forceNew) { + try { + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + throw new Error(`Workspace creation request ${options.requestId} was cancelled`) + } + if (this.shuttingDown) { + throw new Error("Workspace manager is shutting down") + } + if (options.forceNew) { + const ownership = this.createOwnership(options.requestId) + const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + return this.awaitCreation(record, options, true) + } + const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) + if (existing) { + this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") + const record = this.workspaces.get(existing.id) + if (options.requestId && record) { + if (!record.ownership.has(options.requestId)) record.ownership.set(options.requestId, "active") + this.syncOwnership(record) + return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership) + } + return { workspace: existing, created: false } + } + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + const owner = options.requestId ?? ORDINARY_CREATION_OWNER + if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") + this.syncOwnership(pending) + return this.awaitCreation(pending, options, false) + } const ownership = this.createOwnership(options.requestId) const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - return this.finishCreation(result, options.requestId, ownership) - } - const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) - if (existing) { - this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") - const record = this.workspaces.get(existing.id) - if (options.requestId && record) { - if (!record.ownership.has(options.requestId)) record.ownership.set(options.requestId, "active") - this.syncOwnership(record) - return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership) + this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + this.pendingWorkspaceCreations.set(identityKey, record) + identityAdmission?.resolve() + if (this.pendingWorkspaceAdmissions.get(identityKey) === identityAdmission) { + this.pendingWorkspaceAdmissions.delete(identityKey) + } + try { + return await this.awaitCreation(record, options, true) + } finally { + if (this.pendingWorkspaceCreations.get(identityKey) === record) { + this.pendingWorkspaceCreations.delete(identityKey) + } } - return { workspace: existing, created: false } - } - const pending = this.pendingWorkspaceCreations.get(identityKey) - if (pending) { - const state = pending[WORKSPACE_STATE] - const owner = options.requestId ?? ORDINARY_CREATION_OWNER - if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") - this.syncOwnership(pending) - const result = await state.creation! - return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) - } - const ownership = this.createOwnership(options.requestId) - const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - this.pendingWorkspaceCreations.set(identityKey, record) - try { - return this.finishCreation(await creation, options.requestId, ownership) } finally { - if (this.pendingWorkspaceCreations.get(identityKey) === record) { - this.pendingWorkspaceCreations.delete(identityKey) + identityAdmission?.resolve() + if (this.pendingWorkspaceAdmissions.get(identityKey) === identityAdmission) { + this.pendingWorkspaceAdmissions.delete(identityKey) } } } finally { @@ -334,7 +438,9 @@ export class WorkspaceManager { Object.defineProperties(record, { identityKey: { value: identityKey }, ownership: { value: ownership }, - [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, + [WORKSPACE_STATE]: { + value: { abortController: new AbortController(), published: false, stoppedPublished: false, cleanupUnconfirmed: false }, + }, }) this.workspaces.set(id, record) @@ -345,11 +451,43 @@ export class WorkspaceManager { } private startCreation(record: WorkspaceRecord, options: WorkspaceCreateOptions, launchDeadlineAt: number, launchTimeoutMs: number): Promise { - const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) + const creation = this.mutationGate.exclusive(record.id, async () => { + let startupSucceeded = false + try { + return await this.withLaunchRepository(record.path, launchDeadlineAt, launchTimeoutMs, record[WORKSPACE_STATE].abortController.signal, async () => { + this.throwIfCancelled(record) + if (this.shuttingDown) throw new Error("Workspace manager is shutting down") + await this.acquireLifetimeLease(record) + const result = await this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) + startupSucceeded = true + return result + }) + } catch (error) { + const state = record[WORKSPACE_STATE] + if (!startupSucceeded && !state.cleanupUnconfirmed && this.workspaces.get(record.id) === record) { + await state.lifetimeLease?.release() + state.lifetimeLease = undefined + this.removeRecord(record.id, record, false) + } + throw error + } + }) record[WORKSPACE_STATE].creation = creation record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined) return creation } + + private async acquireLifetimeLease(record: WorkspaceRecord): Promise { + try { + record[WORKSPACE_STATE].lifetimeLease = await (this.options.acquireLifetimeLease ?? acquireWorkspaceLifetimeLease)( + record.path, + record.id, + ) + } catch (error) { + this.removeRecord(record.id, record, false) + throw error + } + } private async createWithDeadline(record: WorkspaceRecord, options: WorkspaceCreateOptions, launchDeadlineAt: number, launchTimeoutMs: number): Promise { const timeoutMs = Math.max(1, launchDeadlineAt - Date.now()) @@ -383,6 +521,29 @@ export class WorkspaceManager { if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } } + private async withLaunchRepository(workspacePath: string, deadlineAt: number, + launchTimeoutMs: number, signal: AbortSignal | undefined, operation: () => Promise): Promise { + const controller = new AbortController() + const abort = () => controller.abort(signal?.reason) + signal?.addEventListener("abort", abort, { once: true }) + const timeoutMs = Math.max(1, deadlineAt - Date.now()) + let timeout: ManagerTimeout | null = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + controller.abort(new WorkspaceLaunchTimeoutError(undefined, launchTimeoutMs)) + }, timeoutMs) + try { + if (signal?.aborted) abort() + return await (this.options.withRepositoryMutation ?? withRepositoryMutation)({ + workspaceFolder: workspacePath, + gate: this.mutationGate, + signal: controller.signal, + operation: async () => operation(), + }) + } finally { + signal?.removeEventListener("abort", abort) + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) + } + } private async createResolvedWorkspace( record: WorkspaceRecord, options: WorkspaceCreateOptions, @@ -465,12 +626,16 @@ export class WorkspaceManager { stopFailure = stopError }) if (!stopFailure) { + await state.lifetimeLease?.release() + state.lifetimeLease = undefined this.removeRecord(id, record, state.published) throw launchFailure } if (!state.published) { + state.cleanupUnconfirmed = true throw stopFailure } + state.cleanupUnconfirmed = true record.status = "error" record.error = stopFailure instanceof Error ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` @@ -484,20 +649,35 @@ export class WorkspaceManager { } } - delete(id: string): Promise { + delete(id: string, signal?: AbortSignal): Promise { const record = this.workspaces.get(id) if (!record) return Promise.resolve(undefined) const state = record[WORKSPACE_STATE] - if (!state.abortController.signal.aborted) { - state.abortController.abort(new WorkspaceLaunchCancelledError(id)) - } - const pending = this.pendingWorkspaceCreations.get(record.identityKey) - if (pending === record) { - this.pendingWorkspaceCreations.delete(record.identityKey) - } + signal?.throwIfAborted() if (!state.deletePromise) { let deletePromise!: Promise - deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => { + const starting = record.status === "starting" || !state.published + const cleanup = async (admissionSignal?: AbortSignal) => this.mutationGate.exclusive(id, async () => { + return withRepositoryMutation({ + workspaceFolder: record.path, + gate: this.mutationGate, + signal: admissionSignal, + operation: async () => { + this.abortWorkspaceLifecycle(id, record) + return this.cleanupDeletedWorkspace(id, record).catch((error) => { + state.cleanupUnconfirmed = true + throw error + }) + }, + }) + }, admissionSignal) + if (starting) { + this.abortWorkspaceLifecycle(id, record) + deletePromise = cleanup() + } else { + deletePromise = cleanup(signal) + } + deletePromise = deletePromise.catch((error) => { if (state.deletePromise === deletePromise) state.deletePromise = undefined throw error }) @@ -546,6 +726,35 @@ export class WorkspaceManager { return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]) } + private async awaitCreation( + record: WorkspaceRecord, + options: WorkspaceCreateOptions, + created: boolean, + ): Promise { + const { requestId, signal } = options + const finish = async () => { + const result = await record[WORKSPACE_STATE].creation! + return this.finishCreation({ workspace: result.workspace, created }, requestId, record.ownership) + } + if (!requestId || !signal) return finish() + let cancellation: Promise | undefined + let cancellationError: unknown + const cancel = () => { + cancellation ??= this.cancelCreationRequest(requestId).catch((error) => { + cancellationError = error + }) + } + signal.addEventListener("abort", cancel, { once: true }) + if (signal.aborted) cancel() + try { + return await finish() + } finally { + signal.removeEventListener("abort", cancel) + await cancellation + if (cancellationError) throw cancellationError + } + } + private finishCreation( result: WorkspaceCreateResult, requestId: string | undefined, @@ -620,6 +829,21 @@ export class WorkspaceManager { record[WORKSPACE_STATE].abortController.signal.throwIfAborted() } + private throwIfCreationRequestCancelled(options: WorkspaceCreateOptions): void { + options.signal?.throwIfAborted() + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + throw new Error(`Workspace creation request ${options.requestId} was cancelled`) + } + } + + private abortWorkspaceLifecycle(id: string, record: WorkspaceRecord): void { + const state = record[WORKSPACE_STATE] + if (!state.abortController.signal.aborted) state.abortController.abort(new WorkspaceLaunchCancelledError(id)) + if (this.pendingWorkspaceCreations.get(record.identityKey) === record) { + this.pendingWorkspaceCreations.delete(record.identityKey) + } + } + private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { // Stop once immediately, then again after launch settlement to cover a child // that became available while cancellation was propagating. @@ -630,6 +854,9 @@ export class WorkspaceManager { await immediateStop await this.runtime.stop(id) + record[WORKSPACE_STATE].cleanupUnconfirmed = false + await record[WORKSPACE_STATE].lifetimeLease?.release() + record[WORKSPACE_STATE].lifetimeLease = undefined this.removeRecord(id, record, true) return record } @@ -875,20 +1102,29 @@ export class WorkspaceManager { if (!record) return const workspace = record - this.opencodeAuth.delete(workspaceId) - this.options.logger.info({ workspaceId, ...info }, "Workspace process exited") - workspace.pid = undefined + const state = record[WORKSPACE_STATE] + state.cleanupUnconfirmed = true workspace.port = undefined workspace.updatedAt = new Date().toISOString() - if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) { + if (state.abortController.signal.aborted || info.requested || info.code === 0) { this.publishStopped(record) } else { workspace.status = "error" workspace.error = `Process exited with code ${info.code}` this.options.eventBus.publish({ type: "workspace.error", workspace }) } + void this.runtime.stop(workspaceId).then(async () => { + if (this.workspaces.get(workspaceId) !== record) return + await state.lifetimeLease?.release() + state.lifetimeLease = undefined + state.cleanupUnconfirmed = false + workspace.pid = undefined + this.opencodeAuth.delete(workspaceId) + }).catch((error) => { + this.options.logger.warn({ workspaceId, err: error }, "Workspace descendant cleanup remains unconfirmed") + }) } } diff --git a/packages/server/src/workspaces/native-session-location.test.ts b/packages/server/src/workspaces/native-session-location.test.ts new file mode 100644 index 000000000..664f28597 --- /dev/null +++ b/packages/server/src/workspaces/native-session-location.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { resolveNativeSessionLocation } from "./native-session-location" + +describe("resolveNativeSessionLocation", () => { + const workspaces = [ + { id: "feature", directory: "/repo-feature" }, + { id: "other", directory: "/repo-other" }, + ] + + it("lets an explicit workspace win over a stale session directory", () => { + assert.deepEqual(resolveNativeSessionLocation("/repo", workspaces, { + id: "session", + workspaceID: "feature", + directory: "/repo-other", + }), { workspaceId: "feature", directory: "/repo-feature" }) + }) + + it("uses directory fallback only for one canonical location", () => { + assert.deepEqual(resolveNativeSessionLocation("/repo", workspaces, { + id: "root-session", + directory: "/repo", + }), { workspaceId: null, directory: "/repo" }) + assert.deepEqual(resolveNativeSessionLocation("/repo", workspaces, { + id: "feature-session", + directory: "/repo-feature", + }), { workspaceId: "feature", directory: "/repo-feature" }) + }) + + it("fails closed for missing, unknown, or ambiguous locations", () => { + assert.throws(() => resolveNativeSessionLocation("/repo", workspaces, { id: "missing" }), /missing/) + assert.throws(() => resolveNativeSessionLocation("/repo", workspaces, { + id: "unknown-workspace", + workspaceID: "missing", + directory: "/repo-feature", + }), /unavailable/) + assert.throws(() => resolveNativeSessionLocation("/repo", [ + { id: "one", directory: "/same" }, + { id: "two", directory: "/same" }, + ], { id: "ambiguous", directory: "/same" }), /ambiguous/) + }) + + it("uses case-sensitive POSIX semantics for WSL-native directories", () => { + const wslWorkspaces = [{ id: "feature", directory: "/home/dev/Repo/feature" }] + assert.deepEqual(resolveNativeSessionLocation("/home/dev/Repo", wslWorkspaces, { + id: "feature-session", + directory: "/home/dev/Repo/topic/../feature", + }), { workspaceId: "feature", directory: "/home/dev/Repo/feature" }) + assert.throws(() => resolveNativeSessionLocation("/home/dev/Repo", wslWorkspaces, { + id: "wrong-case", + directory: "/home/dev/repo/feature", + }), /unresolved/) + }) +}) diff --git a/packages/server/src/workspaces/native-session-location.ts b/packages/server/src/workspaces/native-session-location.ts new file mode 100644 index 000000000..6e38a9b6e --- /dev/null +++ b/packages/server/src/workspaces/native-session-location.ts @@ -0,0 +1,42 @@ +import { nativeWorkspacePathsEqual } from "./workspace-identity" + +export interface NativeWorkspaceLocation { + id: string + directory?: string | null +} + +export interface NativeSessionLocationSource { + id: string + directory?: string | null + workspaceID?: string +} + +export interface ResolvedNativeSessionLocation { + workspaceId: string | null + directory: string +} + +export function resolveNativeSessionLocation( + rootDirectory: string, + workspaces: NativeWorkspaceLocation[], + session: NativeSessionLocationSource, +): ResolvedNativeSessionLocation { + if (session.workspaceID) { + const workspace = workspaces.find((candidate) => candidate.id === session.workspaceID) + if (!workspace?.directory) throw new Error(`Native workspace ${session.workspaceID} is unavailable`) + return { workspaceId: workspace.id, directory: workspace.directory } + } + + if (!session.directory) throw new Error(`Session ${session.id} location is missing`) + const matches: ResolvedNativeSessionLocation[] = [] + if (nativeWorkspacePathsEqual(rootDirectory, session.directory)) { + matches.push({ workspaceId: null, directory: rootDirectory }) + } + for (const workspace of workspaces) { + if (workspace.directory && nativeWorkspacePathsEqual(workspace.directory, session.directory)) { + matches.push({ workspaceId: workspace.id, directory: workspace.directory }) + } + } + if (matches.length !== 1) throw new Error(`Session ${session.id} location is ${matches.length ? "ambiguous" : "unresolved"}`) + return matches[0]! +} diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts index 0efe5add9..757ebfa81 100644 --- a/packages/server/src/workspaces/process-identity.test.ts +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -5,22 +5,25 @@ import { readFileSync } from "node:fs" import { describe, it } from "node:test" import { + createManagerHostIdentity, + managerHostIdentity, + managerProcessIdentity, probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, signalOwnedPosixProcessGroup, signalPosixProcesses, signalWindowsProcesses, startedNoLaterThan, type ProcessIdentity, } from "./process-identity" type Spawn = typeof import("node:child_process").spawnSync -type Call = { command: string; args: readonly string[]; script: string } +type Call = { command: string; args: readonly string[]; script: string; env?: NodeJS.ProcessEnv } const output = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) -const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[]) => { - if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "" }) +const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { + if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "", env: options?.env }) return output(stdout, status, stderr) }) as unknown as Spawn const b64 = (value: string) => Buffer.from(value).toString("base64") const identity = (startTime = "123456"): ProcessIdentity => - ({ pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) + ({ hostId: managerProcessIdentity.hostId, pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) describe("process identity probes", () => { it("parses immutable Linux identities", () => { @@ -63,6 +66,7 @@ describe("process identity probes", () => { const start = "Fri Jul 10 12:34:56 2026" const probe = probePosixProcesses(spawn(`42 1 42 ${start} ${command}\n`, call), 25, "darwin") assert.deepEqual([call.command, call.args], ["ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="]]) + assert.equal(call.env?.TZ, "UTC") assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) }) @@ -121,13 +125,34 @@ describe("process identity probes", () => { it("rejects PID reuse and invalid start ordering", () => { const original = identity("9") - for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], [{ ...original, pid: 43 }, false]] as const) + for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], + [{ ...original, pid: 43 }, false], [{ ...original, hostId: "another-host" }, false]] as const) assert.equal(sameProcess(original, candidate), expected) assert.equal(startedNoLaterThan(original, "10"), true) assert.equal(startedNoLaterThan({ ...original, startOrder: "11" }, "10"), false) assert.equal(startedNoLaterThan({ ...original, startOrder: "Fri Jul 10" }, "10"), false) }) + it("persists one immutable manager start identity", () => { + assert.match(managerHostIdentity, /^[a-f0-9]{64}$/) + assert.equal(managerProcessIdentity.pid, process.pid) + assert.equal(Object.isFrozen(managerProcessIdentity), true) + assert.equal(sameProcess(managerProcessIdentity, { ...managerProcessIdentity, startTime: `${managerProcessIdentity.startTime}-reused` }), false) + }) + + it("scopes manager hosts by WSL distribution and PID namespace", () => { + const identityFor = (wslDistribution: string, pidNamespace: string) => createManagerHostIdentity({ + platform: "linux", + machineIdentity: "shared-machine", + hostname: "host", + wslDistribution, + pidNamespace, + }) + assert.equal(identityFor("Ubuntu", "pid:[1]"), identityFor("ubuntu", "pid:[1]")) + assert.notEqual(identityFor("Ubuntu", "pid:[1]"), identityFor("Debian", "pid:[1]")) + assert.notEqual(identityFor("Ubuntu", "pid:[1]"), identityFor("Ubuntu", "pid:[2]")) + }) + it("returns a POSIX mismatch without a second signal command", () => { const call = {} as Call const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|0||0\n", call), { leader: identity(), groupId: 42, members: [identity()], signal: "SIGTERM" }, 25, "linux") diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts index 2d9b2f070..68c45f4ce 100644 --- a/packages/server/src/workspaces/process-identity.ts +++ b/packages/server/src/workspaces/process-identity.ts @@ -1,6 +1,81 @@ -import type { SpawnSyncReturns, spawnSync } from "node:child_process" +import { spawnSync, type SpawnSyncReturns } from "node:child_process" +import { createHash } from "node:crypto" +import { readFileSync, readlinkSync } from "node:fs" +import os from "node:os" + +function commandMachineIdentity(command: string, args: string[], pattern: RegExp): string | undefined { + try { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 2_000, windowsHide: true }) + if (result.status !== 0) return undefined + return String(result.stdout ?? "").match(pattern)?.[1]?.trim() + } catch { + return undefined + } +} + +function stableMachineIdentity(): string | undefined { + if (process.platform === "linux") { + for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const value = readFileSync(file, "utf8").trim() + if (value) return value + } catch { + // Try the next OS identity source. + } + } + } + if (process.platform === "darwin") { + return commandMachineIdentity("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], /"IOPlatformUUID"\s*=\s*"([^"]+)"/) + } + if (process.platform === "win32") { + return commandMachineIdentity("reg.exe", ["query", String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid"], + /MachineGuid\s+REG_\w+\s+([^\r\n]+)/i) + } + return undefined +} + +function safeHostname(): string { + try { + return os.hostname().trim().toLowerCase() || "unknown-host" + } catch { + return "unknown-host" + } +} + +export function createManagerHostIdentity(sources: { + platform: NodeJS.Platform + machineIdentity?: string + hostname: string + wslDistribution?: string + pidNamespace?: string +}): string { + return createHash("sha256").update([ + sources.platform, + sources.machineIdentity ?? `hostname:${sources.hostname.trim().toLowerCase() || "unknown-host"}`, + sources.wslDistribution?.trim().toLowerCase() ?? "", + sources.pidNamespace?.trim() ?? "", + ].join("\0")).digest("hex") +} + +function linuxPidNamespace(): string | undefined { + if (process.platform !== "linux") return undefined + try { + return readlinkSync("/proc/self/ns/pid") + } catch { + return undefined + } +} + +export const managerHostIdentity = createManagerHostIdentity({ + platform: process.platform, + machineIdentity: stableMachineIdentity(), + hostname: safeHostname(), + wslDistribution: process.platform === "linux" ? process.env.WSL_DISTRO_NAME : undefined, + pidNamespace: linuxPidNamespace(), +}) export interface ProcessIdentity { + hostId?: string pid: number parentPid: number groupId: number @@ -97,7 +172,7 @@ printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" ` const POSIX_IDENTITY_FUNCTIONS = String.raw` -LC_ALL=C; export LC_ALL; set -f +LC_ALL=C; LANG=C; TZ=UTC; export LC_ALL LANG TZ; set -f encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } read_identity() { current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 @@ -207,7 +282,7 @@ function parseDelimitedSnapshot(output: string, requireBootId = false): Map 0 ? groupId : pid, startTime, + processes.set(pid, { hostId: managerHostIdentity, pid, parentPid, groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, startTime, ...(bootId ? { bootId } : {}), ...(startOrder ? { startOrder } : {}) }) } return processes @@ -241,7 +316,7 @@ function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="], { - encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C" }, + encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" }, }), (output) => parsePortablePosixSnapshot(output, filter), { allowEmpty: Boolean(filter) }, @@ -470,6 +546,31 @@ export function probeWslProcesses(spawnCommand: SpawnCommand, distro: string, ti ) } +export function probeHostProcess( + spawnCommand: SpawnCommand, + pid: number, + timeoutMs: number, + platform: NodeJS.Platform = process.platform, +): ProcessSnapshot { + const snapshot = platform === "win32" + ? probeWindowsProcesses(spawnCommand, timeoutMs) + : probePosixProcesses(spawnCommand, timeoutMs, platform, { pids: [pid] }) + if (!snapshot.ok) return snapshot + const identity = snapshot.processes.get(pid) + return { ok: true, processes: identity ? new Map([[pid, identity]]) : new Map() } +} + +function captureManagerProcessIdentity(): Readonly { + const snapshot = probeHostProcess(spawnSync, process.pid, 5_000) + const identity = snapshot.ok ? snapshot.processes.get(process.pid) : undefined + if (!identity) { + throw new Error(`Unable to capture immutable manager process identity${snapshot.ok ? "" : `: ${snapshot.error}`}`) + } + return Object.freeze({ ...identity }) +} + +export const managerProcessIdentity = captureManagerProcessIdentity() + export function signalPosixProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, timeoutMs: number, platform: NodeJS.Platform): GuardedSignalResult { const linux = platform === "linux" diff --git a/packages/server/src/workspaces/repository-authority-path.ts b/packages/server/src/workspaces/repository-authority-path.ts new file mode 100644 index 000000000..3ae5d4af2 --- /dev/null +++ b/packages/server/src/workspaces/repository-authority-path.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto" +import { access, constants, lstat, realpath } from "node:fs/promises" +import path from "node:path" +import { canonicalFilesystemPath, sharedRelativePathIdentity } from "./workspace-identity" + +export async function adjacentRepositoryAuthorityPath( + workspaceFolder: string, + category: string, + suffix = "", +): Promise { + const workspacePath = await canonicalFilesystemPath(workspaceFolder) + const workspaceParent = path.dirname(workspacePath) + let candidate = workspaceParent + const missingSegments: string[] = [] + while (true) { + try { + const canonicalAncestor = await realpath(candidate) + const metadata = await lstat(canonicalAncestor) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return undefined + if (process.platform !== "win32" && process.getuid && metadata.uid !== process.getuid()) return undefined + await access(canonicalAncestor, constants.W_OK) + const canonicalParent = path.resolve(canonicalAncestor, ...missingSegments) + const relativeIdentity = sharedRelativePathIdentity(workspacePath, workspaceParent) + const digest = createHash("sha256").update(relativeIdentity).digest("hex") + return path.join(canonicalParent, ".codenomad", category, `${digest}${suffix}`) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) return undefined + const parent = path.dirname(candidate) + if (parent === candidate) return undefined + missingSegments.unshift(path.basename(candidate)) + candidate = parent + } + } +} diff --git a/packages/server/src/workspaces/repository-lock-ownership.ts b/packages/server/src/workspaces/repository-lock-ownership.ts new file mode 100644 index 000000000..a07853aac --- /dev/null +++ b/packages/server/src/workspaces/repository-lock-ownership.ts @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto" +import { spawnSync } from "node:child_process" +import { lstat, mkdir, readFile, readdir, realpath, rename, rm, statfs, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { + managerProcessIdentity, + managerHostIdentity, + probeHostProcess, + sameProcess, + type ProcessIdentity, + type ProcessSnapshot, +} from "./process-identity" +import { currentWslWindowsDriveMounts, windowsDrivePathIdentity, type WslWindowsDriveMount } from "./wsl-windows-drive" + +const POLL_MS = 40 +const HEARTBEAT_MS = 1_000 +const RELEASE_ATTEMPTS = 3 +const WSL_WINDOWS_FILESYSTEM_TYPES = new Set([0x01021997, 0x53464846]) + +interface OwnerRecord { + token: string + createdAt: number + identity: ProcessIdentity +} + +export interface OwnershipHeartbeat { + token: string + hostId: string + updatedAt: number +} + +interface Claim { + path: string + token: string + owner?: OwnerRecord + heartbeat?: OwnershipHeartbeat + heartbeatFence?: string + ticket?: number +} + +const errorCode = (error: unknown): string | undefined => (error as NodeJS.ErrnoException).code + +async function ensureDirectory(directory: string): Promise { + try { + await mkdir(directory) + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error + } + const metadata = await lstat(directory) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Lock path is not a directory: ${directory}`) +} + +export async function ensurePrivateLockRoot(directory: string): Promise { + try { + await mkdir(directory, { mode: 0o700 }) + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error + } + const metadata = await lstat(directory) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Private lock root is not a real directory: ${directory}`) + if (process.platform !== "win32") { + if (process.getuid && metadata.uid !== process.getuid()) throw new Error(`Private lock root is owned by another user: ${directory}`) + const [canonicalDirectory, filesystem] = await Promise.all([realpath(directory), statfs(directory)]) + if (!privateLockModeIsAcceptable(canonicalDirectory, metadata.mode, process.platform, os.release(), filesystem.type)) { + throw new Error(`Private lock root must have mode 0700: ${directory}`) + } + } +} + +export function privateLockModeIsAcceptable( + directory: string, + mode: number, + platform: NodeJS.Platform = process.platform, + release = os.release(), + filesystemType?: number | bigint, + mounts: readonly WslWindowsDriveMount[] = currentWslWindowsDriveMounts(platform, release), +): boolean { + if (platform === "win32" || (mode & 0o777) === 0o700) return true + const wslWindowsDrive = platform === "linux" && /microsoft/i.test(release) + && Boolean(windowsDrivePathIdentity(path.posix.resolve(directory), platform, release, mounts)) + && filesystemType !== undefined && WSL_WINDOWS_FILESYSTEM_TYPES.has(Number(filesystemType)) + return wslWindowsDrive && (mode & 0o700) === 0o700 +} + +function validOwner(value: unknown, token: string): value is OwnerRecord { + const owner = value as Partial | null + const identity = owner?.identity + return owner?.token === token && Number.isFinite(owner.createdAt) && Boolean(identity) && + Number.isSafeInteger(identity?.pid) && (identity?.pid ?? 0) > 0 && typeof identity?.startTime === "string" && + identity.startTime.length > 0 && typeof identity.hostId === "string" && identity.hostId.length > 0 && + (identity.bootId === undefined || typeof identity.bootId === "string") +} + +function validHeartbeat(value: unknown, token: string, hostId: string): value is OwnershipHeartbeat { + const heartbeat = value as Partial | null + return heartbeat?.token === token && heartbeat.hostId === hostId && Number.isFinite(heartbeat.updatedAt) +} + +async function readHeartbeat(directory: string, token: string, hostId: string): Promise<{ + heartbeat?: OwnershipHeartbeat + fence?: string +}> { + try { + const fence = await readFile(path.join(directory, "heartbeat.json"), "utf8") + const parsed = JSON.parse(fence) as unknown + return validHeartbeat(parsed, token, hostId) ? { heartbeat: parsed, fence } : {} + } catch (error) { + if (errorCode(error) === "ENOENT" || error instanceof SyntaxError) return {} + throw error + } +} + +async function writeHeartbeat(directory: string, token: string): Promise { + const temporaryPath = path.join(directory, `.heartbeat-${randomUUID()}`) + const heartbeat: OwnershipHeartbeat = { token, hostId: managerHostIdentity, updatedAt: Date.now() } + try { + await writeFile(temporaryPath, JSON.stringify(heartbeat), { flag: "wx", mode: 0o600 }) + await rename(temporaryPath, path.join(directory, "heartbeat.json")) + } finally { + await rm(temporaryPath, { force: true }) + } +} + +export function maintainOwnershipHeartbeat(directory: string, token: string): () => Promise { + let update: Promise | undefined + const timer = setInterval(() => { + if (update) return + update = writeHeartbeat(directory, token).catch(() => undefined).finally(() => { update = undefined }) + }, HEARTBEAT_MS) + timer.unref() + return async () => { + clearInterval(timer) + await update + } +} + +async function readClaim(claimsPath: string, token: string): Promise { + const claimPath = path.join(claimsPath, token) + try { + const parsed = JSON.parse(await readFile(path.join(claimPath, "owner.json"), "utf8")) as unknown + const owner = validOwner(parsed, token) ? parsed : undefined + let ticket: number | undefined + try { + const value = await readFile(path.join(claimPath, "ticket"), "utf8") + const parsedTicket = Number(value) + if (Number.isSafeInteger(parsedTicket) && parsedTicket > 0) ticket = parsedTicket + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error + } + const heartbeat = owner ? await readHeartbeat(claimPath, token, owner.identity.hostId!) : {} + return { path: claimPath, token, owner, ticket, ...heartbeat, heartbeatFence: heartbeat.fence } + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined + if (error instanceof SyntaxError) return { path: claimPath, token } + throw error + } +} + +async function claims(claimsPath: string): Promise { + const entries = await readdir(claimsPath, { withFileTypes: true }) + const visible = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + return (await Promise.all(visible.map((entry) => readClaim(claimsPath, entry.name)))) + .filter((claim): claim is Claim => Boolean(claim)) +} + +export function processIdentityIsAlive( + identity: ProcessIdentity, + _claimPath: string, + _heartbeatFence: string, + _now = 0, + probe: (pid: number) => ProcessSnapshot = (pid) => probeHostProcess(spawnSync, pid, 2_000), +): boolean { + // A heartbeat cannot fence a paused foreign process from resuming, so foreign claims fail closed. + if (identity.hostId !== managerHostIdentity) return true + if (sameProcess(identity, managerProcessIdentity)) return true + const snapshot = probe(identity.pid) + if (!snapshot.ok) return true + return sameProcess(identity, snapshot.processes.get(identity.pid)) +} + +export async function retireOwnershipClaim(claimPath: string, expectedHeartbeat: string): Promise { + const retiredPath = path.join(path.dirname(claimPath), `.retired-${path.basename(claimPath)}-${randomUUID()}`) + try { + if (await readFile(path.join(claimPath, "heartbeat.json"), "utf8") !== expectedHeartbeat) return false + } catch (error) { + if (errorCode(error) === "ENOENT") return false + throw error + } + try { + await rename(claimPath, retiredPath) + } catch (error) { + if (errorCode(error) === "ENOENT") return false + throw error + } + if (await readFile(path.join(retiredPath, "heartbeat.json"), "utf8") !== expectedHeartbeat) { + await rename(retiredPath, claimPath) + return false + } + await rm(retiredPath, { recursive: true, force: true }) + return true +} + +export async function retireCurrentOwnershipClaim(claimPath: string): Promise { + try { + return retireOwnershipClaim(claimPath, await readFile(path.join(claimPath, "heartbeat.json"), "utf8")) + } catch (error) { + if (errorCode(error) === "ENOENT") { + try { + await lstat(claimPath) + return false + } catch (claimError) { + if (errorCode(claimError) === "ENOENT") return true + throw claimError + } + } + throw error + } +} + +export async function retireCurrentOwnershipClaimWithRetry( + claimPath: string, + retire: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise { + let failure: unknown + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + try { + if (await retire(claimPath)) return + failure = new Error(`Ownership claim changed while releasing: ${claimPath}`) + } catch (error) { + failure = error + } + if (attempt + 1 < RELEASE_ATTEMPTS) await delay(POLL_MS) + } + throw failure +} + +export async function retireOwnedClaimUntilSuccessful( + claimPath: string, + token: string, + stopHeartbeat: () => Promise, + retire: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise { + while (true) { + await stopHeartbeat() + try { + await retireCurrentOwnershipClaimWithRetry(claimPath, retire) + return + } catch { + stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) + await delay(POLL_MS) + } + } +} + +async function wait(signal?: AbortSignal): Promise { + try { + await delay(POLL_MS, undefined, { signal }) + } catch (error) { + if (signal?.aborted) throw signal.reason + throw error + } +} + +export async function acquireOwnershipQueue( + lockPath: string, + signal?: AbortSignal, + retireCurrent: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise<() => Promise> { + signal?.throwIfAborted() + await ensureDirectory(lockPath) + const claimsPath = path.join(lockPath, "claims") + await ensureDirectory(claimsPath) + + const token = randomUUID() + const preparationPath = path.join(claimsPath, `.prepare-${token}`) + const claimPath = path.join(claimsPath, token) + let stopHeartbeat: (() => Promise) | undefined + await mkdir(preparationPath) + try { + const owner: OwnerRecord = { token, createdAt: Date.now(), identity: managerProcessIdentity } + await writeFile(path.join(preparationPath, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 }) + await writeHeartbeat(preparationPath, token) + await rename(preparationPath, claimPath) + stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) + + const existing = await claims(claimsPath) + const ticket = Math.max(0, ...existing.map((claim) => claim.ticket ?? 0)) + 1 + const ticketPreparationPath = path.join(claimPath, `.ticket-${token}`) + await writeFile(ticketPreparationPath, String(ticket), { flag: "wx", mode: 0o600 }) + await rename(ticketPreparationPath, path.join(claimPath, "ticket")) + + while (true) { + signal?.throwIfAborted() + let blocked = false + for (const claim of await claims(claimsPath)) { + if (claim.token === token) continue + if (!claim.owner) { + throw new Error(`Malformed repository ownership claim: ${claim.path}`) + } + const precedes = claim.ticket === undefined || claim.ticket < ticket || + (claim.ticket === ticket && claim.token < token) + if (!precedes) continue + if (!claim.heartbeat) { + blocked = true + break + } + if (!processIdentityIsAlive(claim.owner.identity, claim.path, claim.heartbeatFence!)) { + if (!await retireOwnershipClaim(claim.path, claim.heartbeatFence!)) continue + continue + } + blocked = true + break + } + if (!blocked) break + await wait(signal) + } + + let releasePending: Promise | undefined + return async () => { + releasePending ??= retireOwnedClaimUntilSuccessful(claimPath, token, stopHeartbeat!) + await releasePending + } + } catch (error) { + if (stopHeartbeat) await retireOwnedClaimUntilSuccessful(claimPath, token, stopHeartbeat, retireCurrent) + await rm(preparationPath, { recursive: true, force: true }) + throw error + } +} diff --git a/packages/server/src/workspaces/repository-mutation-lock.child.ts b/packages/server/src/workspaces/repository-mutation-lock.child.ts new file mode 100644 index 000000000..ddd2bf518 --- /dev/null +++ b/packages/server/src/workspaces/repository-mutation-lock.child.ts @@ -0,0 +1,41 @@ +import { access, appendFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { acquireRepositoryMutation } from "./repository-mutation-lock" +import { writeWorktreeMap } from "./worktree-map" + +const [action, workspaceFolder, value, markerPath] = process.argv.slice(2) + +if (action === "hold") { + const admission = await acquireRepositoryMutation({ workspaceFolder }) + await appendFile(markerPath!, `enter:${value}\n`) + await new Promise((resolve) => setTimeout(resolve, Number(value))) + await appendFile(markerPath!, `exit:${value}\n`) + await admission.release() +} else if (action === "cas") { + try { + await writeWorktreeMap(workspaceFolder!, { + version: 1, + revision: 1, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { [value!]: "root" }, + }, undefined, 0) + process.stdout.write("written") + } catch (error) { + process.stdout.write(error instanceof Error ? error.name : "error") + } +} else if (action === "controlled") { + const admission = await acquireRepositoryMutation({ workspaceFolder }) + const readyPath = path.join(markerPath!, `${value}.ready`) + const releasePath = path.join(markerPath!, `${value}.release`) + await writeFile(readyPath, JSON.stringify([...admission.lockPaths])) + while (true) { + try { + await access(releasePath) + break + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + await admission.release() + await writeFile(path.join(markerPath!, `${value}.done`), "") +} diff --git a/packages/server/src/workspaces/repository-mutation-lock.test.ts b/packages/server/src/workspaces/repository-mutation-lock.test.ts new file mode 100644 index 000000000..40c2b37b4 --- /dev/null +++ b/packages/server/src/workspaces/repository-mutation-lock.test.ts @@ -0,0 +1,447 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { execFileSync } from "node:child_process" +import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { fileURLToPath } from "node:url" +import { managerProcessIdentity } from "./process-identity" +import { + acquireOwnershipQueue, + ensurePrivateLockRoot, + privateLockModeIsAcceptable, + processIdentityIsAlive, + retireCurrentOwnershipClaim, + retireCurrentOwnershipClaimWithRetry, + retireOwnershipClaim, +} from "./repository-lock-ownership" +import { acquireRepositoryMutation } from "./repository-mutation-lock" +import { readWorktreeMapStrict } from "./worktree-map" + +const temporaryDirectories: string[] = [] +const childScript = fileURLToPath(new URL("./repository-mutation-lock.child.ts", import.meta.url)) + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function repository() { + const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-repository-lock-")) + temporaryDirectories.push(directory) + execFileSync("git", ["init"], { cwd: directory }) + return directory +} + +async function plainDirectory() { + const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-lock-directory-")) + temporaryDirectories.push(directory) + return directory +} + +async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + try { + return await readFile(filePath, "utf8") + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + throw new Error(`Timed out waiting for ${filePath}`) +} + +function child(args: string[]): Promise { + return new Promise((resolve, reject) => { + const childProcess = spawn(process.execPath, ["--import", "tsx", childScript, ...args], { + cwd: path.dirname(childScript), + stdio: ["ignore", "pipe", "inherit"], + }) + let stdout = "" + childProcess.stdout.on("data", (chunk) => { stdout += chunk }) + childProcess.once("error", reject) + childProcess.once("exit", (code) => code === 0 ? resolve(stdout) : reject(new Error(`child exited ${code}`))) + }) +} + +describe("repository mutation lock", () => { + it("excludes a mutation in another process", async () => { + const directory = await repository() + const marker = path.join(directory, "marker.txt") + await writeFile(marker, "") + const first = child(["hold", directory, "250", marker]) + while (!(await readFile(marker, "utf8")).includes("enter:250")) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + const second = child(["hold", directory, "0", marker]) + await Promise.all([first, second]) + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), [ + "enter:250", "exit:250", "enter:0", "exit:0", + ]) + }) + + it("allows only one cross-process map compare-and-swap", async () => { + const directory = await repository() + const results = await Promise.all([ + child(["cas", directory, "first"]), + child(["cas", directory, "second"]), + ]) + assert.deepEqual(results.sort(), ["WorktreeMapRevisionConflictError", "written"]) + const map = (await readWorktreeMapStrict(directory)).map + assert.equal(map.revision, 1) + assert.equal(Object.keys(map.parentSessionWorktreeSlug).length, 1) + }) + + it("preserves AbortSignal.reason while waiting", async () => { + const directory = await plainDirectory() + const first = await acquireRepositoryMutation({ workspaceFolder: directory }) + const controller = new AbortController() + const reason = new Error("stop waiting") + const waiting = acquireRepositoryMutation({ workspaceFolder: directory, signal: controller.signal }) + setTimeout(() => controller.abort(reason), 50) + await assert.rejects(waiting, (error) => error === reason) + await first.release() + }) + + it("places a missing non-Git destination lock under its stable immediate parent", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "missing", "workspace") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.equal([...admission.lockPaths].some((lockPath) => + lockPath.startsWith(path.join(directory, "missing", ".codenomad", "repository-locks"))), true) + } finally { + await admission.release() + } + }) + + it("keeps another process blocked after missing intermediate directories appear", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "missing", "workspace") + const marker = path.join(directory, "marker.txt") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + + const waiting = child(["hold", destination, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + + it("shares non-Git admission through a direct directory alias", async () => { + const directory = await plainDirectory() + const target = path.join(directory, "target") + const alias = path.join(directory, "alias") + const marker = path.join(directory, "marker.txt") + await mkdir(target) + await symlink(target, alias, process.platform === "win32" ? "junction" : "dir") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: alias }) + const waiting = child(["hold", target, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + + it("retains the adjacent destination lock after Git appears", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + const marker = path.join(directory, "marker.txt") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + await mkdir(destination) + execFileSync("git", ["init"], { cwd: destination }) + + const waiting = child(["hold", destination, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + + it("keeps an existing empty clone destination empty during admission", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + await mkdir(destination) + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.deepEqual(await readdir(destination), []) + assert.equal([...admission.lockPaths].some((lockPath) => + lockPath.startsWith(path.join(directory, ".codenomad", "repository-locks"))), true) + } finally { + await admission.release() + } + }) + + it("accepts a non-private general .codenomad directory but protects the dedicated lock root", + { skip: process.platform === "win32" }, async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + const generalDirectory = path.join(directory, ".codenomad") + await mkdir(destination) + await mkdir(generalDirectory) + await chmod(generalDirectory, 0o755) + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.equal((await stat(generalDirectory)).mode & 0o777, 0o755) + assert.equal((await stat(path.join(generalDirectory, "repository-locks"))).mode & 0o777, 0o700) + } finally { + await admission.release() + } + }) + + it("fails closed for foreign owners regardless of heartbeat age", () => { + const foreignIdentity = { ...managerProcessIdentity, hostId: "foreign-host", pid: process.pid } + const claimPath = path.join(os.tmpdir(), "foreign-clock-skew-claim") + const oldClockFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: -1e15 }) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, 100, () => { + throw new Error("foreign PID must not be probed") + }), true) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, Number.MAX_SAFE_INTEGER), true) + }) + + it("fails closed when a local process probe fails", () => { + const identity = { ...managerProcessIdentity, pid: managerProcessIdentity.pid + 1, startTime: "other-start" } + assert.equal(processIdentityIsAlive(identity, "local-claim", "local-fence", 0, + () => ({ ok: false, error: "probe unavailable" })), true) + }) + + it("does not admit behind an expired foreign-host heartbeat", async () => { + const directory = await repository() + const claimPath = path.join(directory, ".git", "codenomad", "mutation.lock", "claims", "foreign") + await mkdir(claimPath, { recursive: true }) + const foreignIdentity = { ...managerProcessIdentity, hostId: "foreign-host", startTime: "foreign-start" } + await writeFile(path.join(claimPath, "owner.json"), JSON.stringify({ + token: "foreign", + createdAt: Date.now(), + identity: foreignIdentity, + })) + const heartbeatPath = path.join(claimPath, "heartbeat.json") + const freshAt = Date.now() + const freshFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: freshAt }) + await writeFile(heartbeatPath, freshFence) + await writeFile(path.join(claimPath, "ticket"), "1") + + const controller = new AbortController() + const reason = new Error("fresh foreign owner") + setTimeout(() => controller.abort(reason), 100) + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, signal: controller.signal }), + (error) => error === reason) + + const staleFence = JSON.stringify({ + token: "foreign", + hostId: "foreign-host", + updatedAt: -1e15, + }) + await writeFile(heartbeatPath, staleFence) + const staleController = new AbortController() + const staleReason = new Error("foreign owner remains authoritative") + setTimeout(() => staleController.abort(staleReason), 100) + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, signal: staleController.signal }), + (error) => error === staleReason) + await access(claimPath) + }) + + it("retries a changed ownership fence before reporting release success", async () => { + let attempts = 0 + await retireCurrentOwnershipClaimWithRetry("claim", async () => { + attempts += 1 + return attempts === 2 + }) + assert.equal(attempts, 2) + }) + + it("does not report a missing heartbeat as released while its claim survives", async () => { + const directory = await plainDirectory() + const claimPath = path.join(directory, "claim") + await mkdir(claimPath) + assert.equal(await retireCurrentOwnershipClaim(claimPath), false) + await access(claimPath) + }) + + it("retries claim retirement when a waiting acquisition is cancelled", async () => { + const directory = await plainDirectory() + const lockPath = path.join(directory, "cancel.lock") + const release = await acquireOwnershipQueue(lockPath) + const controller = new AbortController() + const reason = new Error("cancel waiting owner") + let retireAttempts = 0 + const waiting = acquireOwnershipQueue(lockPath, controller.signal, async (claimPath) => { + retireAttempts += 1 + if (retireAttempts <= 4) throw new Error("transient retirement failure") + return retireCurrentOwnershipClaim(claimPath) + }) + const claimsPath = path.join(lockPath, "claims") + while ((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")).length < 2) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + controller.abort(reason) + + await assert.rejects(waiting, (error) => error === reason) + assert.equal(retireAttempts, 5) + await release() + assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) + }) + + it("keeps retrying ownership release until the claim is retired", async () => { + const directory = await plainDirectory() + const lockPath = path.join(directory, "release.lock") + const release = await acquireOwnershipQueue(lockPath) + const claimsPath = path.join(lockPath, "claims") + const [claim] = (await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")) + assert.ok(claim) + const heartbeatPath = path.join(claimsPath, claim, "heartbeat.json") + const original = JSON.parse(await readFile(heartbeatPath, "utf8")) as { updatedAt: number } + await rm(heartbeatPath) + await mkdir(heartbeatPath) + + let settled = false + const releasing = release().then(() => { settled = true }) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(settled, false) + await rm(heartbeatPath, { recursive: true }) + await writeFile(heartbeatPath, JSON.stringify({ + token: claim, + hostId: managerProcessIdentity.hostId, + updatedAt: original.updatedAt, + })) + await releasing + assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) + }) + + it("does not settle repository admission release before cleanup succeeds", async () => { + const directory = await plainDirectory() + let releaseAttempts = 0 + const gate = { + acquireExclusive: async () => async () => { + releaseAttempts += 1 + if (releaseAttempts <= 4) throw new Error("transient release failure") + }, + } + const admission = await acquireRepositoryMutation({ workspaceFolder: directory, gate }) + await admission.release() + assert.equal(releaseAttempts, 5) + }) + + it("retries a transient release while cleaning up failed admission", async () => { + const directory = await repository() + let acquisitions = 0 + let releaseAttempts = 0 + const gate = { + acquireExclusive: async () => { + acquisitions += 1 + if (acquisitions === 2) throw new Error("identity admission failed") + return async () => { + releaseAttempts += 1 + if (releaseAttempts === 1) throw new Error("transient cleanup failure") + } + }, + } + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, gate }), /identity admission failed/) + assert.equal(releaseAttempts, 2) + }) + + it("recovers an owner whose PID has been reused", async () => { + const directory = await repository() + const claimPath = path.join(directory, ".git", "codenomad", "mutation.lock", "claims", "reused") + await mkdir(claimPath, { recursive: true }) + await writeFile(path.join(claimPath, "owner.json"), JSON.stringify({ + token: "reused", + createdAt: Date.now(), + identity: { ...managerProcessIdentity, startTime: `${managerProcessIdentity.startTime}-reused` }, + })) + await writeFile(path.join(claimPath, "heartbeat.json"), JSON.stringify({ + token: "reused", + hostId: managerProcessIdentity.hostId, + updatedAt: Date.now(), + })) + await writeFile(path.join(claimPath, "ticket"), "1") + await child(["hold", directory, "0", path.join(directory, "marker.txt")]) + await assert.rejects(access(claimPath), { code: "ENOENT" }) + }) + + it("keeps a private real fallback root", async () => { + const directory = await plainDirectory() + const privateRoot = path.join(directory, "private") + await ensurePrivateLockRoot(privateRoot) + const metadata = await stat(privateRoot) + if (process.platform !== "win32") assert.equal(metadata.mode & 0o777, 0o700) + + const target = path.join(directory, "target") + const linkedRoot = path.join(directory, "linked") + await mkdir(target) + await symlink(target, linkedRoot, process.platform === "win32" ? "junction" : "dir") + await assert.rejects(ensurePrivateLockRoot(linkedRoot), /not a real directory/) + }) + + it("accepts WSL Windows-drive mode projection without relaxing normal POSIX roots", () => { + const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + const mounts = [{ mountPoint: "/windows/c", drive: "c" }] + assert.equal(privateLockModeIsAcceptable( + "/windows/c/Repo/.codenomad", 0o40777, "linux", wslRelease, 0x01021997, mounts, + ), true) + assert.equal(privateLockModeIsAcceptable( + "/windows/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0x53464846, mounts, + ), true) + assert.equal(privateLockModeIsAcceptable( + "/windows/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0xef53, mounts, + ), false) + assert.equal(privateLockModeIsAcceptable( + "/home/dev/.codenomad", 0o40755, "linux", wslRelease, 0x01021997, + ), false) + assert.equal(privateLockModeIsAcceptable( + "/windows/c/Repo/.codenomad", 0o40755, "linux", "6.8.0-linux", 0x01021997, mounts, + ), false) + assert.equal(privateLockModeIsAcceptable("/home/dev/.codenomad", 0o40700, "linux", "6.8.0-linux"), true) + }) + + it("serializes a simultaneous multi-process acquisition race", async () => { + const directory = await repository() + const marker = path.join(directory, "race.txt") + await writeFile(marker, "") + await Promise.all(Array.from({ length: 8 }, (_, index) => child(["hold", directory, String(10 + index), marker]))) + let active = 0 + let maximum = 0 + for (const line of (await readFile(marker, "utf8")).trim().split(/\r?\n/)) { + active += line.startsWith("enter:") ? 1 : -1 + maximum = Math.max(maximum, active) + assert.ok(active >= 0) + } + assert.equal(maximum, 1) + assert.equal(active, 0) + }) + + it("does not let delayed recovery or release remove a replacement owner", async () => { + const directory = await plainDirectory() + const control = path.join(directory, "control") + const marker = path.join(directory, "marker.txt") + await mkdir(control) + await writeFile(marker, "") + + const first = child(["controlled", directory, "first", control]) + const [lockPath] = JSON.parse(await waitForFile(path.join(control, "first.ready"))) as string[] + const claimsPath = path.join(lockPath!, "claims") + const [firstClaim] = (await readdir(claimsPath)).filter((name) => !name.startsWith(".")) + assert.ok(firstClaim) + const firstFence = await readFile(path.join(claimsPath, firstClaim, "heartbeat.json"), "utf8") + await rename(path.join(claimsPath, firstClaim), path.join(claimsPath, `.detached-${firstClaim}`)) + + const replacement = child(["controlled", directory, "replacement", control]) + await waitForFile(path.join(control, "replacement.ready")) + await retireOwnershipClaim(path.join(claimsPath, firstClaim), firstFence) + await writeFile(path.join(control, "first.release"), "") + await Promise.all([first, waitForFile(path.join(control, "first.done"))]) + + const waiter = child(["hold", directory, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await writeFile(path.join(control, "replacement.release"), "") + await Promise.all([replacement, waiter]) + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) +}) diff --git a/packages/server/src/workspaces/repository-mutation-lock.ts b/packages/server/src/workspaces/repository-mutation-lock.ts new file mode 100644 index 000000000..d01c36ed1 --- /dev/null +++ b/packages/server/src/workspaces/repository-mutation-lock.ts @@ -0,0 +1,121 @@ +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { AsyncLocalStorage } from "node:async_hooks" +import { setTimeout as delay } from "node:timers/promises" +import type { InstanceMutationGate } from "../server/instance-mutation-gate" +import { acquireOwnershipQueue, ensurePrivateLockRoot } from "./repository-lock-ownership" +import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" +import { repositoryMutationKey, resolveRepositoryIdentity } from "./workspace-identity" + +const heldLocks = new AsyncLocalStorage>() + +async function adjacentLockPath(workspaceFolder: string): Promise { + const lockPath = await adjacentRepositoryAuthorityPath(workspaceFolder, "repository-locks", ".lock") + if (!lockPath) throw new Error(`No shared repository lock location is available for ${workspaceFolder}`) + return lockPath +} + +async function lockPathForIdentity( + identity: Awaited>, + workspaceFolder: string, +): Promise { + return identity.commonDir + ? path.join(identity.commonDir, "codenomad", "mutation.lock") + : adjacentLockPath(workspaceFolder) +} + +async function acquireFileLock(lockPath: string, signal?: AbortSignal): Promise<() => Promise> { + if (path.basename(path.dirname(lockPath)) === "repository-locks") { + await mkdir(path.dirname(path.dirname(lockPath)), { recursive: true }) + await ensurePrivateLockRoot(path.dirname(lockPath)) + } else { + await ensurePrivateLockRoot(path.dirname(lockPath)) + } + return acquireOwnershipQueue(lockPath, signal) +} + +async function releaseAll(releases: Array<() => void | Promise>): Promise { + while (releases.length > 0) { + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + await releases[index]!() + releases.splice(index, 1) + } catch {} + } + if (releases.length > 0) await delay(40) + } +} + +export async function acquireRepositoryMutation(params: { + workspaceFolder: string + gate?: Pick + signal?: AbortSignal +}): Promise<{ repositoryKey: string; lockPaths: Set; release: () => Promise }> { + const releases: Array<() => void | Promise> = [] + const acquiredKeys = new Set() + const acquiredPaths = new Set() + const acquire = async (key: string, lockPath: string) => { + if (!acquiredKeys.has(key) && params.gate) { + releases.push(await params.gate.acquireExclusive(key, params.signal)) + acquiredKeys.add(key) + } + if (!acquiredPaths.has(lockPath)) { + if (heldLocks.getStore()?.has(lockPath)) { + acquiredPaths.add(lockPath) + return + } + releases.push(await acquireFileLock(lockPath, params.signal)) + acquiredPaths.add(lockPath) + } + } + + try { + const lexicalKey = repositoryMutationKey(params.workspaceFolder) + await acquire(lexicalKey, await adjacentLockPath(params.workspaceFolder)) + while (true) { + const identity = await resolveRepositoryIdentity(params.workspaceFolder) + const identityLockPath = await lockPathForIdentity(identity, params.workspaceFolder) + try { + await acquire(identity.mutationKey, identityLockPath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!identity.commonDir || !["EACCES", "EPERM", "EROFS"].includes(code ?? "")) throw error + const sharedFallback = await adjacentRepositoryAuthorityPath(identity.commonDir, "repository-locks", ".lock") + if (!sharedFallback) throw error + await acquire(identity.mutationKey, sharedFallback) + } + const confirmed = await resolveRepositoryIdentity(params.workspaceFolder) + if (confirmed.mutationKey === identity.mutationKey) { + let releasePending: Promise | undefined + return { + repositoryKey: confirmed.mutationKey, + lockPaths: acquiredPaths, + release: async () => { + releasePending ??= releaseAll(releases) + await releasePending + }, + } + } + } + } catch (error) { + await releaseAll(releases) + throw error + } +} + +export async function withRepositoryMutation(params: { + workspaceFolder: string + gate?: Pick + signal?: AbortSignal + operation: (repositoryKey: string) => Promise +}): Promise { + // ponytail: this is global-exclusive; use a readers/writer lock if repository mutation throughput matters. + const admission = await acquireRepositoryMutation(params) + try { + params.signal?.throwIfAborted() + const inherited = heldLocks.getStore() ?? new Set() + return await heldLocks.run(new Set([...inherited, ...admission.lockPaths]), () => params.operation(admission.repositoryKey)) + } finally { + await admission.release() + } +} diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 52ec18d79..8eb371638 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -1,6 +1,7 @@ -import { spawnSync } from "child_process" +import { execFile, spawnSync } from "child_process" import { statSync } from "fs" import path from "path" +import { promisify } from "node:util" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) @@ -16,6 +17,7 @@ const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" +const execFileAsync = promisify(execFile) const WINDOWS_SHELL_NAMES = new Set([ "bash", "bash.exe", @@ -65,7 +67,10 @@ export type WslWorkingDirectory = | { kind: "windows"; path: string } export function parseWslUncPath(input: string): WslPath | null { - const normalized = input.trim().replace(/\//g, "\\") + const normalized = input.trim() + .replace(/^\\\\\?\\UNC[\\/]/i, "\\\\") + .replace(/^\\\\\?\\/, "") + .replace(/\//g, "\\") const match = normalized.match(WSL_UNC_PATH_REGEX) if (!match) { return null @@ -91,6 +96,20 @@ export function resolveWslWorkingDirectory(folder: string, distro: string): WslW return windowsFolder ? { kind: "windows", path: windowsFolder } : null } +export async function resolveWslNativePath(folder: string, distro: string): Promise { + const workingDirectory = resolveWslWorkingDirectory(folder, distro) + if (!workingDirectory) throw new Error(`Unable to translate path for WSL distro "${distro}": ${folder}`) + if (workingDirectory.kind === "linux") return workingDirectory.path + const { stdout } = await execFileAsync( + "wsl.exe", + ["--distribution", distro, "--exec", "wslpath", "-au", workingDirectory.path], + { windowsHide: true }, + ) + const nativePath = stdout.trim() + if (!nativePath.startsWith("/")) throw new Error(`WSL returned an invalid native path for ${folder}`) + return nativePath +} + export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec { const wslPath = parseWslUncPath(binaryPath) if (wslPath) { diff --git a/packages/server/src/workspaces/state-root.test.ts b/packages/server/src/workspaces/state-root.test.ts new file mode 100644 index 000000000..c0aa31159 --- /dev/null +++ b/packages/server/src/workspaces/state-root.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, stat } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { ensurePrivateStateDirectory, resolveServerStateRoot } from "./state-root" + +describe("server state root", () => { + it("uses the OS account home independently of home environment variables", () => { + const expected = resolveServerStateRoot(os.userInfo().homedir, process.platform) + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + try { + process.env.HOME = path.join(os.tmpdir(), "different-home") + process.env.USERPROFILE = path.join(os.tmpdir(), "different-profile") + assert.equal(resolveServerStateRoot(), expected) + } finally { + if (previous.HOME === undefined) delete process.env.HOME + else process.env.HOME = previous.HOME + if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previous.USERPROFILE + } + }) + + it("creates and verifies a private per-user directory", async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), "codenomad-state-root-test-")) + const directory = path.join(parent, "state") + try { + await ensurePrivateStateDirectory(directory) + const metadata = await stat(directory) + assert.equal(metadata.isDirectory(), true) + if (process.platform !== "win32") assert.equal(metadata.mode & 0o077, 0) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/workspaces/state-root.ts b/packages/server/src/workspaces/state-root.ts new file mode 100644 index 000000000..b4bd67e69 --- /dev/null +++ b/packages/server/src/workspaces/state-root.ts @@ -0,0 +1,30 @@ +import { lstat, mkdir } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export function resolveServerStateRoot( + homeDirectory = os.userInfo().homedir, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === "win32") return path.win32.join(homeDirectory, "AppData", "Local", "CodeNomad", "state") + if (platform === "darwin") return path.posix.join(homeDirectory, "Library", "Application Support", "CodeNomad", "state") + return path.posix.join(homeDirectory, ".local", "state", "codenomad") +} + +export const serverStateRoot = resolveServerStateRoot() + +export async function ensurePrivateStateDirectory(directory = serverStateRoot): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }) + const metadata = await lstat(directory) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`Server state path is not a real directory: ${directory}`) + } + if (process.platform !== "win32") { + if (process.getuid && metadata.uid !== process.getuid()) { + throw new Error(`Server state directory is owned by another user: ${directory}`) + } + if ((metadata.mode & 0o077) !== 0) { + throw new Error(`Server state directory must not be accessible by other users: ${directory}`) + } + } +} diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index 803070156..9aeaea97c 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -1,12 +1,215 @@ import { realpath, stat } from "node:fs/promises" +import { realpathSync } from "node:fs" +import os from "node:os" import path from "node:path" +import { queryGitRepositoryPaths } from "./git-output" +import { + defaultWslWindowsDrivePathIdentity, + resolveWslWindowsPath, + windowsDrivePathIdentity, + type WslWindowsDriveMount, +} from "./wsl-windows-drive" + +const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i + +function withoutWindowsExtendedPrefix(value: string): string { + return value.replace(/^\\\\\?\\UNC[\\/]/i, "\\\\").replace(/^\\\\\?\\/, "") +} + +interface WorkspaceIdentitySources { + mounts?: readonly WslWindowsDriveMount[] + resolveWslPath?: (distribution: string, linuxPath: string) => string | undefined +} + +function wslUncIdentity(value: string, sources: WorkspaceIdentitySources = {}): string | null { + const match = withoutWindowsExtendedPrefix(value.trim()).replace(/\//g, "\\").match(WSL_UNC_PATH_REGEX) + if (!match) return null + const linuxPath = `/${(match[2] ?? "").split(/\\+/).filter(Boolean).join("/")}` + const windowsPath = (sources.resolveWslPath ?? resolveWslWindowsPath)(match[1]!, linuxPath) + const distroIdentity = `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` + if (windowsPath) return windowsDriveIdentity(windowsPath, "win32", undefined, sources.mounts) ?? distroIdentity + return defaultWslWindowsDrivePathIdentity(linuxPath) ?? distroIdentity +} + +function windowsDriveIdentity( + value: string, + platform: NodeJS.Platform, + release = os.release(), + mounts?: readonly WslWindowsDriveMount[], +): string | null { + return windowsDrivePathIdentity(platform === "win32" ? withoutWindowsExtendedPrefix(value) : value, + platform, release, mounts) +} export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { const pathApi = platform === "win32" ? path.win32 : path.posix - const normalized = pathApi.normalize(value) + const withoutExtendedPrefix = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value + const normalized = pathApi.normalize(withoutExtendedPrefix) return platform === "win32" ? normalized.toLowerCase() : normalized } +export function canonicalFilesystemIdentity( + value: string, + platform: NodeJS.Platform = process.platform, + release = os.release(), + sources: WorkspaceIdentitySources = {}, +): string { + const input = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value + const wslIdentity = platform === "win32" ? wslUncIdentity(input, sources) : null + if (wslIdentity) { + if (platform === process.platform) { + try { + return wslUncIdentity(realpathSync.native(path.win32.resolve(input)), sources) ?? wslIdentity + } catch { + // Keep a stable lexical identity for missing WSL paths. + } + } + return wslIdentity + } + const pathApi = platform === "win32" ? path.win32 : path.posix + const absolutePath = pathApi.resolve(input) + const canonicalPath = platform === process.platform ? canonicalFilesystemPathSync(absolutePath) : absolutePath + const driveIdentity = windowsDriveIdentity(canonicalPath, platform, release, sources.mounts) + if (driveIdentity) return driveIdentity + if (platform === process.platform) { + return normalizeWorkspaceIdentityPath(canonicalPath, platform) + } + return normalizeWorkspaceIdentityPath(absolutePath, platform) +} + +export function sharedRelativePathIdentity( + value: string, + ancestor: string, + platform: NodeJS.Platform = process.platform, + release = os.release(), + sources: WorkspaceIdentitySources = {}, +): string { + const pathApi = platform === "win32" ? path.win32 : path.posix + const relative = pathApi.relative(pathApi.resolve(ancestor), pathApi.resolve(value)) + const segments = relative.split(/[\\/]+/).filter(Boolean) + if (segments.some((segment) => segment === "..")) throw new Error(`${value} is outside ${ancestor}`) + const identity = segments.join("/") + const valueWsl = wslUncIdentity(value, sources) + const ancestorWsl = wslUncIdentity(ancestor, sources) + const valueDrive = windowsDriveIdentity(pathApi.resolve(value), platform, release, sources.mounts) + ?? (valueWsl?.startsWith("windows-drive:") ? valueWsl : null) + const ancestorDrive = windowsDriveIdentity(pathApi.resolve(ancestor), platform, release, sources.mounts) + ?? (ancestorWsl?.startsWith("windows-drive:") ? ancestorWsl : null) + const windowsDrive = valueDrive && ancestorDrive + return (platform === "win32" && !valueWsl) || windowsDrive ? identity.toLowerCase() : identity +} + +export function workspaceIdentityPathsEqual( + left: string, + right: string, + platform: NodeJS.Platform = process.platform, + sources: WorkspaceIdentitySources = {}, +): boolean { + return canonicalFilesystemIdentity(left, platform, undefined, sources) + === canonicalFilesystemIdentity(right, platform, undefined, sources) +} + +export function nativeWorkspacePathsEqual(left: string, right: string): boolean { + const leftIsPosix = left.startsWith("/") + const rightIsPosix = right.startsWith("/") + if (leftIsPosix || rightIsPosix) { + return leftIsPosix && rightIsPosix && path.posix.resolve(left) === path.posix.resolve(right) + } + return workspaceIdentityPathsEqual(left, right) +} + +export function repositoryMutationKey(repoRoot: string, platform: NodeJS.Platform = process.platform): string { + return `repo:${canonicalFilesystemIdentity(repoRoot, platform)}` +} + +export interface RepositoryIdentity { + repositoryRoot: string + commonDir?: string + isGitRepository: boolean + mutationKey: string +} + +export class RepositoryIdentityError extends Error { + constructor(message: string, readonly cause?: unknown) { + super(message) + this.name = "RepositoryIdentityError" + } +} + +function canonicalFilesystemPathSync(value: string): string { + const absolutePath = path.resolve(value) + let ancestor = absolutePath + const suffix: string[] = [] + while (true) { + try { + return path.resolve(realpathSync.native(ancestor), ...suffix.reverse()) + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return absolutePath + const parent = path.dirname(ancestor) + if (parent === ancestor) return absolutePath + suffix.push(path.basename(ancestor)) + ancestor = parent + } + } +} + +export async function canonicalFilesystemPath(value: string): Promise { + const absolutePath = path.resolve(value) + let ancestor = absolutePath + const suffix: string[] = [] + while (true) { + try { + return path.resolve(await realpath(ancestor), ...suffix.reverse()) + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return absolutePath + const parent = path.dirname(ancestor) + if (parent === ancestor) return absolutePath + suffix.push(path.basename(ancestor)) + ancestor = parent + } + } +} + +export async function resolveRepositoryIdentity(folder: string): Promise { + const workspacePath = await canonicalFilesystemPath(folder) + try { + const { topLevel: repositoryRootValue, commonDir: commonDirValue } = await queryGitRepositoryPaths(workspacePath) + if (repositoryRootValue && commonDirValue) { + const repositoryRoot = await canonicalFilesystemPath(repositoryRootValue) + const commonDir = await canonicalFilesystemPath(path.isAbsolute(commonDirValue) + ? commonDirValue + : path.resolve(workspacePath, commonDirValue)) + return { + repositoryRoot, + commonDir, + isGitRepository: true, + mutationKey: repositoryMutationKey(commonDir), + } + } + } catch (error) { + if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) { + return { + repositoryRoot: workspacePath, + isGitRepository: false, + mutationKey: repositoryMutationKey(workspacePath), + } + } + const stderr = String((error as { stderr?: unknown }).stderr ?? "") + if (!/not a git repository/i.test(stderr)) { + throw new RepositoryIdentityError(`Unable to resolve Git repository identity for ${workspacePath}`, error) + } + } + return { + repositoryRoot: workspacePath, + isGitRepository: false, + mutationKey: repositoryMutationKey(workspacePath), + } +} + +export async function resolveRepositoryMutationKey(folder: string): Promise { + return (await resolveRepositoryIdentity(folder)).mutationKey +} + export async function resolveWorkspaceIdentity( folder: string, rootDir: string, @@ -23,10 +226,10 @@ export async function resolveWorkspaceIdentity( : normalizeWorkspaceIdentityPath(workspacePath), } } catch { - // Preserve the existing launch behavior when the path cannot be resolved yet. + const workspacePath = await canonicalFilesystemPath(submittedPath) return { - workspacePath: submittedPath, - identityKey: normalizeWorkspaceIdentityPath(submittedPath), + workspacePath, + identityKey: normalizeWorkspaceIdentityPath(workspacePath), } } } diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.child.ts b/packages/server/src/workspaces/workspace-lifetime-lease.child.ts new file mode 100644 index 000000000..ff935b8ef --- /dev/null +++ b/packages/server/src/workspaces/workspace-lifetime-lease.child.ts @@ -0,0 +1,18 @@ +import { readFile, writeFile } from "node:fs/promises" +import { setTimeout as delay } from "node:timers/promises" +import { acquireWorkspaceLifetimeLease } from "./workspace-lifetime-lease" + +const [workspaceFolder, readyPath, stopPath] = process.argv.slice(2) +if (!workspaceFolder || !readyPath || !stopPath) throw new Error("Missing lease child arguments") + +const lease = await acquireWorkspaceLifetimeLease(workspaceFolder, `child-${process.pid}`) +await writeFile(readyPath, "ready") +while (true) { + try { + await readFile(stopPath) + break + } catch { + await delay(20) + } +} +await lease.release() diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts new file mode 100644 index 000000000..4bef6fe9e --- /dev/null +++ b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict" +import { spawn, type ChildProcess } from "node:child_process" +import { execFileSync } from "node:child_process" +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { fileURLToPath } from "node:url" +import { + acquireWorkspaceLifetimeLease, + hasWorkspaceLifetimeBlocker, + workspaceLifetimeAuthorityRoots, +} from "./workspace-lifetime-lease" +import { retireCurrentOwnershipClaim } from "./repository-lock-ownership" + +const childScript = fileURLToPath(new URL("./workspace-lifetime-lease.child.ts", import.meta.url)) +const temporaryDirectories: string[] = [] +const children = new Set() + +afterEach(async () => { + for (const child of children) child.kill() + children.clear() + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-lifetime-lease-test-")) + temporaryDirectories.push(directory) + return directory +} + +async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + try { + await readFile(filePath) + return + } catch { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + } + throw new Error(`Timed out waiting for ${filePath}`) +} + +function startChild(directory: string, readyPath: string, stopPath: string): ChildProcess { + const child = spawn(process.execPath, ["--import", "tsx", childScript, directory, readyPath, stopPath], { + cwd: path.dirname(childScript), + stdio: "inherit", + }) + children.add(child) + return child +} + +function exited(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once("error", reject) + child.once("exit", () => { children.delete(child); resolve() }) + }) +} + +describe("workspace lifetime lease", () => { + it("excludes its own generation and releases cleanly", async () => { + const directory = await temporaryDirectory() + const lease = await acquireWorkspaceLifetimeLease(directory, "workspace") + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory, excludingToken: lease.token }), false) + await lease.release() + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), false) + }) + + it("shares one release operation between concurrent callers", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + let allowRetirement!: () => void + const retirementGate = new Promise((resolve) => { allowRetirement = resolve }) + const attempts = new Map() + const lease = await acquireWorkspaceLifetimeLease(directory, "workspace", async (claimPath) => { + attempts.set(claimPath, (attempts.get(claimPath) ?? 0) + 1) + await retirementGate + return retireCurrentOwnershipClaim(claimPath) + }) + + const firstRelease = lease.release() + const secondRelease = lease.release() + assert.equal(firstRelease, secondRelease) + while (attempts.size === 0) await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal([...attempts.values()].reduce((total, count) => total + count, 0), 1) + + allowRetirement() + await firstRelease + const authority = await workspaceLifetimeAuthorityRoots(directory) + assert.equal(attempts.size, authority.roots.length) + assert.ok([...attempts.values()].every((count) => count === 1)) + }) + + it("observes another process and recovers its stale lease", async () => { + const directory = await temporaryDirectory() + const readyPath = path.join(directory, "ready") + const stopPath = path.join(directory, "stop") + const child = startChild(directory, readyPath, stopPath) + await waitForFile(readyPath) + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) + const childExit = exited(child) + child.kill("SIGKILL") + await childExit + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), false) + }) + + it("keeps another process blocked until its clean release completes", async () => { + const directory = await temporaryDirectory() + const readyPath = path.join(directory, "ready") + const stopPath = path.join(directory, "stop") + const child = startChild(directory, readyPath, stopPath) + await waitForFile(readyPath) + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) + const childExit = exited(child) + await writeFile(stopPath, "stop") + await childExit + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), false) + }) + + it("publishes repository lifetime authority where Windows and WSL can both observe it", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + const lease = await acquireWorkspaceLifetimeLease(directory, "workspace") + const authority = await workspaceLifetimeAuthorityRoots(directory) + const repositoryRoot = path.join(directory, ".git", "codenomad", "workspace-leases") + assert.ok(authority.roots.includes(repositoryRoot)) + await access(path.join(repositoryRoot, lease.token)) + + const foreignHostPath = process.platform === "win32" + ? "/home/dev/repository" + : String.raw`\\wsl.localhost\Ubuntu\home\dev\repository` + for (const root of authority.roots) { + const recordPath = path.join(root, lease.token, "lease.json") + const record = JSON.parse(await readFile(recordPath, "utf8")) as { workspaceFolder: string } + record.workspaceFolder = foreignHostPath + await writeFile(recordPath, JSON.stringify(record)) + } + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) + await lease.release() + }) + + it("finds a pre-Git lease later through a linked worktree", async () => { + const container = await temporaryDirectory() + const repository = path.join(container, "repository") + const linked = path.join(container, "linked") + await mkdir(repository) + const lease = await acquireWorkspaceLifetimeLease(repository, "workspace") + + execFileSync("git", ["init", "-b", "main"], { cwd: repository }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repository }) + execFileSync("git", ["config", "user.name", "Test"], { cwd: repository }) + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd: repository }) + execFileSync("git", ["worktree", "add", "-b", "linked", linked], { cwd: repository }) + + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: linked }), true) + await lease.release() + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: linked }), false) + }) + + it("fails closed when a nonstandard common directory hides pre-Git adjacent authority", async () => { + const container = await temporaryDirectory() + const repository = path.join(container, "repository") + const commonDirectory = path.join(container, "git-data") + await mkdir(repository) + execFileSync("git", ["init", "--separate-git-dir", commonDirectory], { cwd: repository }) + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: repository }), true) + }) + + it("does not discard a partial publication after bounded retirement failures", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + const authority = await workspaceLifetimeAuthorityRoots(directory) + const repositoryRoot = path.join(directory, ".git", "codenomad", "workspace-leases") + await mkdir(path.dirname(repositoryRoot), { recursive: true, mode: 0o700 }) + await writeFile(repositoryRoot, "block publication") + let attempts = 0 + let allowRetirement = false + let settled = false + const acquisition = acquireWorkspaceLifetimeLease(directory, "workspace", async (claimPath) => { + attempts += 1 + return allowRetirement ? retireCurrentOwnershipClaim(claimPath) : false + }) + acquisition.then(() => { settled = true }, () => { settled = true }) + while (attempts < 4) await new Promise((resolve) => setTimeout(resolve, 10)) + assert.equal(settled, false) + allowRetirement = true + await assert.rejects(acquisition) + assert.ok(attempts > 3) + const adjacentRoot = authority.roots.find((root) => root !== repositoryRoot)! + assert.deepEqual((await readdir(adjacentRoot)).filter((entry) => !entry.startsWith(".")), []) + }) +}) diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts new file mode 100644 index 000000000..b60e09d60 --- /dev/null +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto" +import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { managerProcessIdentity, type ProcessIdentity } from "./process-identity" +import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" +import { + ensurePrivateLockRoot, + maintainOwnershipHeartbeat, + processIdentityIsAlive, + retireCurrentOwnershipClaim, + retireOwnedClaimUntilSuccessful, + retireOwnershipClaim, + type OwnershipHeartbeat, +} from "./repository-lock-ownership" +import { canonicalFilesystemIdentity, resolveRepositoryIdentity } from "./workspace-identity" + +interface LeaseRecord { + token: string + workspaceId: string + workspaceFolder: string + directoryKey: string + repositoryKey: string + createdAt: number + owner: ProcessIdentity +} + +export interface WorkspaceLifetimeLease { + token: string + directoryKey: string + repositoryKey: string + release: () => Promise +} + +function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { + const record = value as Partial | null + return record?.token === token && typeof record.workspaceId === "string" && record.workspaceId.length > 0 + && typeof record.workspaceFolder === "string" && record.workspaceFolder.length > 0 + && typeof record.directoryKey === "string" && typeof record.repositoryKey === "string" + && Number.isFinite(record.createdAt) && Boolean(record.owner) + && Number.isSafeInteger(record.owner?.pid) && (record.owner?.pid ?? 0) > 0 + && typeof record.owner?.startTime === "string" && record.owner.startTime.length > 0 + && typeof record.owner?.hostId === "string" && record.owner.hostId.length > 0 +} + +export async function workspaceLifetimeAuthorityRoots(workspaceFolder: string): Promise<{ + repositoryKey: string + roots: string[] + complete: boolean +}> { + const identity = await resolveRepositoryIdentity(workspaceFolder) + const authorityFolders = [workspaceFolder] + const standardCommonDirectory = !identity.commonDir || path.basename(identity.commonDir) === ".git" + if (identity.commonDir && standardCommonDirectory) authorityFolders.push(path.dirname(identity.commonDir)) + const adjacent = await Promise.all([...new Set(authorityFolders)].map( + (folder) => adjacentRepositoryAuthorityPath(folder, "workspace-leases"), + )) + const roots = [...adjacent, identity.commonDir && path.join(identity.commonDir, "codenomad", "workspace-leases")] + .filter((value): value is string => Boolean(value)) + if (roots.length === 0) throw new Error(`No shared workspace lease location is available for ${workspaceFolder}`) + return { repositoryKey: identity.mutationKey, roots: [...new Set(roots)], complete: standardCommonDirectory } +} + +async function ensureLeaseRoot(root: string): Promise { + await mkdir(path.dirname(root), { recursive: true, mode: 0o700 }) + await ensurePrivateLockRoot(path.dirname(root)) + await ensurePrivateLockRoot(root) +} + +async function readLease(root: string, entryName: string): Promise<{ + path: string + record?: LeaseRecord + heartbeat?: OwnershipHeartbeat + heartbeatFence?: string +} | undefined> { + const leasePath = path.join(root, entryName) + try { + const metadata = await lstat(leasePath) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return { path: leasePath } + const parsed = JSON.parse(await readFile(path.join(leasePath, "lease.json"), "utf8")) as unknown + if (!isLeaseRecord(parsed, entryName)) return { path: leasePath } + try { + const heartbeatFence = await readFile(path.join(leasePath, "heartbeat.json"), "utf8") + const heartbeat = JSON.parse(heartbeatFence) as OwnershipHeartbeat + if (heartbeat.token !== entryName || heartbeat.hostId !== parsed.owner.hostId || !Number.isFinite(heartbeat.updatedAt)) { + return { path: leasePath, record: parsed } + } + return { path: leasePath, record: parsed, heartbeat, heartbeatFence } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) { + return { path: leasePath, record: parsed } + } + throw error + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined + if (error instanceof SyntaxError) return { path: leasePath } + throw error + } +} + +async function activeLeases(root: string): Promise { + await ensureLeaseRoot(root) + const entries = await readdir(root, { withFileTypes: true }) + const leases = await Promise.all(entries + .filter((entry) => !entry.name.startsWith(".")) + .map((entry) => readLease(root, entry.name))) + const active: LeaseRecord[] = [] + for (const lease of leases) { + if (!lease) continue + if (!lease.record) throw new Error(`Malformed workspace lifetime lease: ${lease.path}`) + if (!lease.heartbeat) { + active.push(lease.record) + continue + } + if (!processIdentityIsAlive(lease.record.owner, lease.path, lease.heartbeatFence!)) { + if (!await retireOwnershipClaim(lease.path, lease.heartbeatFence!)) continue + continue + } + active.push(lease.record) + } + return active +} + +export async function acquireWorkspaceLifetimeLease( + workspaceFolder: string, + workspaceId: string, + retireCurrent: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise { + const authority = await workspaceLifetimeAuthorityRoots(workspaceFolder) + const token = randomUUID() + const record: LeaseRecord = { + token, + workspaceId, + workspaceFolder: path.resolve(workspaceFolder), + directoryKey: canonicalFilesystemIdentity(workspaceFolder), + repositoryKey: authority.repositoryKey, + createdAt: Date.now(), + owner: managerProcessIdentity, + } + const claims: Array<{ path: string; stopHeartbeat: () => Promise }> = [] + try { + for (const root of authority.roots) { + await ensureLeaseRoot(root) + const preparationPath = path.join(root, `.prepare-${token}`) + const leasePath = path.join(root, token) + await mkdir(preparationPath, { mode: 0o700 }) + try { + await writeFile(path.join(preparationPath, "lease.json"), JSON.stringify(record), { flag: "wx", mode: 0o600 }) + await writeFile(path.join(preparationPath, "heartbeat.json"), JSON.stringify({ + token, + hostId: managerProcessIdentity.hostId, + updatedAt: Date.now(), + }), { flag: "wx", mode: 0o600 }) + await rename(preparationPath, leasePath) + } catch (error) { + await rm(preparationPath, { recursive: true, force: true }) + throw error + } + claims.push({ path: leasePath, stopHeartbeat: maintainOwnershipHeartbeat(leasePath, token) }) + } + } catch (error) { + await Promise.all(claims.map((claim) => + retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent))) + throw error + } + + let releasePending: Promise | undefined + const release = async () => { + const failures: unknown[] = [] + for (let index = claims.length - 1; index >= 0; index -= 1) { + const claim = claims[index]! + try { + await retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent) + claims.splice(index, 1) + } catch (error) { + failures.push(error) + } + } + if (failures.length > 0) throw new AggregateError(failures, "Failed to release workspace lifetime lease") + } + return { + token, + directoryKey: record.directoryKey, + repositoryKey: record.repositoryKey, + release: () => releasePending ??= release(), + } +} + +export async function hasWorkspaceLifetimeBlocker(params: { + workspaceFolder: string + repositoryKey?: string + excludingToken?: string +}): Promise { + const authority = await workspaceLifetimeAuthorityRoots(params.workspaceFolder) + for (const root of authority.roots) { + for (const lease of await activeLeases(root)) { + if (lease.token !== params.excludingToken) return true + } + } + return !authority.complete +} diff --git a/packages/server/src/workspaces/worktree-deletion.test.ts b/packages/server/src/workspaces/worktree-deletion.test.ts new file mode 100644 index 000000000..d69014773 --- /dev/null +++ b/packages/server/src/workspaces/worktree-deletion.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata" +import { deleteWorktreeTransaction } from "./worktree-deletion" +import { familiesAtWorktree, type NativeSessionState } from "./worktree-session-move" + +interface TestSession { + id: string + parentID?: string + directory: string + workspaceID?: string + metadata?: unknown +} + +function harness(options: { + gitError?: Error + ambiguousSessionId?: string + busySessionId?: string + statusType?: string + legacySlug?: string + mapSlug?: string + mapReadError?: Error + emptyNativeInventory?: boolean + missingTargetInventory?: boolean +} = {}) { + const sessions: TestSession[] = options.emptyNativeInventory ? [] : [ + { + id: "root-session", + directory: "/repo-other", + workspaceID: "other-workspace", + metadata: { codenomad: { worktreeSlug: options.legacySlug ?? "feature" } }, + }, + { id: "child-session", parentID: "root-session", directory: "/repo-feature", workspaceID: "feature-workspace" }, + ] + const calls: string[] = [] + const client = { + session: { + list: async () => ({ data: sessions.map((session) => ({ ...session })) }), + status: async () => ({ + data: options.busySessionId ? { [options.busySessionId]: { type: options.statusType ?? "busy" } } : {}, + }), + }, + experimental: { + controlPlane: { + moveSession: async ({ sessionID, destination }: { sessionID: string; destination: { directory: string } }) => { + const id = destination.directory === "/repo-feature" + ? "feature-workspace" + : destination.directory === "/repo-other" ? "other-workspace" : null + calls.push(`warp:${sessionID}:${id ?? "root"}`) + const session = sessions.find((candidate) => candidate.id === sessionID)! + session.workspaceID = id ?? undefined + session.directory = destination.directory + if (sessionID === options.ambiguousSessionId && id === null) throw new Error("response lost") + return { data: true } + }, + }, + workspace: { + syncList: async () => ({ data: true }), + list: async () => ({ data: options.emptyNativeInventory ? [] : [ + ...(!options.missingTargetInventory ? [{ id: "feature-workspace", directory: "/repo-feature" }] : []), + { id: "other-workspace", directory: "/repo-other" }, + ] }), + remove: async () => { calls.push("remove-native"); return { data: true } }, + }, + }, + } as unknown as OpencodeClient + const metadata = { + setWorktreeSlug: async (_instanceId: string, sessionId: string) => { + calls.push(`metadata:${sessionId}`) + return {} + }, + } as unknown as OpencodeYoloPersistence + const run = () => deleteWorktreeTransaction({ + instanceId: "workspace", + workspaceFolder: "/repo", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + client, + sessionMetadataPersistence: metadata, + removeGitWorktree: async () => { + calls.push("git") + if (options.gitError) throw options.gitError + }, + readMap: async () => { + if (options.mapReadError) throw options.mapReadError + return { + version: 1 as const, + revision: 4, + defaultWorktreeSlug: "feature", + parentSessionWorktreeSlug: { "root-session": options.mapSlug ?? "feature", keep: "other" }, + } + }, + writeMap: async (_folder, map) => { calls.push(`map:${JSON.stringify(map)}`) }, + }) + return { calls, run, sessions } +} + +describe("deleteWorktreeTransaction", () => { + it("honors explicit workspace identity over a stale matching directory", () => { + const state = { + sessions: [ + { id: "explicit-other", workspaceID: "other-workspace", directory: "/repo-feature" }, + { id: "directory-fallback", directory: "/repo-feature" }, + ], + workspaces: [ + { id: "feature-workspace", directory: "/repo-feature" }, + { id: "other-workspace", directory: "/repo-other" }, + ], + } as NativeSessionState + + assert.deepEqual( + familiesAtWorktree(state, { slug: "feature", directory: "/repo-feature", kind: "worktree" }) + .map((session) => session.id), + ["directory-fallback"], + ) + }) + + it("selects a whole authoritative family and reconciles a lost successful warp response", async () => { + const test = harness({ ambiguousSessionId: "child-session" }) + await test.run() + + assert.deepEqual(test.sessions.map(({ directory, workspaceID }) => ({ directory, workspaceID })), [ + { directory: "/repo", workspaceID: undefined }, + { directory: "/repo", workspaceID: undefined }, + ]) + assert.deepEqual(test.calls.slice(0, 2), ["warp:root-session:root", "warp:child-session:root"]) + assert.ok(test.calls.indexOf("git") > test.calls.indexOf("warp:child-session:root")) + assert.ok(test.calls.includes("metadata:root-session")) + assert.ok(test.calls.some((call) => call.includes('"defaultWorktreeSlug":"root"'))) + assert.equal(test.calls.at(-1), "remove-native") + }) + + it("restores actual locations and preserves metadata and map when Git fails", async () => { + const test = harness({ gitError: new Error("contains modified files") }) + await assert.rejects(test.run, /modified files/) + + assert.deepEqual(test.sessions.map(({ directory, workspaceID }) => ({ directory, workspaceID })), [ + { directory: "/repo-other", workspaceID: "other-workspace" }, + { directory: "/repo-feature", workspaceID: "feature-workspace" }, + ]) + assert.deepEqual(test.calls, [ + "warp:root-session:root", + "warp:child-session:root", + "git", + "warp:child-session:feature-workspace", + "warp:root-session:other-workspace", + ]) + }) + + for (const statusType of ["busy", "working", "compacting", "pending", "retry"]) { + it(`rejects an authoritative ${statusType} family before any warp or Git removal`, async () => { + const test = harness({ busySessionId: "root-session", statusType }) + await assert.rejects(test.run, /working, compacting, or pending/) + assert.deepEqual(test.calls, []) + }) + } + + it("clears every moved root binding even when its prior slug differs from the deleted worktree", async () => { + const test = harness({ legacySlug: "alternate", mapSlug: "alternate" }) + await test.run() + + assert.ok(test.calls.includes("metadata:root-session")) + const mapCall = test.calls.find((call) => call.startsWith("map:")) + assert.ok(mapCall) + assert.deepEqual(JSON.parse(mapCall.slice(4)).parentSessionWorktreeSlug, { keep: "other" }) + }) + + it("does not overwrite map fallback when strict cleanup reading fails", async () => { + const test = harness({ mapReadError: new Error("malformed map") }) + await test.run() + assert.equal(test.calls.some((call) => call.startsWith("map:")), false) + }) + + it("deletes an empty worktree absent from native workspace inventory", async () => { + const test = harness({ emptyNativeInventory: true }) + await test.run() + + assert.equal(test.calls.includes("git"), true) + assert.equal(test.calls.some((call) => call.startsWith("warp:")), false) + assert.equal(test.calls.includes("remove-native"), false) + }) + + it("fails closed when a session workspace is absent from native inventory", async () => { + const test = harness({ missingTargetInventory: true }) + await assert.rejects(test.run, /Unable to account for session child-session workspace feature-workspace/) + assert.deepEqual(test.calls, []) + }) +}) diff --git a/packages/server/src/workspaces/worktree-deletion.ts b/packages/server/src/workspaces/worktree-deletion.ts new file mode 100644 index 000000000..e6a262bdd --- /dev/null +++ b/packages/server/src/workspaces/worktree-deletion.ts @@ -0,0 +1,113 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorktreeDescriptor, WorktreeMap } from "../api-types" +import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata" +import { removeWorktree } from "./git-worktrees" +import type { LogLike } from "./git-worktrees" +import { readWorktreeMapStrict, writeWorktreeMap } from "./worktree-map" +import { + assertSessionsMovable, + familiesAtWorktree, + loadNativeSessionState, + moveNativeSessions, + sameWorktreeDirectory, + WorktreeRollbackIncompleteError, +} from "./worktree-session-move" + +interface DeleteWorktreeTransactionParams { + instanceId: string + workspaceFolder: string + nativeWorkspaceFolder?: string + nativeTargetDirectory?: string + target: WorktreeDescriptor + force?: boolean + client: OpencodeClient + sessionMetadataPersistence: OpencodeYoloPersistence + logger?: LogLike + removeGitWorktree?: typeof removeWorktree + readMap?: (workspaceFolder: string, logger?: LogLike) => Promise + writeMap?: typeof writeWorktreeMap + signal?: AbortSignal +} + +function metadataWorktreeSlug(metadata: unknown): string | undefined { + if (!metadata || typeof metadata !== "object") return undefined + const codenomad = (metadata as Record).codenomad + if (!codenomad || typeof codenomad !== "object") return undefined + const slug = (codenomad as Record).worktreeSlug + return typeof slug === "string" ? slug : undefined +} + +export async function deleteWorktreeTransaction(params: DeleteWorktreeTransactionParams): Promise { + params.signal?.throwIfAborted() + const nativeWorkspaceFolder = params.nativeWorkspaceFolder ?? params.workspaceFolder + const nativeTarget = { ...params.target, directory: params.nativeTargetDirectory ?? params.target.directory } + const state = await loadNativeSessionState(params.client, nativeWorkspaceFolder) + const workspaceIds = new Set(state.workspaces.map((workspace) => workspace.id)) + const unaccountedSession = state.sessions.find((session) => session.workspaceID && !workspaceIds.has(session.workspaceID)) + if (unaccountedSession) { + throw new Error(`Unable to account for session ${unaccountedSession.id} workspace ${unaccountedSession.workspaceID}`) + } + const selected = familiesAtWorktree(state, nativeTarget) + const movedRootIds = new Set(selected.filter((session) => !session.parentID).map((session) => session.id)) + assertSessionsMovable(state, selected) + const targetWorkspaceId = state.workspaces.find((workspace) => + sameWorktreeDirectory(workspace.directory, nativeTarget.directory))?.id + const prepared = await moveNativeSessions(state, selected, { + workspaceId: null, + directory: nativeWorkspaceFolder, + }, params.signal) + + try { + await (params.removeGitWorktree ?? removeWorktree)({ + workspaceFolder: params.workspaceFolder, + directory: params.target.directory, + force: params.force, + logger: params.logger, + }) + } catch (error) { + try { + await prepared.rollback() + } catch (restoreError) { + throw new WorktreeRollbackIncompleteError( + [error, restoreError], + "Worktree deletion failed and session restoration was incomplete", + ) + } + throw error + } + + // Git is authoritative. Everything below is cleanup and must not turn a successful delete into a failure. + for (const session of state.sessions) { + if (!movedRootIds.has(session.id) && metadataWorktreeSlug(session.metadata) !== params.target.slug) continue + await params.sessionMetadataPersistence + .setWorktreeSlug(params.instanceId, session.id, null, { alreadyAdmitted: true }).catch((error) => { + params.logger?.warn?.({ error, sessionId: session.id }, "Failed to clear deleted worktree session metadata") + }) + } + try { + const readMap = params.readMap ?? (async (workspaceFolder: string, logger?: LogLike) => + (await readWorktreeMapStrict(workspaceFolder, logger)).map) + const writeMap = params.writeMap ?? writeWorktreeMap + const current = await readMap(params.workspaceFolder, params.logger) + const next: WorktreeMap = { + version: 1, + revision: (current.revision ?? 0) + 1, + defaultWorktreeSlug: current.defaultWorktreeSlug === params.target.slug ? "root" : current.defaultWorktreeSlug, + parentSessionWorktreeSlug: Object.fromEntries( + Object.entries(current.parentSessionWorktreeSlug ?? {}) + .filter(([sessionId, slug]) => !movedRootIds.has(sessionId) && slug !== params.target.slug), + ), + } + await writeMap(params.workspaceFolder, next, params.logger) + } catch (error) { + params.logger?.warn?.({ error, slug: params.target.slug }, "Failed to prune deleted worktree mappings") + } + if (targetWorkspaceId) { + await state.client.experimental.workspace.remove( + { directory: nativeWorkspaceFolder, id: targetWorkspaceId }, + { throwOnError: true }, + ).catch((error) => { + params.logger?.warn?.({ error, workspaceId: targetWorkspaceId }, "Failed to remove deleted native workspace") + }) + } +} diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts index b54f0dc29..e3f400d3d 100644 --- a/packages/server/src/workspaces/worktree-map.ts +++ b/packages/server/src/workspaces/worktree-map.ts @@ -1,26 +1,94 @@ -import fs from "fs" import { promises as fsp } from "fs" +import { execFile } from "node:child_process" +import { randomUUID } from "node:crypto" import path from "path" +import { promisify } from "node:util" import type { WorktreeMap } from "../api-types" -import { resolveRepoRoot } from "./git-worktrees" import type { LogLike } from "./git-worktrees" +import { queryGitRepositoryPaths } from "./git-output" +import { canonicalFilesystemIdentity, resolveRepositoryIdentity } from "./workspace-identity" +import { withRepositoryMutation } from "./repository-mutation-lock" + +const execFileAsync = promisify(execFile) const DEFAULT_MAP: WorktreeMap = { version: 1, + revision: 0, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {}, } -function getMapPath(repoRoot: string): string { - return path.join(repoRoot, ".codenomad", "worktreeMap.json") +export interface StrictWorktreeMapRead { + exists: boolean + map: WorktreeMap } -function getGitExcludePath(repoRoot: string): string { - return path.join(repoRoot, ".git", "info", "exclude") +export class WorktreeMapReadError extends Error { + readonly cause?: unknown + constructor(message: string, cause?: unknown) { + super(message) + this.name = "WorktreeMapReadError" + this.cause = cause + } +} + +export class WorktreeMapRevisionConflictError extends Error { + constructor() { + super("Worktree map revision is stale") + this.name = "WorktreeMapRevisionConflictError" + } } -async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise { - const excludePath = getGitExcludePath(repoRoot) +export class WorktreeMapConflictError extends WorktreeMapReadError { + constructor(message: string) { + super(message) + this.name = "WorktreeMapConflictError" + } +} + +function defaultMap(): WorktreeMap { + return { ...DEFAULT_MAP, parentSessionWorktreeSlug: {} } +} + +interface WorktreeMapPaths { + filePath: string + legacyPaths: string[] + gitExcludePath?: string +} + +async function resolveWorktreeMapPaths(workspaceFolder: string): Promise { + const identity = await resolveRepositoryIdentity(workspaceFolder) + const legacyPath = path.join(identity.repositoryRoot, ".codenomad", "worktreeMap.json") + if (!identity.commonDir) return { filePath: legacyPath, legacyPaths: [] } + + const gitPaths = await queryGitRepositoryPaths(workspaceFolder) + const commonDir = path.resolve(workspaceFolder, gitPaths.commonDir) + + const filePath = path.basename(commonDir).toLowerCase() === ".git" + ? path.join(path.dirname(commonDir), ".codenomad", "worktreeMap.json") + : path.join(commonDir, "codenomad", "worktreeMap.json") + let stdout: string + try { + ({ stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain", "-z"], { + cwd: workspaceFolder, + windowsHide: true, + })) + } catch (error) { + throw new WorktreeMapReadError("Failed to enumerate linked worktree maps", error) + } + const canonicalPath = canonicalFilesystemIdentity(filePath) + const legacyPaths = stdout.split("\0") + .filter((field) => field.startsWith("worktree ")) + .map((field) => path.join(field.slice("worktree ".length), ".codenomad", "worktreeMap.json")) + .filter((candidate) => canonicalFilesystemIdentity(candidate) !== canonicalPath) + return { + filePath, + legacyPaths, + gitExcludePath: path.join(commonDir, "info", "exclude"), + } +} + +async function ensureGitExclude(excludePath: string, logger?: LogLike): Promise { try { await fsp.mkdir(path.dirname(excludePath), { recursive: true }) } catch { @@ -57,90 +125,188 @@ async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise { - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) - if (!isGitRepo) { - return - } - await ensureGitExclude(repoRoot, logger) + const { gitExcludePath } = await resolveWorktreeMapPaths(workspaceFolder) + if (gitExcludePath) await ensureGitExclude(gitExcludePath, logger) } -export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) +function parseWorktreeMap(raw: string, filePath: string): WorktreeMap { try { - const raw = await fsp.readFile(filePath, "utf-8") const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") { - return DEFAULT_MAP + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Map must be an object") + if ((parsed as any).version !== 1) throw new Error("Unsupported map version") + if (typeof (parsed as any).defaultWorktreeSlug !== "string") throw new Error("Invalid default worktree slug") + const revision = (parsed as any).revision ?? 0 + if (!Number.isSafeInteger(revision) || revision < 0) throw new Error("Invalid map revision") + const mapping = (parsed as any).parentSessionWorktreeSlug + if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) + || Object.values(mapping).some((slug) => typeof slug !== "string")) { + throw new Error("Invalid parent session mapping") } - const version = (parsed as any).version - if (version !== 1) { - return DEFAULT_MAP - } - const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root" - const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug - const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {} return { version: 1, - defaultWorktreeSlug, + revision, + defaultWorktreeSlug: (parsed as any).defaultWorktreeSlug, parentSessionWorktreeSlug: { ...mapping }, } } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === "ENOENT") { - if (isGitRepo) { - // Best-effort ignore setup on first use. - await ensureGitExclude(repoRoot, logger).catch(() => undefined) - } - return DEFAULT_MAP - } - logger?.warn?.({ err: error, filePath }, "Failed to read worktree map") - return DEFAULT_MAP + throw new WorktreeMapReadError(`Malformed worktree map at ${filePath}`, error) } } -export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise { - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) +async function writeMapFile(filePath: string, raw: string): Promise { await fsp.mkdir(path.dirname(filePath), { recursive: true }) + const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp` + await fsp.writeFile(tmpPath, raw, "utf-8") + await fsp.rename(tmpPath, filePath) +} - // Ensure ignore rules are present (local-only). - if (isGitRepo) { - await ensureGitExclude(repoRoot, logger).catch(() => undefined) +async function readMapFile(filePath: string): Promise { + try { + return parseWorktreeMap(await fsp.readFile(filePath, "utf-8"), filePath) + } catch (error) { + if (error instanceof WorktreeMapReadError) throw error + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined + throw new WorktreeMapReadError(`Failed to read worktree map at ${filePath}`, error) } +} - if (Object.keys(next.parentSessionWorktreeSlug ?? {}).length === 0) { - await deleteWorktreeMap(workspaceFolder, logger) - return +function mergeWorktreeMaps(sources: Array<{ filePath: string; map: WorktreeMap }>): WorktreeMap { + const first = sources[0] + const mapping: Record = Object.create(null) + let revision = 0 + for (const source of sources) { + if (source.map.defaultWorktreeSlug !== first.map.defaultWorktreeSlug) { + throw new WorktreeMapConflictError(`Conflicting default worktree slug at ${source.filePath}`) + } + revision = Math.max(revision, source.map.revision ?? 0) + for (const [sessionId, slug] of Object.entries(source.map.parentSessionWorktreeSlug)) { + if (Object.prototype.hasOwnProperty.call(mapping, sessionId) && mapping[sessionId] !== slug) { + throw new WorktreeMapConflictError(`Conflicting worktree binding for session ${sessionId} at ${source.filePath}`) + } + mapping[sessionId] = slug + } } - - const payload: WorktreeMap = { + return { version: 1, - defaultWorktreeSlug: next.defaultWorktreeSlug || "root", - parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {}, + revision: sources.length > 1 ? revision + 1 : revision, + defaultWorktreeSlug: first.map.defaultWorktreeSlug, + parentSessionWorktreeSlug: { ...mapping }, } +} - // Write atomically. - const tmpPath = `${filePath}.${process.pid}.tmp` - await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8") - await fsp.rename(tmpPath, filePath) +function sameMapContents(left: WorktreeMap, right: WorktreeMap): boolean { + const leftEntries = Object.entries(left.parentSessionWorktreeSlug).sort(([a], [b]) => a.localeCompare(b)) + const rightEntries = Object.entries(right.parentSessionWorktreeSlug).sort(([a], [b]) => a.localeCompare(b)) + return left.defaultWorktreeSlug === right.defaultWorktreeSlug + && JSON.stringify(leftEntries) === JSON.stringify(rightEntries) } -export async function deleteWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { - const { repoRoot } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) - try { - await fsp.rm(filePath, { force: true }) - } catch (error) { - logger?.warn?.({ err: error, filePath }, "Failed to delete worktree map") - throw error +async function readExistingMap( + paths: WorktreeMapPaths, + logger?: LogLike, + requireLegacyRetirement = false, +): Promise { + const [canonical, ...legacyMaps] = await Promise.all([ + readMapFile(paths.filePath), + ...paths.legacyPaths.map(readMapFile), + ]) + const legacySources = legacyMaps.flatMap((map, index) => map + ? [{ filePath: paths.legacyPaths[index], map }] + : []) + if (legacySources.length === 0) return canonical + + const sources = [ + ...(canonical ? [{ filePath: paths.filePath, map: canonical }] : []), + ...legacySources, + ] + let merged = mergeWorktreeMaps(sources) + if (canonical && sameMapContents(canonical, merged) + && (canonical.revision ?? 0) >= Math.max(...sources.map(({ map }) => map.revision ?? 0))) { + merged = canonical } + await writeMapFile(paths.filePath, JSON.stringify(merged, null, 2)) + const retirementFailures: unknown[] = [] + await Promise.all(legacySources.map(async ({ filePath }) => { + await fsp.rm(filePath, { force: true }).catch((error) => { + logger?.warn?.({ err: error, filePath }, "Failed to remove published legacy worktree map") + retirementFailures.push(error) + }) + })) + if (requireLegacyRetirement && retirementFailures.length) { + throw new AggregateError(retirementFailures, "Failed to retire legacy worktree maps") + } + return merged +} + +export async function readWorktreeMapStrict(workspaceFolder: string, logger?: LogLike): Promise { + return withRepositoryMutation({ + workspaceFolder, + operation: async () => { + const paths = await resolveWorktreeMapPaths(workspaceFolder) + const map = await readExistingMap(paths, logger) + if (!map) { + if (paths.gitExcludePath) await ensureGitExclude(paths.gitExcludePath, logger).catch(() => undefined) + return { exists: false, map: defaultMap() } + } + return { exists: true, map } + }, + }) } -export function worktreeMapExists(repoRoot: string): boolean { +export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { try { - return fs.existsSync(getMapPath(repoRoot)) - } catch { - return false + return (await readWorktreeMapStrict(workspaceFolder, logger)).map + } catch (error) { + logger?.warn?.({ err: error, workspaceFolder }, "Failed to read worktree map") + return defaultMap() } } + +export async function writeWorktreeMap( + workspaceFolder: string, + next: WorktreeMap, + logger?: LogLike, + expectedRevision?: number, +): Promise { + await withRepositoryMutation({ workspaceFolder, operation: async () => { + const paths = await resolveWorktreeMapPaths(workspaceFolder) + const current = await readExistingMap(paths, logger, true) + if (expectedRevision !== undefined && (current?.revision ?? 0) !== expectedRevision) { + throw new WorktreeMapRevisionConflictError() + } + + // Ensure ignore rules are present (local-only). + if (paths.gitExcludePath) { + await ensureGitExclude(paths.gitExcludePath, logger).catch(() => undefined) + } + + const payload: WorktreeMap = { + version: 1, + revision: next.revision ?? 0, + defaultWorktreeSlug: next.defaultWorktreeSlug || "root", + parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {}, + } + + await writeMapFile(paths.filePath, JSON.stringify(payload, null, 2)) + } }) +} + +export async function deleteWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { + await withRepositoryMutation({ workspaceFolder, operation: async () => { + const paths = await resolveWorktreeMapPaths(workspaceFolder) + try { + await readExistingMap(paths, logger, true) + await fsp.rm(paths.filePath, { force: true }) + } catch (error) { + logger?.warn?.({ err: error, filePath: paths.filePath }, "Failed to delete worktree map") + throw error + } + } }) +} + +export async function worktreeMapExists(workspaceFolder: string): Promise { + return withRepositoryMutation({ workspaceFolder, operation: async () => { + const paths = await resolveWorktreeMapPaths(workspaceFolder) + return (await readExistingMap(paths)) !== undefined + } }) +} diff --git a/packages/server/src/workspaces/worktree-session-move.test.ts b/packages/server/src/workspaces/worktree-session-move.test.ts new file mode 100644 index 000000000..8537cc7a8 --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-move.test.ts @@ -0,0 +1,373 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { InstanceMutationGate } from "../server/instance-mutation-gate" +import { moveSessionFamilyTransaction, WorktreeRollbackIncompleteError } from "./worktree-session-move" + +function client(events: string[], label: string, options: { + ambiguousChild?: boolean + failChild?: boolean + recordLists?: boolean + staleDirectoryAfterWarp?: boolean + legacyWorktree?: boolean + rootDirectory?: string + featureDirectory?: string + scopes?: unknown[] +} = {}): OpencodeClient { + const rootDirectory = options.rootDirectory ?? "/repo" + const featureDirectory = options.featureDirectory ?? "/repo-feature" + const initialDirectory = options.legacyWorktree ? featureDirectory : rootDirectory + const sessions = [ + { id: `${label}-root`, directory: initialDirectory, metadata: { codenomad: { worktreeSlug: "legacy" } } }, + { id: `${label}-child`, parentID: `${label}-root`, directory: initialDirectory }, + ] + return { + session: { + list: async (scope: unknown) => { + options.scopes?.push(scope) + if (options.recordLists) events.push(`${label}:list`) + return { data: sessions.map((session) => ({ ...session })) } + }, + status: async () => ({ data: {} }), + }, + experimental: { + controlPlane: { + moveSession: async ({ sessionID, destination }: { sessionID: string; destination: { directory: string } }) => { + const id = destination.directory === rootDirectory ? null : "feature-workspace" + events.push(`${label}:${sessionID}:${id ?? "root"}`) + if (options.failChild && sessionID.endsWith("child")) throw new Error("warp failed") + const session = sessions.find((candidate) => candidate.id === sessionID)! + if (!options.staleDirectoryAfterWarp) session.directory = destination.directory + ;(session as any).workspaceID = id ?? undefined + if (options.ambiguousChild && sessionID.endsWith("child")) throw new Error("response lost") + return { data: true } + }, + }, + workspace: { + syncList: async (scope: unknown) => { options.scopes?.push(scope); return { data: true } }, + list: async (scope: unknown) => { + options.scopes?.push(scope) + return { data: [{ id: "feature-workspace", directory: featureDirectory }] } + }, + }, + }, + } as unknown as OpencodeClient +} + +function legacyCleanup(events: string[], label: string, rootSessionId: string, options: { + failMetadataClear?: boolean + failMetadataRestore?: boolean + failMapWrite?: boolean +} = {}) { + let metadataSlug: string | null = "legacy" + let metadataClearFailed = false + let mapWriteFailed = false + let map = { + version: 1 as const, + revision: 3, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { [rootSessionId]: "legacy", other: "feature" }, + } + return { + sessionMetadataPersistence: { + setWorktreeSlug: async (_instanceId: string, sessionId: string, slug: string | null) => { + events.push(`${label}:metadata:${slug ?? "null"}`) + assert.equal(sessionId, rootSessionId) + metadataSlug = slug + if (slug === null && options.failMetadataClear && !metadataClearFailed) { + metadataClearFailed = true + throw new Error("metadata clear failed") + } + if (slug === "legacy" && options.failMetadataRestore) throw new Error("metadata restore failed") + return {} + }, + }, + readMap: async () => { + events.push(`${label}:read-map`) + return { ...map, parentSessionWorktreeSlug: { ...map.parentSessionWorktreeSlug } } + }, + writeMap: async (_folder: string, next: any) => { + events.push(`${label}:write-map`) + map = { ...next, parentSessionWorktreeSlug: { ...next.parentSessionWorktreeSlug } } + if (!Object.prototype.hasOwnProperty.call(next.parentSessionWorktreeSlug, rootSessionId) + && options.failMapWrite && !mapWriteFailed) { + mapWriteFailed = true + throw new Error("map write failed") + } + }, + metadataSlug: () => metadataSlug, + map: () => map, + } +} + +describe("moveSessionFamilyTransaction", () => { + it("moves a directory-only worktree family to the canonical root with no workspace ID", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "rooted", "rooted-root") + const result = await moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "rooted", { legacyWorktree: true }), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo" }, + sessionId: "rooted-root", + target: { slug: "root", directory: "/repo", kind: "root" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }) + + assert.deepEqual(result.sessions, [ + { sessionId: "rooted-root", workspaceId: null, directory: "/repo" }, + { sessionId: "rooted-child", workspaceId: null, directory: "/repo" }, + ]) + assert.deepEqual(events.filter((event) => event.includes("rooted-") && event.endsWith(":root")), [ + "rooted:rooted-root:root", + "rooted:rooted-child:root", + ]) + }) + + it("keeps WSL host map paths separate from case-sensitive native OpenCode scope", async () => { + const events: string[] = [] + const scopes: unknown[] = [] + const mapFolders: string[] = [] + const nativeRoot = "/home/dev/Repo" + const nativeTarget = "/home/dev/Repo-Feature" + const legacy = legacyCleanup(events, "wsl", "wsl-root") + const result = await moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "wsl", { rootDirectory: nativeRoot, featureDirectory: nativeTarget, scopes }), + hostWorkspaceFolder: String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, + nativeScope: { rootDirectory: nativeRoot, targetDirectory: nativeTarget }, + sessionId: "wsl-root", + target: { + slug: "feature", + directory: String.raw`\\wsl$\ubuntu\home\dev\repo-feature`, + kind: "worktree", + }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: async (folder) => { mapFolders.push(folder); return legacy.readMap() }, + writeMap: async (folder, map) => { mapFolders.push(folder); return legacy.writeMap(folder, map) }, + }) + + assert.deepEqual(result.sessions.map((location) => location.directory), [nativeTarget, nativeTarget]) + assert.deepEqual(new Set(scopes.map((scope) => (scope as { directory: string }).directory)), new Set([nativeRoot])) + assert.deepEqual(mapFolders, [ + String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, + String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, + ]) + }) + + it("rejects a workspace ID when the canonical destination directory remains stale", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "stale", "stale-root") + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "stale", { staleDirectoryAfterWarp: true }), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "stale-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }), /did not reach its requested location/) + }) + + it("cleans root legacy state only after the complete family is authoritatively reconciled", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "one", "one-root") + const result = await moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "one", { ambiguousChild: true, recordLists: true }), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "one-child", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }) + + assert.equal(result.rootSessionId, "one-root") + assert.deepEqual(result.sessions, [ + { sessionId: "one-root", workspaceId: "feature-workspace", directory: "/repo-feature" }, + { sessionId: "one-child", workspaceId: "feature-workspace", directory: "/repo-feature" }, + ]) + const mapReadIndex = events.indexOf("one:read-map") + assert.ok(mapReadIndex > events.lastIndexOf("one:list"), "cleanup must follow final authoritative reconciliation") + assert.deepEqual(events.filter((event) => event.includes("one:one-")), [ + "one:one-root:feature-workspace", + "one:one-child:feature-workspace", + ]) + assert.deepEqual(events.slice(mapReadIndex), ["one:read-map", "one:metadata:null", "one:write-map"]) + assert.equal(legacy.metadataSlug(), null) + assert.deepEqual(legacy.map(), { + version: 1, + revision: 4, + defaultWorktreeSlug: "root", + parentSessionWorktreeSlug: { other: "feature" }, + }) + }) + + it("does not interleave member warps from concurrent clients", async () => { + const gate = new InstanceMutationGate() + const events: string[] = [] + const target = { slug: "feature", directory: "/repo-feature", kind: "worktree" as const } + const firstLegacy = legacyCleanup(events, "first", "first-root") + const secondLegacy = legacyCleanup(events, "second", "second-root") + await Promise.all([ + gate.exclusive("workspace", () => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "first"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "first-root", + target, + sessionMetadataPersistence: firstLegacy.sessionMetadataPersistence, + readMap: firstLegacy.readMap, + writeMap: firstLegacy.writeMap, + })), + gate.exclusive("workspace", () => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "second"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "second-root", + target, + sessionMetadataPersistence: secondLegacy.sessionMetadataPersistence, + readMap: secondLegacy.readMap, + writeMap: secondLegacy.writeMap, + })), + ]) + + assert.deepEqual(events.filter((event) => /:(?:first|second)-(?:root|child):/.test(event)), [ + "first:first-root:feature-workspace", + "first:first-child:feature-workspace", + "second:second-root:feature-workspace", + "second:second-child:feature-workspace", + ]) + assert.ok(events.indexOf("second:second-root:feature-workspace") > events.indexOf("first:write-map")) + }) + + it("preserves metadata and map when warp reconciliation fails", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "failed", "failed-root") + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "failed", { failChild: true, recordLists: true }), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "failed-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }), /warp failed/) + + assert.equal(legacy.metadataSlug(), "legacy") + assert.equal(legacy.map().revision, 3) + assert.equal(events.some((event) => event.includes("metadata") || event.includes("map")), false) + }) + + it("restores legacy sources and native locations when metadata clear fails after warps", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "metadata-failure", "metadata-failure-root", { failMetadataClear: true }) + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "metadata-failure"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "metadata-failure-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }), /metadata clear failed/) + + assert.equal(legacy.metadataSlug(), "legacy") + assert.deepEqual(legacy.map().parentSessionWorktreeSlug, { + "metadata-failure-root": "legacy", + other: "feature", + }) + assert.deepEqual(events.filter((event) => event.includes("metadata-failure-root:")), [ + "metadata-failure:metadata-failure-root:feature-workspace", + "metadata-failure:metadata-failure-root:root", + ]) + assert.equal(events.includes("metadata-failure:write-map"), false) + }) + + it("restores legacy sources and native locations when map write fails after warps", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "map-failure", "map-failure-root", { failMapWrite: true }) + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "map-failure"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "map-failure-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }), /map write failed/) + + assert.equal(legacy.metadataSlug(), "legacy") + assert.equal(legacy.map().revision, 5) + assert.deepEqual(legacy.map().parentSessionWorktreeSlug, { + "map-failure-root": "legacy", + other: "feature", + }) + assert.deepEqual(events.filter((event) => event.includes("map-failure-root:")), [ + "map-failure:map-failure-root:feature-workspace", + "map-failure:map-failure-root:root", + ]) + }) + + it("aggregates cleanup and rollback failures", async () => { + const events: string[] = [] + const legacy = legacyCleanup(events, "aggregate", "aggregate-root", { + failMapWrite: true, + failMetadataRestore: true, + }) + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "aggregate"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "aggregate-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: legacy.sessionMetadataPersistence, + readMap: legacy.readMap, + writeMap: legacy.writeMap, + }), (error: unknown) => { + assert.ok(error instanceof WorktreeRollbackIncompleteError) + assert.match(error.message, /rollback was incomplete/) + assert.equal(error.errors.length, 2) + assert.match(String(error.errors[0]), /map write failed/) + assert.match(String(error.errors[1]), /metadata restore failed/) + return true + }) + }) + + it("does not overwrite fallback after strict map capture fails", async () => { + const events: string[] = [] + let metadataCalls = 0 + let mapWrites = 0 + await assert.rejects(() => moveSessionFamilyTransaction({ + instanceId: "workspace", + client: client(events, "malformed"), + hostWorkspaceFolder: "/repo", + nativeScope: { rootDirectory: "/repo", targetDirectory: "/repo-feature" }, + sessionId: "malformed-root", + target: { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + sessionMetadataPersistence: { + setWorktreeSlug: async () => { metadataCalls += 1; return {} }, + }, + readMap: async () => { throw new Error("malformed map") }, + writeMap: async () => { mapWrites += 1 }, + }), /malformed map/) + assert.equal(metadataCalls, 0) + assert.equal(mapWrites, 0) + assert.ok(events.includes("malformed:malformed-root:root"), "native root must be reconciled after capture failure") + }) +}) diff --git a/packages/server/src/workspaces/worktree-session-move.ts b/packages/server/src/workspaces/worktree-session-move.ts new file mode 100644 index 000000000..c86796967 --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-move.ts @@ -0,0 +1,366 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { + WorktreeDescriptor, + WorktreeMap, + WorktreeSessionLocation, + WorktreeSessionMoveResponse, +} from "../api-types" +import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata" +import type { LogLike } from "./git-worktrees" +import { readWorktreeMapStrict, writeWorktreeMap } from "./worktree-map" +import { nativeWorkspacePathsEqual } from "./workspace-identity" + +const SESSION_LIST_LIMIT = 10_000 + +export interface NativeSession { + id: string + parentID?: string + directory?: string + workspaceID?: string + metadata?: unknown + status?: unknown +} + +interface NativeWorkspace { + id: string + directory?: string | null +} + +interface NativeClient { + session: { + list: (...args: any[]) => Promise<{ data?: NativeSession[] }> + status: (...args: any[]) => Promise<{ data?: Record }> + } + experimental: { + controlPlane: { + moveSession: (...args: any[]) => Promise + } + workspace: { + syncList: (...args: any[]) => Promise + list: (...args: any[]) => Promise<{ data?: NativeWorkspace[] }> + warp: (...args: any[]) => Promise + remove: (...args: any[]) => Promise + } + } +} + +export interface NativeSessionState { + client: NativeClient + workspaceFolder: string + sessions: NativeSession[] + workspaces: NativeWorkspace[] + statuses: Record +} + +export interface NativeLocation { + sessionId: string + workspaceId: string | null + directory: string +} + +export class WorktreeSessionBusyError extends Error { + constructor(readonly sessionIds: string[]) { + super("Cannot move a session family while it is working, compacting, or pending") + this.name = "WorktreeSessionBusyError" + } +} + +export class WorktreeRollbackIncompleteError extends AggregateError { + constructor(errors: unknown[], message: string) { + super(errors, message) + this.name = "WorktreeRollbackIncompleteError" + } +} + +function metadataWorktreeSlug(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object") return null + const codenomad = (metadata as Record).codenomad + if (!codenomad || typeof codenomad !== "object") return null + const slug = (codenomad as Record).worktreeSlug + return typeof slug === "string" ? slug : null +} + +export function sameWorktreeDirectory(left: string | null | undefined, right: string | null | undefined): boolean { + if (!left || !right) return false + return nativeWorkspacePathsEqual(left, right) +} + +async function listSessions(state: NativeSessionState): Promise { + const { data = [] } = await state.client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory: state.workspaceFolder }, + { throwOnError: true }, + ) + if (data.length >= SESSION_LIST_LIMIT) throw new Error("Unable to verify the complete project session inventory") + state.sessions = data + return data +} + +export async function loadNativeSessionState(client: OpencodeClient, workspaceFolder: string): Promise { + const nativeClient = client as unknown as NativeClient + const scope = { directory: workspaceFolder } + await nativeClient.experimental.workspace.syncList(scope, { throwOnError: true }) + const state: NativeSessionState = { + client: nativeClient, + workspaceFolder, + sessions: [], + workspaces: [], + statuses: {}, + } + const [{ data: workspaces = [] }] = await Promise.all([ + nativeClient.experimental.workspace.list(scope, { throwOnError: true }), + listSessions(state), + ]) + state.workspaces = workspaces + const statusResponses = await Promise.all([ + nativeClient.session.status(scope, { throwOnError: true }), + ...workspaces.map((workspace) => nativeClient.session.status( + { ...scope, workspace: workspace.id }, + { throwOnError: true }, + )), + ]) + state.statuses = Object.assign({}, ...statusResponses.map(({ data = {} }) => data)) + return state +} + +function rootFor(session: NativeSession, byId: Map): NativeSession { + let current = session + const seen = new Set([current.id]) + while (current.parentID) { + const parent = byId.get(current.parentID) + if (!parent) throw new Error(`Unable to verify the complete family for session ${session.id}`) + if (seen.has(parent.id)) throw new Error(`Session family cycle detected for ${session.id}`) + seen.add(parent.id) + current = parent + } + return current +} + +export function completeFamily(state: NativeSessionState, sessionId: string): { root: NativeSession; members: NativeSession[] } { + const byId = new Map(state.sessions.map((session) => [session.id, session])) + const selected = byId.get(sessionId) + if (!selected) throw new Error("Session not found") + const root = rootFor(selected, byId) + return { + root, + members: state.sessions.filter((session) => rootFor(session, byId).id === root.id), + } +} + +export function familiesAtWorktree(state: NativeSessionState, target: WorktreeDescriptor): NativeSession[] { + const byId = new Map(state.sessions.map((session) => [session.id, session])) + const targetWorkspaceId = state.workspaces.find((workspace) => + sameWorktreeDirectory(workspace.directory, target.directory))?.id + const roots = new Set(state.sessions + .filter((session) => session.workspaceID + ? Boolean(targetWorkspaceId && session.workspaceID === targetWorkspaceId) + : sameWorktreeDirectory(session.directory, target.directory)) + .map((session) => rootFor(session, byId).id)) + return state.sessions.filter((session) => roots.has(rootFor(session, byId).id)) +} + +function statusType(status: unknown): string | undefined { + if (typeof status === "string") return status + if (!status || typeof status !== "object") return undefined + const type = (status as Record).type + return typeof type === "string" ? type : undefined +} + +export function assertSessionsMovable(state: NativeSessionState, sessions: NativeSession[]): void { + const busy = sessions.filter((session) => { + const type = statusType(state.statuses[session.id]) ?? statusType(session.status) + return Boolean(type && type !== "idle") + }) + if (busy.length) throw new WorktreeSessionBusyError(busy.map((session) => session.id)) +} + +function workspaceForDirectory(state: NativeSessionState, directory: string | undefined): NativeWorkspace | undefined { + return state.workspaces.find((workspace) => sameWorktreeDirectory(workspace.directory, directory)) +} + +export function resolveTargetLocation(state: NativeSessionState, target: WorktreeDescriptor): Omit { + if (target.kind === "root" || target.slug === "root") { + return { workspaceId: null, directory: state.workspaceFolder } + } + const workspace = workspaceForDirectory(state, target.directory) + if (!workspace) throw new Error(`OpenCode workspace not found for worktree ${target.slug}`) + return { workspaceId: workspace.id, directory: target.directory } +} + +function currentLocation(state: NativeSessionState, session: NativeSession): NativeLocation { + if (!session.workspaceID && sameWorktreeDirectory(session.directory, state.workspaceFolder)) { + return { sessionId: session.id, workspaceId: null, directory: state.workspaceFolder } + } + const workspace = session.workspaceID + ? state.workspaces.find((candidate) => candidate.id === session.workspaceID) + : workspaceForDirectory(state, session.directory) + if (!workspace?.directory) throw new Error(`Unable to resolve session ${session.id} location`) + return { sessionId: session.id, workspaceId: workspace.id, directory: workspace.directory } +} + +function atNativeLocation(session: NativeSession | undefined, location: Omit): boolean { + if (!session) return false + const workspaceMatches = location.workspaceId === null ? !session.workspaceID : session.workspaceID === location.workspaceId + return workspaceMatches && sameWorktreeDirectory(session.directory, location.directory) +} + +async function moveAndReconcile(state: NativeSessionState, location: NativeLocation, signal?: AbortSignal): Promise { + let moveError: unknown + await state.client.experimental.controlPlane.moveSession( + { + sessionID: location.sessionId, + destination: { directory: location.directory }, + }, + { + throwOnError: true, + headers: { "x-codenomad-mutation-session": location.sessionId }, + signal, + }, + ).catch((error) => { moveError = error }) + const actual = (await listSessions(state)).find((session) => session.id === location.sessionId) + if (atNativeLocation(actual, location)) return + throw moveError ?? new Error(`Session ${location.sessionId} did not reach its requested location`) +} + +export async function moveNativeSessions( + state: NativeSessionState, + sessions: NativeSession[], + target: Omit, + signal?: AbortSignal, +): Promise<{ locations: WorktreeSessionLocation[]; rollback: () => Promise }> { + const originals = new Map(sessions.map((session) => [session.id, currentLocation(state, session)])) + const attempted: string[] = [] + const rollback = async () => { + const failures: unknown[] = [] + for (const sessionId of [...attempted].reverse()) { + await moveAndReconcile(state, originals.get(sessionId)!).catch((error) => failures.push(error)) + } + if (failures.length) throw new AggregateError(failures, "Failed to restore session family locations") + } + try { + for (const session of sessions) { + signal?.throwIfAborted() + const desired = { sessionId: session.id, ...target } + if (atNativeLocation(state.sessions.find((candidate) => candidate.id === session.id), desired)) continue + attempted.push(session.id) + await moveAndReconcile(state, desired, signal) + } + const authoritative = await listSessions(state) + const locations = sessions.map((session) => { + const desired = { sessionId: session.id, ...target } + const actual = authoritative.find((candidate) => candidate.id === session.id) + if (!atNativeLocation(actual, desired)) throw new Error(`Unable to reconcile session ${session.id} after move`) + return desired + }) + return { locations, rollback } + } catch (error) { + try { + await rollback() + } catch (rollbackError) { + throw new WorktreeRollbackIncompleteError( + [error, rollbackError], + "Session family move failed and restoration was incomplete", + ) + } + throw error + } +} + +export async function moveSessionFamilyTransaction(params: { + instanceId: string + client: OpencodeClient + hostWorkspaceFolder: string + nativeScope: { + rootDirectory: string + targetDirectory: string + } + sessionId: string + target: WorktreeDescriptor + sessionMetadataPersistence: Pick + readMap?: (workspaceFolder: string, logger?: LogLike) => Promise + writeMap?: typeof writeWorktreeMap + logger?: LogLike + signal?: AbortSignal +}): Promise { + params.signal?.throwIfAborted() + const state = await loadNativeSessionState(params.client, params.nativeScope.rootDirectory) + const family = completeFamily(state, params.sessionId) + assertSessionsMovable(state, family.members) + const moved = await moveNativeSessions(state, family.members, resolveTargetLocation(state, { + ...params.target, + directory: params.nativeScope.targetDirectory, + }), params.signal) + const readMap = params.readMap ?? (async (workspaceFolder: string, logger?: LogLike) => + (await readWorktreeMapStrict(workspaceFolder, logger)).map) + const writeMap = params.writeMap ?? writeWorktreeMap + const priorMetadataSlug = metadataWorktreeSlug(family.root.metadata) + let priorMap: WorktreeMap + try { + const current = await readMap(params.hostWorkspaceFolder, params.logger) + priorMap = { ...current, parentSessionWorktreeSlug: { ...current.parentSessionWorktreeSlug } } + } catch (error) { + try { + await moved.rollback() + } catch (rollbackError) { + throw new WorktreeRollbackIncompleteError( + [error, rollbackError], + "Legacy state capture failed and native rollback was incomplete", + ) + } + throw error + } + + let mapWriteAttempted = false + try { + await params.sessionMetadataPersistence.setWorktreeSlug( + params.instanceId, + family.root.id, + null, + { alreadyAdmitted: true }, + ) + if (Object.prototype.hasOwnProperty.call(priorMap.parentSessionWorktreeSlug, family.root.id)) { + const parentSessionWorktreeSlug = { ...priorMap.parentSessionWorktreeSlug } + delete parentSessionWorktreeSlug[family.root.id] + mapWriteAttempted = true + await writeMap(params.hostWorkspaceFolder, { + ...priorMap, + revision: (priorMap.revision ?? 0) + 1, + parentSessionWorktreeSlug, + }, params.logger) + } + } catch (error) { + const rollbackFailures: unknown[] = [] + await params.sessionMetadataPersistence + .setWorktreeSlug(params.instanceId, family.root.id, priorMetadataSlug, { alreadyAdmitted: true }) + .catch((rollbackError) => rollbackFailures.push(rollbackError)) + + if (mapWriteAttempted) { + let latestRevision = priorMap.revision ?? 0 + try { + const latest = await readMap(params.hostWorkspaceFolder, params.logger) + latestRevision = Math.max(latestRevision, latest.revision ?? 0) + } catch (rollbackError) { + rollbackFailures.push(rollbackError) + } + await writeMap(params.hostWorkspaceFolder, { + ...priorMap, + revision: latestRevision + 1, + parentSessionWorktreeSlug: { ...priorMap.parentSessionWorktreeSlug }, + }, params.logger).catch((rollbackError) => rollbackFailures.push(rollbackError)) + } + await moved.rollback().catch((rollbackError) => rollbackFailures.push(rollbackError)) + + if (rollbackFailures.length) { + throw new WorktreeRollbackIncompleteError( + [error, ...rollbackFailures], + "Session family legacy cleanup failed and rollback was incomplete", + ) + } + throw error + } + + return { + rootSessionId: family.root.id, + worktreeSlug: params.target.slug, + sessions: moved.locations, + } +} diff --git a/packages/server/src/workspaces/wsl-windows-drive.ts b/packages/server/src/workspaces/wsl-windows-drive.ts new file mode 100644 index 000000000..637ff5d4d --- /dev/null +++ b/packages/server/src/workspaces/wsl-windows-drive.ts @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" + +export interface WslWindowsDriveMount { + mountPoint: string + drive: string + sourcePrefix?: string +} + +const decodeMountField = (value: string): string => value.replace(/\\([0-7]{3})/g, + (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))) + +export function parseWslWindowsDriveMounts(mountInfo: string): WslWindowsDriveMount[] { + const mounts: WslWindowsDriveMount[] = [] + for (const line of mountInfo.split(/\r?\n/)) { + const fields = line.split(" ") + const separator = fields.indexOf("-") + if (separator < 0 || !fields[4] || !fields[separator + 2]) continue + const source = decodeMountField(fields[separator + 2]!).replace(/\\/g, "/") + const match = source.match(/^([a-z]):(?:\/(.*))?$/i) + if (!match) continue + const sourcePrefix = path.posix.normalize(`/${match[2] ?? ""}`).split("/").filter(Boolean).join("/") + mounts.push({ + mountPoint: path.posix.normalize(decodeMountField(fields[4])), + drive: match[1]!.toLowerCase(), + sourcePrefix, + }) + } + return mounts.sort((left, right) => right.mountPoint.length - left.mountPoint.length) +} + +export function currentWslWindowsDriveMounts( + platform: NodeJS.Platform = process.platform, + release = os.release(), +): WslWindowsDriveMount[] { + if (platform !== "linux" || !/microsoft/i.test(release)) return [] + try { + return parseWslWindowsDriveMounts(readFileSync("/proc/self/mountinfo", "utf8")) + } catch { + return [] + } +} + +export function windowsDrivePathIdentity( + value: string, + platform: NodeJS.Platform, + release = os.release(), + mounts: readonly WslWindowsDriveMount[] = currentWslWindowsDriveMounts(platform, release), +): string | null { + if (platform === "win32") { + const match = value.replace(/\\/g, "/").match(/^([a-z]):(?:\/(.*))?$/i) + if (!match) return null + const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` + } + if (platform !== "linux" || !/microsoft/i.test(release)) return null + const normalized = path.posix.normalize(value) + for (const mount of mounts) { + const relative = path.posix.relative(path.posix.resolve(mount.mountPoint), normalized) + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) continue + const suffix = [mount.sourcePrefix ?? "", relative].join("/").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${mount.drive.toLowerCase()}:/${suffix}` + } + return null +} + +export function defaultWslWindowsDrivePathIdentity(value: string): string | null { + const match = path.posix.normalize(value).match(/^\/mnt\/([a-z])(?:\/(.*))?$/i) + if (!match) return null + const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` +} + +export function resolveWslWindowsPath(distribution: string, linuxPath: string): string | undefined { + try { + const result = spawnSync("wsl.exe", ["--distribution", distribution, "--exec", "wslpath", "-w", linuxPath], { + encoding: "utf8", + timeout: 2_000, + windowsHide: true, + }) + if (result.status !== 0) return undefined + return String(result.stdout ?? "").trim() || undefined + } catch { + return undefined + } +} diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index d99219a8c..e09ccabb4 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -33,7 +33,7 @@ tauri-plugin-notification = "2" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0.2" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 78c42dd11..52d4ef3c9 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -712,6 +712,32 @@ pub struct CliStatus { pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CliReadyAuthority { + pub generation: u64, + pub pid: u32, + pub base_url: String, +} + +fn ready_authority_for( + generation: u64, + status: &CliStatus, + child_pid: Option, +) -> Option { + let pid = status.pid?; + let base_url = status.url.clone()?; + (status.state == CliState::Ready && child_pid == Some(pid)).then_some(CliReadyAuthority { + generation, + pid, + base_url, + }) +} + +fn live_child_pid(child: &mut Option) -> Option { + let child = child.as_mut()?; + child.try_wait().ok()?.is_none().then(|| child.id()) +} + impl Default for CliStatus { fn default() -> Self { Self { @@ -874,8 +900,35 @@ impl CliProcessManager { self.status.lock().clone() } + pub(crate) fn ready_authority(&self) -> Option { + let _lifecycle = self.lifecycle.lock(); + let status = self.status.lock(); + let child_pid = live_child_pid(&mut self.child.lock()); + ready_authority_for(self.generation.load(Ordering::SeqCst), &status, child_pid) + } + + pub(crate) fn with_ready_authority( + &self, + authority: &CliReadyAuthority, + operation: impl FnOnce() -> T, + ) -> Option { + let _lifecycle = self.lifecycle.lock(); + let status = self.status.lock(); + let mut child = self.child.lock(); + let current = ready_authority_for( + self.generation.load(Ordering::SeqCst), + &status, + live_child_pid(&mut child), + )?; + (current == *authority).then(operation) + } + pub fn desktop_event_stream_config(&self) -> Option { - let base_url = self.status.lock().url.clone()?; + let status = self.status.lock(); + if status.state != CliState::Ready { + return None; + } + let base_url = status.url.clone()?; let events_url = format!("{}/api/events", base_url.trim_end_matches('/')); let client_id = format!("tauri-{}", std::process::id()); let cookie_name = self @@ -1113,7 +1166,7 @@ impl CliProcessManager { thread::spawn(move || loop { enum Poll { Running, - Exited(std::process::ExitStatus), + Exited(CliStatus, Option, u64), Failed(String), } let poll = { @@ -1140,12 +1193,35 @@ impl CliProcessManager { let group_is_gone: anyhow::Result = Ok(true); match group_is_gone { Ok(gone) if containment_is_complete(true, gone) => { + // Revoke folder authority before dropping the child/job identity. + let exit_generation = manager.advance_generation(); + let (snapshot, error) = { + let mut current = manager.status.lock(); + let error = if current.state != CliState::Ready { + current.state = CliState::Error; + let message = current + .error + .get_or_insert_with(|| { + format!("CLI exited early: {status}") + }) + .clone(); + Some(message) + } else { + current.state = CliState::Stopped; + None + }; + current.pid = None; + current.port = None; + current.url = None; + (current.clone(), error) + }; + *manager.session_cookie.lock() = None; *child = None; #[cfg(windows)] { manager.job.lock().take(); } - Poll::Exited(status) + Poll::Exited(snapshot, error, exit_generation) } // The root may be only a launcher/wrapper. Keep ownership and // monitoring until the process group/job is actually empty. @@ -1165,20 +1241,10 @@ impl CliProcessManager { manager.publish_error(&app, generation, message); return; } - Poll::Exited(code) => { - manager.with_current_generation(generation, || { - let mut status = manager.status.lock(); - if status.state != CliState::Ready { - status.state = CliState::Error; - if status.error.is_none() { - status.error = Some(format!("CLI exited early: {code}")); - } - let _ = app.emit( - "cli:error", - json!({"message": status.error.clone().unwrap_or_default()}), - ); - } else { - status.state = CliState::Stopped; + Poll::Exited(status, error, exit_generation) => { + manager.with_current_generation(exit_generation, || { + if let Some(message) = error { + let _ = app.emit("cli:error", json!({"message": message})); } Self::emit_status(&app, &status); }); @@ -1762,6 +1828,28 @@ mod tests { assert_eq!(manager.status().state, CliState::Ready); } + #[test] + fn folder_authority_requires_the_same_ready_generation_and_child() { + let status = CliStatus { + state: CliState::Ready, + pid: Some(42), + port: Some(43123), + url: Some("http://127.0.0.1:43123".to_string()), + error: None, + }; + let authority = ready_authority_for(7, &status, Some(42)).unwrap(); + assert_eq!(authority.generation, 7); + assert_ne!( + ready_authority_for(8, &status, Some(42)).unwrap(), + authority + ); + assert!(ready_authority_for(7, &status, None).is_none()); + + let mut exited = status; + exited.state = CliState::Stopped; + assert!(ready_authority_for(7, &exited, Some(42)).is_none()); + } + #[test] fn stop_waits_for_an_authorized_spawn_section() { let manager = CliProcessManager::new(); diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index d2b37b188..ab2bca452 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -274,6 +274,25 @@ impl ClientState { self.renderer_access.claim(access_token, renderer_url) } + pub(crate) fn validate_renderer_access( + &self, + access_token: &str, + renderer_url: &Url, + ) -> Result { + self.renderer_access.validate(access_token, renderer_url) + } + + pub(crate) fn with_renderer_access( + &self, + access_token: &str, + renderer_url: &Url, + generation: u64, + operation: impl FnOnce() -> Result, + ) -> Result { + self.renderer_access + .with_valid_access(access_token, renderer_url, generation, operation) + } + fn load(&self) -> Result { let state = self.state.lock().map_err(|err| err.to_string())?; let is_primary = self.is_primary(); diff --git a/packages/tauri-app/src-tauri/src/client_state/access.rs b/packages/tauri-app/src-tauri/src/client_state/access.rs index 31181c62e..e1372d796 100644 --- a/packages/tauri-app/src-tauri/src/client_state/access.rs +++ b/packages/tauri-app/src-tauri/src/client_state/access.rs @@ -9,22 +9,23 @@ pub(super) struct RendererAccess { #[derive(Default)] struct RendererAccessState { token: Option, - committed_origin: Option, - pending_origin: Option, + committed_document: Option, + pending_document: Option, generation: u64, } pub(super) struct PendingNavigation { - previous_origin: Option, - staged_origin: Option, + previous_document: Option, + staged_document: Option, } -fn origin_key(url: &Url) -> Result { - match (url.scheme(), url.host_str()) { - ("http" | "https", Some(_)) => Ok(url.origin().ascii_serialization()), - ("tauri" | "asset", Some(host)) => Ok(format!("{}://{}", url.scheme(), host)), - _ => Err("Client state renderer URL does not have a supported origin".to_string()), - } +fn document_key(url: &Url) -> Result { + let origin = match (url.scheme(), url.host_str()) { + ("http" | "https", Some(_)) => url.origin().ascii_serialization(), + ("tauri" | "asset", Some(host)) => format!("{}://{}", url.scheme(), host), + _ => return Err("Client state renderer URL does not have a supported origin".to_string()), + }; + Ok(format!("{origin}{}", url.path())) } impl RendererAccess { @@ -33,24 +34,24 @@ impl RendererAccess { return Err("Client state access token must not be empty".to_string()); } - let renderer_origin = origin_key(renderer_url)?; + let renderer_document = document_key(renderer_url)?; let mut state = self.state.lock().map_err(|err| err.to_string())?; if state.token.is_none() - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + || state.pending_document.as_deref() == Some(renderer_document.as_str()) { state.token = Some(access_token.to_string()); - state.committed_origin = Some(renderer_origin); - state.pending_origin = None; + state.committed_document = Some(renderer_document); + state.pending_document = None; state.generation = state.generation.wrapping_add(1); return Ok(()); } if state.token.as_deref() != Some(access_token) { return Err("Client state access token does not match this renderer".to_string()); } - if state.committed_origin.as_deref() == Some(renderer_origin.as_str()) { + if state.committed_document.as_deref() == Some(renderer_document.as_str()) { Ok(()) } else { - Err("Client state renderer origin changed without access rotation".to_string()) + Err("Client state renderer document changed without access rotation".to_string()) } } @@ -59,22 +60,40 @@ impl RendererAccess { return Err("Client state access token must not be empty".to_string()); } - let renderer_origin = origin_key(renderer_url)?; + let renderer_document = document_key(renderer_url)?; let state = self.state.lock().map_err(|err| err.to_string())?; if state.token.as_deref() == Some(access_token) - && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) + && state.committed_document.as_deref() == Some(renderer_document.as_str()) { return Ok(state.generation); } match state.token.as_deref() { Some(current) if current == access_token => { - Err("Client state renderer origin does not match this renderer".to_string()) + Err("Client state renderer document does not match this renderer".to_string()) } Some(_) => Err("Client state access token does not match this renderer".to_string()), None => Err("Client state access has not been claimed by this renderer".to_string()), } } + pub(super) fn with_valid_access( + &self, + access_token: &str, + renderer_url: &Url, + generation: u64, + operation: impl FnOnce() -> Result, + ) -> Result { + let renderer_document = document_key(renderer_url)?; + let state = self.state.lock().map_err(|err| err.to_string())?; + if state.generation != generation + || state.token.as_deref() != Some(access_token) + || state.committed_document.as_deref() != Some(renderer_document.as_str()) + { + return Err("Client state renderer authority changed before dispatch".to_string()); + } + operation() + } + pub(super) fn is_generation_current(&self, generation: u64) -> bool { self.state .lock() @@ -82,15 +101,15 @@ impl RendererAccess { .unwrap_or(false) } - pub(super) fn allows_claim_origin(&self, renderer_url: &Url) -> bool { - let Ok(renderer_origin) = origin_key(renderer_url) else { + pub(super) fn allows_claim_document(&self, renderer_url: &Url) -> bool { + let Ok(renderer_document) = document_key(renderer_url) else { return false; }; self.state .lock() .map(|state| { - state.committed_origin.as_deref() == Some(renderer_origin.as_str()) - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + state.committed_document.as_deref() == Some(renderer_document.as_str()) + || state.pending_document.as_deref() == Some(renderer_document.as_str()) }) .unwrap_or(false) } @@ -100,24 +119,24 @@ impl RendererAccess { target_url: Option<&Url>, ) -> Result { let mut state = self.state.lock().map_err(|err| err.to_string())?; - let previous_origin = state.pending_origin.clone(); - let staged_origin = match target_url { - Some(url) => Some(origin_key(url)?), - None => previous_origin + let previous_document = state.pending_document.clone(); + let staged_document = match target_url { + Some(url) => Some(document_key(url)?), + None => previous_document .clone() - .or_else(|| state.committed_origin.clone()), + .or_else(|| state.committed_document.clone()), }; - state.pending_origin = staged_origin.clone(); + state.pending_document = staged_document.clone(); Ok(PendingNavigation { - previous_origin, - staged_origin, + previous_document, + staged_document, }) } pub(super) fn cancel_navigation(&self, navigation: PendingNavigation) { let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if state.pending_origin == navigation.staged_origin { - state.pending_origin = navigation.previous_origin; + if state.pending_document == navigation.staged_document { + state.pending_document = navigation.previous_document; } } diff --git a/packages/tauri-app/src-tauri/src/client_state/commands.rs b/packages/tauri-app/src-tauri/src/client_state/commands.rs index f22b64232..5f32cdb88 100644 --- a/packages/tauri-app/src-tauri/src/client_state/commands.rs +++ b/packages/tauri-app/src-tauri/src/client_state/commands.rs @@ -4,9 +4,9 @@ use serde_json::Value; use tauri::{AppHandle, State, WebviewWindow}; use url::Url; -fn same_origin(url: &Url, expected: &str) -> bool { +fn same_document(url: &Url, expected: &str) -> bool { Url::parse(expected) - .map(|expected| url.origin() == expected.origin()) + .map(|expected| url.origin() == expected.origin() && url.path() == expected.path()) .unwrap_or(false) } @@ -25,12 +25,11 @@ fn is_dev_renderer_origin(url: &Url) -> bool { && url.port() == Some(1420) } -pub(super) fn is_allowed_client_state_origin(url: &Url, managed_cli_url: Option<&str>) -> bool { +pub(super) fn is_allowed_client_state_document(url: &Url, managed_cli_url: Option<&str>) -> bool { managed_cli_url - .map(|expected| same_origin(url, expected)) + .map(|expected| same_document(url, expected)) .unwrap_or(false) - || is_app_renderer_origin(url) - || is_dev_renderer_origin(url) + || url.path() == "/" && (is_app_renderer_origin(url) || is_dev_renderer_origin(url)) } fn main_window_url(window: &WebviewWindow) -> Result { @@ -45,18 +44,19 @@ fn main_window_url(window: &WebviewWindow) -> Result { .map_err(|err| format!("failed to inspect current renderer URL: {err}")) } -fn validate_claim_origin( +fn validate_claim_document( current_url: &Url, app_state: &AppState, state: &ClientState, ) -> Result<(), String> { let status = app_state.manager.status(); - if state.renderer_access.allows_claim_origin(current_url) - || is_allowed_client_state_origin(current_url, status.url.as_deref()) + if current_url.path() == "/" + && (state.renderer_access.allows_claim_document(current_url) + || is_allowed_client_state_document(current_url, status.url.as_deref())) { Ok(()) } else { - Err("Client state commands are not available to the current renderer origin".to_string()) + Err("Client state commands are not available to the current renderer document".to_string()) } } @@ -77,7 +77,7 @@ pub fn client_state_claim_access( access_token: String, ) -> Result<(), String> { let current_url = main_window_url(&window)?; - validate_claim_origin(¤t_url, &app_state, &state)?; + validate_claim_document(¤t_url, &app_state, &state)?; state.claim_renderer_access(&access_token, ¤t_url) } diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs index afeaeedce..d1486f20e 100644 --- a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -16,6 +16,7 @@ const RECOVERY_PREFIX: &str = "recovery."; const RECOVERY_SUFFIX: &str = ".claim"; const RETIRED_PREFIX: &str = "retired."; const ACQUIRE_ATTEMPTS: usize = 10; +const LEGACY_PARTICIPANT_GRACE: std::time::Duration = std::time::Duration::from_millis(50); #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -229,7 +230,7 @@ impl Registration { let legacy_blocked = legacy_electron_data .filter(|_| primary_candidate) .map(|path| { - has_live_legacy_electron_with( + has_live_legacy_electron_with_grace( path, election_directory, pid_alive, @@ -305,7 +306,7 @@ impl Registration { .legacy_electron_data .as_deref() .map(|path| { - has_live_legacy_electron_with( + has_live_legacy_electron_with_grace( path, &self.election_directory, pid_is_alive, @@ -722,6 +723,34 @@ fn has_live_legacy_electron_with( Ok(false) } +fn has_live_legacy_electron_with_grace( + directory: &Path, + election_directory: &Path, + pid_alive: impl Fn(u32) -> bool + Copy, + identity: impl Fn(u32) -> Option + Copy, + expected_electron: impl Fn(u32) -> Option + Copy, +) -> Result { + let blocked = has_live_legacy_electron_with( + directory, + election_directory, + pid_alive, + identity, + expected_electron, + )?; + if !blocked { + return Ok(false); + } + // ponytail: one bounded retry closes the modern participant-publication race. + std::thread::sleep(LEGACY_PARTICIPANT_GRACE); + has_live_legacy_electron_with( + directory, + election_directory, + pid_alive, + identity, + expected_electron, + ) +} + fn expected_electron_process(pid: u32) -> Option { let executable = process_executable(pid)?; let current = std::env::current_exe().ok()?; @@ -1413,6 +1442,34 @@ mod tests { .unwrap()); } + #[test] + fn legacy_check_retries_after_a_modern_participant_is_published() { + let election = tempfile::tempdir().unwrap(); + let legacy = tempfile::tempdir().unwrap(); + let upgraded = owner(703, "electron", "electron-start"); + let participant = participant_path(election.path(), &upgraded); + let published = std::cell::Cell::new(false); + fs::write( + legacy.path().join("client-state.running.703.electron.json"), + serialize_owner(&upgraded).unwrap(), + ) + .unwrap(); + + assert!(!has_live_legacy_electron_with_grace( + legacy.path(), + election.path(), + |_| { + if !published.replace(true) { + publish_participant(&participant, &upgraded).unwrap(); + } + true + }, + |_| Some("electron-start".to_string()), + |_| Some(true), + ) + .unwrap()); + } + #[test] fn platform_paths_match_electron() { let resolve = |platform: &str, values: HashMap<&str, &str>, fallback: &str| { diff --git a/packages/tauri-app/src-tauri/src/client_state/navigation.rs b/packages/tauri-app/src-tauri/src/client_state/navigation.rs index ef49dbc01..055e81180 100644 --- a/packages/tauri-app/src-tauri/src/client_state/navigation.rs +++ b/packages/tauri-app/src-tauri/src/client_state/navigation.rs @@ -304,7 +304,7 @@ mod tests { .renderer_access .validate("outgoing-renderer", &outgoing_url) .unwrap(); - assert!(state.renderer_access.allows_claim_origin(&outgoing_url)); + assert!(state.renderer_access.allows_claim_document(&outgoing_url)); state .renderer_access .claim("incoming-renderer", &incoming_url) diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index a2a7d6187..b80eb6b60 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -1,4 +1,4 @@ -use super::commands::is_allowed_client_state_origin; +use super::commands::is_allowed_client_state_document; use super::process::{PRIMARY_LOCK_FILENAME, RUNNING_MARKER_PREFIX, RUNNING_MARKER_SUFFIX}; use super::window::{ clamp_window_bounds, normalize_native_zoom_level, DisplayArea, NativeWindowState, WindowBounds, @@ -512,11 +512,14 @@ fn future_envelope_is_preserved_until_successful_clear() { fn renderer_tokens_and_origins_are_isolated_across_navigation() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); - let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); - let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let outgoing = Url::parse("http://127.0.0.1:43123/").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/").unwrap(); assert!(state.renderer_access.claim("", &outgoing).is_err()); assert_access_rejected(&state, "missing", &outgoing); state.renderer_access.claim("outgoing", &outgoing).unwrap(); + let preview = Url::parse("http://127.0.0.1:43123/previews/token").unwrap(); + assert_access_rejected(&state, "outgoing", &preview); + assert!(state.renderer_access.claim("outgoing", &preview).is_err()); assert!(state.renderer_access.claim("other", &outgoing).is_err()); assert_access_rejected(&state, "outgoing", &incoming); state @@ -536,19 +539,23 @@ fn renderer_tokens_and_origins_are_isolated_across_navigation() { for (url, managed, allowed) in [ (&outgoing, Some("http://127.0.0.1:43123"), true), (&incoming, Some("http://127.0.0.1:43123"), false), - ( - &Url::parse("http://localhost:9000/workspace").unwrap(), - None, - false, - ), - ( - &Url::parse("https://tauri.localhost/loading.html").unwrap(), - None, - true, - ), + (&Url::parse("http://localhost:9000/").unwrap(), None, false), + (&Url::parse("https://tauri.localhost/").unwrap(), None, true), ] { - assert_eq!(is_allowed_client_state_origin(url, managed), allowed); + assert_eq!(is_allowed_client_state_document(url, managed), allowed); } + assert!(!is_allowed_client_state_document( + &preview, + Some("http://127.0.0.1:43123") + )); + assert!(!is_allowed_client_state_document( + &Url::parse("https://tauri.localhost/loading.html").unwrap(), + None + )); + assert!(is_allowed_client_state_document( + &Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(), + Some("http://127.0.0.1:43123") + )); for url in ["file:///tmp/loading.html", "about:blank"] { assert!(state .renderer_access @@ -729,6 +736,46 @@ fn renderer_rotation_blocks_an_in_flight_old_renderer_replacement() { assert_eq!(state.load().unwrap().snapshot, Value::Null); assert!(!directory.path().join(CLIENT_STATE_FILENAME).exists()); } + +#[test] +fn renderer_rotation_waits_until_guarded_native_dispatch_finishes() { + let directory = tempfile::tempdir().unwrap(); + let state = Arc::new(ClientState::initialize_at(directory.path()).unwrap()); + let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + state.renderer_access.claim("old", &outgoing).unwrap(); + let generation = state.renderer_access.validate("old", &outgoing).unwrap(); + let (dispatch_tx, dispatch_rx) = mpsc::sync_channel(0); + let (release_tx, release_rx) = mpsc::sync_channel(0); + let guarded = Arc::clone(&state); + let guarded_url = outgoing.clone(); + let dispatch = thread::spawn(move || { + guarded + .renderer_access + .with_valid_access("old", &guarded_url, generation, || { + dispatch_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }) + }); + dispatch_rx.recv().unwrap(); + + let rotating = Arc::clone(&state); + let (rotated_tx, rotated_rx) = mpsc::sync_channel(0); + let rotation = thread::spawn(move || { + rotating + .renderer_access + .begin_navigation(Some(&incoming)) + .unwrap(); + rotating.renderer_access.claim("new", &incoming).unwrap(); + rotated_tx.send(()).unwrap(); + }); + assert!(rotated_rx.recv_timeout(Duration::from_millis(20)).is_err()); + release_tx.send(()).unwrap(); + dispatch.join().unwrap().unwrap(); + rotated_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + rotation.join().unwrap(); +} #[test] fn oversized_snapshot_does_not_replace_state() { let directory = tempfile::tempdir().unwrap(); diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4dc142612..ca67fa9e1 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -10,6 +10,7 @@ mod linux_tls; mod managed_node; mod shutdown; mod windows_update; +mod worktree_directory; use cli_manager::{CliProcessManager, CliStatus}; use desktop_event_transport::{ @@ -233,8 +234,44 @@ fn should_allow_window_origin( false } +fn is_allowed_main_window_document(url: &Url, managed_cli_url: Option<&str>) -> bool { + if let Some(managed) = managed_cli_url.and_then(|value| Url::parse(value).ok()) { + if url.origin() == managed.origin() && matches!(url.path(), "/" | "/login" | "/auth/token") + { + return true; + } + } + + match url.scheme() { + "tauri" | "asset" | "file" => url.path().ends_with("/loading.html"), + "about" => url.as_str() == "about:blank", + "http" | "https" if url.host_str() == Some("tauri.localhost") => { + url.path() == "/loading.html" + } + "http" if is_dev_mode() => { + matches!(url.host_str(), Some("127.0.0.1" | "localhost")) + && url.port() == Some(1420) + && url.path() == "/" + } + _ => false, + } +} + fn intercept_navigation(webview: &Webview, url: &Url) -> bool { let window_label = webview.label().to_string(); + if window_label == "main" { + if is_allowed_main_window_document(url, None) { + return true; + } + let app_handle = webview.app_handle(); + let status = app_handle.state::().manager.status(); + if is_allowed_main_window_document(url, status.url.as_deref()) { + return true; + } + if should_allow_window_origin(&app_handle, &window_label, url) { + return false; + } + } if should_allow_window_origin(&webview.app_handle(), &window_label, url) { return true; } @@ -664,6 +701,7 @@ fn main() { desktop_events_stop, wake_lock_start, wake_lock_stop, + worktree_directory::open_local_directory, needs_local_certificate_install, open_remote_window, client_state::client_state_claim_access, @@ -1057,6 +1095,29 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada } } +#[cfg(test)] +mod navigation_guard_tests { + use super::is_allowed_main_window_document; + use url::Url; + + #[test] + fn main_window_allows_shell_reload_and_denies_same_origin_preview_navigation() { + let managed = Some("http://127.0.0.1:43123"); + assert!(is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(), + managed + )); + assert!(!is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/previews/token").unwrap(), + managed + )); + assert!(!is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/workspaces/owned").unwrap(), + managed + )); + } +} + #[cfg(test)] mod menu_tests { use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL}; diff --git a/packages/tauri-app/src-tauri/src/worktree_directory.rs b/packages/tauri-app/src-tauri/src/worktree_directory.rs new file mode 100644 index 000000000..8dfc5aa13 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/worktree_directory.rs @@ -0,0 +1,782 @@ +use crate::client_state::ClientState; +use crate::{cert_manager, AppState}; +use reqwest::header::{ACCEPT, COOKIE}; +use reqwest::redirect::Policy; +use serde::Deserialize; +#[cfg(unix)] +use std::fs::File; +use std::net::IpAddr; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tauri::{AppHandle, State, WebviewWindow}; +use tauri_plugin_opener::OpenerExt; +use url::Url; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileInformationByHandle, GetFinalPathNameByHandleW, BY_HANDLE_FILE_INFORMATION, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, VOLUME_NAME_DOS, +}; +#[cfg(windows)] +use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE; + +#[cfg(not(windows))] +const DRIVE_REMOTE: u32 = 4; + +const MAX_WORKTREE_RESPONSE_BYTES: usize = 1024 * 1024; +const WORKTREE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const WORKTREE_READ_TIMEOUT: Duration = Duration::from_secs(5); +const WORKTREE_TOTAL_TIMEOUT: Duration = Duration::from_secs(15); + +#[cfg(windows)] +#[derive(Clone, Copy, PartialEq, Eq)] +struct DirectoryIdentity { + volume: u32, + index_high: u32, + index_low: u32, +} + +#[cfg(windows)] +struct VerifiedDirectory { + path: PathBuf, + handle: HANDLE, + identity: DirectoryIdentity, + final_path: PathBuf, +} + +#[cfg(unix)] +struct VerifiedDirectory { + path: PathBuf, + handle: File, + dev: u64, + ino: u64, +} + +#[cfg(windows)] +impl Drop for VerifiedDirectory { + fn drop(&mut self) { + unsafe { CloseHandle(self.handle) }; + } +} + +#[cfg(windows)] +fn final_path_for_handle(handle: HANDLE) -> Result { + let mut buffer = vec![0u16; 512]; + loop { + let length = unsafe { + GetFinalPathNameByHandleW( + handle, + buffer.as_mut_ptr(), + buffer.len() as u32, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + if length == 0 { + return Err("Unable to resolve the final worktree path".to_string()); + } + if (length as usize) < buffer.len() { + return Ok(std::ffi::OsString::from_wide(&buffer[..length as usize]).into()); + } + buffer.resize(length as usize + 1, 0); + } +} + +#[cfg(windows)] +fn validate_windows_final_path( + path: &Path, + drive_type: impl FnOnce(&Path) -> Option, +) -> Result<(), String> { + let kind = drive_type(path); + if is_network_path(path) || kind.is_none() || kind == Some(DRIVE_REMOTE) { + return Err("Network worktrees cannot be opened".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn same_windows_path(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +#[cfg(windows)] +fn open_directory_handle(path: &Path) -> Result<(HANDLE, DirectoryIdentity, PathBuf), String> { + let wide = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err("Worktree not found".to_string()); + } + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(handle, &mut info) } == 0 + || info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 + || info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + unsafe { CloseHandle(handle) }; + return Err("Worktree not found".to_string()); + } + let final_path = match final_path_for_handle(handle) { + Ok(path) => path, + Err(error) => { + unsafe { CloseHandle(handle) }; + return Err(error); + } + }; + if let Err(error) = validate_windows_final_path(&final_path, windows_drive_type) { + unsafe { CloseHandle(handle) }; + return Err(error); + } + Ok(( + handle, + DirectoryIdentity { + volume: info.dwVolumeSerialNumber, + index_high: info.nFileIndexHigh, + index_low: info.nFileIndexLow, + }, + final_path, + )) +} + +impl VerifiedDirectory { + fn open(path: PathBuf) -> Result { + #[cfg(windows)] + { + let (handle, identity, final_path) = open_directory_handle(&path)?; + if !same_windows_path(&path, &final_path) { + unsafe { CloseHandle(handle) }; + return Err("Worktree changed before it could be opened".to_string()); + } + Ok(Self { + path, + handle, + identity, + final_path, + }) + } + #[cfg(unix)] + { + let link = std::fs::symlink_metadata(&path).map_err(|_| "Worktree not found")?; + let handle = File::open(&path).map_err(|_| "Worktree not found")?; + let identity = handle.metadata().map_err(|_| "Worktree not found")?; + if !identity.is_dir() || link.file_type().is_symlink() { + return Err("Worktree not found".to_string()); + } + Ok(Self { + path, + handle, + dev: identity.dev(), + ino: identity.ino(), + }) + } + } + + fn revalidate(&self) -> Result<(), String> { + #[cfg(windows)] + { + let held_final_path = final_path_for_handle(self.handle)?; + validate_windows_final_path(&held_final_path, windows_drive_type)?; + let (handle, identity, final_path) = open_directory_handle(&self.path)?; + unsafe { CloseHandle(handle) }; + if identity != self.identity + || !same_windows_path(&held_final_path, &self.final_path) + || !same_windows_path(&final_path, &self.final_path) + { + return Err("Worktree changed before it could be opened".to_string()); + } + } + #[cfg(unix)] + { + let held = self.handle.metadata().map_err(|_| "Worktree not found")?; + let current = std::fs::metadata(&self.path).map_err(|_| "Worktree not found")?; + let link = std::fs::symlink_metadata(&self.path).map_err(|_| "Worktree not found")?; + let canonical = std::fs::canonicalize(&self.path).map_err(|_| "Worktree not found")?; + if !held.is_dir() + || !current.is_dir() + || link.file_type().is_symlink() + || canonical != self.path + || held.dev() != self.dev + || held.ino() != self.ino + || current.dev() != self.dev + || current.ino() != self.ino + { + return Err("Worktree changed before it could be opened".to_string()); + } + } + Ok(()) + } +} + +#[derive(Deserialize)] +struct WorktreeListResponse { + worktrees: Vec, +} + +#[derive(Deserialize)] +struct Worktree { + slug: String, + directory: String, +} + +fn is_loopback(url: &Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .map(|address| address.is_loopback()) + .unwrap_or(false) +} + +fn worktree_endpoint(base_url: &str, instance_id: &str) -> Result { + if instance_id.is_empty() || instance_id.trim() != instance_id { + return Err("Invalid workspace".to_string()); + } + let mut url = Url::parse(base_url).map_err(|_| "Managed backend is unavailable")?; + if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) { + return Err("Managed backend is unavailable".to_string()); + } + url.set_query(None); + url.set_fragment(None); + url.path_segments_mut() + .map_err(|_| "Managed backend is unavailable")? + .clear() + .extend(["api", "workspaces", instance_id, "worktrees"]); + Ok(url) +} + +fn is_managed_renderer_document(renderer_url: &Url, base_url: &str) -> bool { + Url::parse(base_url) + .map(|managed| { + is_loopback(&managed) + && renderer_url.origin() == managed.origin() + && renderer_url.path() == managed.path() + }) + .unwrap_or(false) +} + +fn is_network_path(path: &Path) -> bool { + let normalized = path.to_string_lossy().replace('/', "\\").to_lowercase(); + if normalized.starts_with(r"\\?\unc\") { + return true; + } + if normalized.starts_with(r"\\?\") { + let suffix = &normalized[4..]; + return !(suffix.len() >= 3 + && suffix.as_bytes()[0].is_ascii_alphabetic() + && &suffix[1..3] == r":\"); + } + normalized.starts_with(r"\\") +} + +fn is_remote_windows_drive( + path: &Path, + windows: bool, + drive_type: impl FnOnce(&Path) -> Option, +) -> bool { + windows && drive_type(path) == Some(DRIVE_REMOTE) +} + +#[cfg(windows)] +fn windows_drive_type(path: &Path) -> Option { + use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; + + let normalized = path.to_string_lossy().replace('/', "\\"); + let drive = normalized + .strip_prefix(r"\\?\") + .unwrap_or(&normalized) + .as_bytes(); + if drive.len() < 3 || !drive[0].is_ascii_alphabetic() || &drive[1..3] != b":\\" { + return None; + } + let root = format!("{}:\\", drive[0] as char) + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + Some(unsafe { GetDriveTypeW(root.as_ptr()) }) +} + +fn is_remote_drive(path: &Path) -> bool { + #[cfg(windows)] + return is_remote_windows_drive(path, true, windows_drive_type); + #[cfg(not(windows))] + return false; +} + +fn resolve_directory( + body: &[u8], + worktree_slug: &str, + canonicalize: impl FnOnce(&Path) -> std::io::Result, + remote_drive: impl FnOnce(&Path) -> bool, +) -> Result { + if worktree_slug.is_empty() || worktree_slug.trim() != worktree_slug { + return Err("Invalid worktree".to_string()); + } + let response: WorktreeListResponse = + serde_json::from_slice(body).map_err(|_| "Malformed worktree response")?; + let mut slugs = std::collections::HashSet::new(); + let mut selected = None; + for worktree in response.worktrees { + if worktree.slug.is_empty() + || worktree.slug.trim() != worktree.slug + || worktree.directory.is_empty() + || !slugs.insert(worktree.slug.clone()) + { + return Err("Malformed worktree response".to_string()); + } + if worktree.slug == worktree_slug { + selected = Some(worktree.directory); + } + } + let selected = selected.ok_or_else(|| "Worktree not found".to_string())?; + if is_network_path(Path::new(&selected)) { + return Err("Network worktrees cannot be opened".to_string()); + } + let canonical = canonicalize(Path::new(&selected)).map_err(|_| "Worktree not found")?; + if is_network_path(&canonical) || remote_drive(&canonical) { + return Err("Network worktrees cannot be opened".to_string()); + } + if !canonical.is_dir() { + return Err("Worktree not found".to_string()); + } + Ok(canonical) +} + +fn validate_content_length(length: Option) -> Result<(), String> { + if length.is_some_and(|size| size > MAX_WORKTREE_RESPONSE_BYTES as u64) { + return Err("Malformed worktree response".to_string()); + } + Ok(()) +} + +fn append_bounded(body: &mut Vec, chunk: &[u8]) -> Result<(), String> { + if body.len().saturating_add(chunk.len()) > MAX_WORKTREE_RESPONSE_BYTES { + return Err("Malformed worktree response".to_string()); + } + body.extend_from_slice(chunk); + Ok(()) +} + +async fn read_bounded_body(mut response: reqwest::Response) -> Result, String> { + validate_content_length(response.content_length())?; + let mut body = Vec::with_capacity( + response + .content_length() + .unwrap_or(0) + .min(MAX_WORKTREE_RESPONSE_BYTES as u64) as usize, + ); + while let Some(chunk) = response.chunk().await.map_err(worktree_lookup_error)? { + append_bounded(&mut body, &chunk)?; + } + Ok(body) +} + +fn worktree_lookup_error(error: reqwest::Error) -> String { + if error.is_timeout() { + "Worktree lookup timed out".to_string() + } else { + "Worktree lookup failed".to_string() + } +} + +fn apply_worktree_timeouts( + builder: reqwest::ClientBuilder, + connect: Duration, + read: Duration, + total: Duration, +) -> reqwest::ClientBuilder { + builder + .connect_timeout(connect) + .read_timeout(read) + .timeout(total) +} + +fn dispatch_after_validation( + validate: impl FnOnce() -> Result<(), String>, + dispatch: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + validate()?; + dispatch() +} + +fn cookie_value(window: &WebviewWindow, base_url: &str, name: &str) -> Option { + let url = Url::parse(base_url).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + window.cookies().ok()?.into_iter().find_map(|cookie| { + if cookie.name() != name { + return None; + } + if let Some(domain) = cookie.domain() { + let domain = domain.trim_start_matches('.').to_ascii_lowercase(); + if host != domain && !host.ends_with(&format!(".{domain}")) { + return None; + } + } + Some(cookie.value().to_string()) + }) +} + +fn validate_authority( + window: &WebviewWindow, + state: &ClientState, + access_token: &str, + base_url: &str, +) -> Result { + if window.label() != "main" { + return Err("Directory opening is unavailable from this window".to_string()); + } + let renderer_url = window + .url() + .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; + let generation = state.validate_renderer_access(access_token, &renderer_url)?; + if !is_managed_renderer_document(&renderer_url, base_url) { + return Err("Directory opening is unavailable from this renderer".to_string()); + } + Ok(generation) +} + +#[tauri::command] +pub(crate) async fn open_local_directory( + app: AppHandle, + window: WebviewWindow, + app_state: State<'_, AppState>, + client_state: State<'_, ClientState>, + access_token: String, + instance_id: String, + worktree_slug: String, +) -> Result<(), String> { + let config = app_state + .manager + .desktop_event_stream_config() + .ok_or_else(|| "Managed backend is unavailable".to_string())?; + let authority = app_state + .manager + .ready_authority() + .filter(|authority| authority.base_url == config.base_url) + .ok_or_else(|| "Managed backend is unavailable".to_string())?; + let renderer_generation = + validate_authority(&window, &client_state, &access_token, &config.base_url)?; + let endpoint = worktree_endpoint(&config.base_url, &instance_id)?; + + let mut builder = apply_worktree_timeouts( + reqwest::Client::builder() + .no_proxy() + .redirect(Policy::none()), + WORKTREE_CONNECT_TIMEOUT, + WORKTREE_READ_TIMEOUT, + WORKTREE_TOTAL_TIMEOUT, + ); + if endpoint.scheme() == "https" { + let cert = cert_manager::ensure_local_cert()?; + let ca = + reqwest::Certificate::from_der(&cert.ca_cert_der).map_err(|err| err.to_string())?; + builder = builder.add_root_certificate(ca); + } + let client = builder.build().map_err(|err| err.to_string())?; + let mut request = client + .get(endpoint.clone()) + .header(ACCEPT, "application/json"); + let session_cookie = + cookie_value(&window, &config.base_url, &config.cookie_name).or(config.session_cookie); + if let Some(value) = session_cookie.filter(|value| !value.is_empty()) { + request = request.header(COOKIE, format!("{}={}", config.cookie_name, value)); + } + let response = request.send().await.map_err(worktree_lookup_error)?; + if response.status() != reqwest::StatusCode::OK || response.url() != &endpoint { + return Err("Worktree lookup failed".to_string()); + } + let body = read_bounded_body(response).await?; + + // Recheck the claimed token and committed origin after network I/O, before touching the filesystem. + if validate_authority(&window, &client_state, &access_token, &config.base_url)? + != renderer_generation + { + return Err("Directory opening is unavailable from this renderer".to_string()); + } + let directory = resolve_directory( + &body, + &worktree_slug, + |path| std::fs::canonicalize(path), + is_remote_drive, + )?; + let verified = VerifiedDirectory::open(directory)?; + let canonical = verified + .path + .to_str() + .ok_or_else(|| "Worktree path is not valid UTF-8".to_string())?; + app_state + .manager + .with_ready_authority(&authority, || { + let renderer_url = window + .url() + .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; + if window.label() != "main" + || !is_managed_renderer_document(&renderer_url, &config.base_url) + { + return Err("Directory opening is unavailable from this renderer".to_string()); + } + client_state.with_renderer_access( + &access_token, + &renderer_url, + renderer_generation, + || { + verified.revalidate()?; + let final_renderer_url = window + .url() + .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; + if final_renderer_url.origin() != renderer_url.origin() + || final_renderer_url.path() != renderer_url.path() + || !is_managed_renderer_document(&final_renderer_url, &config.base_url) + { + return Err( + "Directory opening is unavailable from this renderer".to_string() + ); + } + // ponytail: the opener accepts only a path, so replacement inside the OS shell is the remaining unavoidable race. + app.opener() + .open_path(canonical, None::<&str>) + .map_err(|err| err.to_string()) + }, + ) + }) + .ok_or_else(|| "Managed backend is unavailable".to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn resolves_exact_slug_and_canonicalizes_once() { + let directory = tempfile::tempdir().unwrap(); + let body = serde_json::to_vec(&serde_json::json!({ + "worktrees": [ + { "slug": "root", "directory": "ignored" }, + { "slug": "feature", "directory": directory.path() }, + ] + })) + .unwrap(); + let calls = Cell::new(0); + let resolved = resolve_directory( + &body, + "feature", + |path| { + calls.set(calls.get() + 1); + std::fs::canonicalize(path) + }, + |_| false, + ) + .unwrap(); + assert_eq!(calls.get(), 1); + assert_eq!(resolved, std::fs::canonicalize(directory.path()).unwrap()); + } + + #[test] + fn rejects_malformed_unknown_duplicate_and_network_worktrees() { + let bodies = [ + serde_json::json!({}), + serde_json::json!({ "worktrees": [{}] }), + serde_json::json!({ "worktrees": [{ "slug": "other", "directory": "C:\\repo" }] }), + serde_json::json!({ "worktrees": [ + { "slug": "feature", "directory": "C:\\one" }, + { "slug": "feature", "directory": "C:\\two" }, + ] }), + serde_json::json!({ "worktrees": [{ "slug": "feature", "directory": "\\\\server\\share" }] }), + ]; + for body in bodies { + let body = serde_json::to_vec(&body).unwrap(); + assert!(resolve_directory(&body, "feature", |_| unreachable!(), |_| false).is_err()); + } + } + + #[test] + fn rejects_canonical_paths_on_remote_windows_drives() { + let directory = tempfile::tempdir().unwrap(); + let body = serde_json::to_vec(&serde_json::json!({ + "worktrees": [{ "slug": "feature", "directory": directory.path() }] + })) + .unwrap(); + let result = resolve_directory( + &body, + "feature", + |path| std::fs::canonicalize(path), + |path| { + assert_eq!(path, std::fs::canonicalize(directory.path()).unwrap()); + true + }, + ); + assert_eq!(result.unwrap_err(), "Network worktrees cannot be opened"); + assert!(is_remote_windows_drive(Path::new(r"Z:\repo"), true, |_| { + Some(4) + })); + assert!(!is_remote_windows_drive( + Path::new(r"C:\repo"), + true, + |_| Some(3) + )); + assert!(!is_remote_windows_drive( + Path::new("/repo"), + false, + |_| panic!("not called") + )); + } + + #[test] + fn rejects_oversized_declared_and_streamed_bodies() { + assert!(validate_content_length(Some((MAX_WORKTREE_RESPONSE_BYTES + 1) as u64)).is_err()); + let mut body = Vec::new(); + append_bounded(&mut body, &vec![0; 600_000]).unwrap(); + assert!(append_bounded(&mut body, &vec![0; 600_000]).is_err()); + assert_eq!(body.len(), 600_000); + } + + #[test] + fn lookup_connect_total_and_read_deadlines_are_bounded() { + assert!(WORKTREE_CONNECT_TIMEOUT <= WORKTREE_TOTAL_TIMEOUT); + assert!(WORKTREE_READ_TIMEOUT <= WORKTREE_TOTAL_TIMEOUT); + apply_worktree_timeouts( + reqwest::Client::builder().no_proxy(), + WORKTREE_CONNECT_TIMEOUT, + WORKTREE_READ_TIMEOUT, + WORKTREE_TOTAL_TIMEOUT, + ) + .build() + .unwrap(); + } + + #[test] + fn navigation_during_canonicalization_revokes_final_dispatch() { + let generation = Cell::new(1); + let dispatched = Cell::new(false); + let directory = tempfile::tempdir().unwrap(); + let body = serde_json::to_vec(&serde_json::json!({ + "worktrees": [{ "slug": "feature", "directory": directory.path() }] + })) + .unwrap(); + resolve_directory( + &body, + "feature", + |path| { + generation.set(2); + std::fs::canonicalize(path) + }, + |_| false, + ) + .unwrap(); + assert!(dispatch_after_validation( + || (generation.get() == 1) + .then_some(()) + .ok_or_else(|| "renderer changed".to_string()), + || { + dispatched.set(true); + Ok(()) + }, + ) + .is_err()); + assert!(!dispatched.get()); + } + + #[cfg(windows)] + #[test] + fn held_directory_identity_rejects_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("feature"); + let moved = root.path().join("moved"); + std::fs::create_dir(&path).unwrap(); + let verified = VerifiedDirectory::open(std::fs::canonicalize(&path).unwrap()).unwrap(); + std::fs::rename(&path, moved).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(verified.revalidate().is_err()); + } + + #[cfg(unix)] + #[test] + fn held_posix_directory_identity_rejects_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("feature"); + let moved = root.path().join("moved"); + std::fs::create_dir(&path).unwrap(); + let verified = VerifiedDirectory::open(std::fs::canonicalize(&path).unwrap()).unwrap(); + std::fs::rename(&path, moved).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(verified.revalidate().is_err()); + } + + #[cfg(windows)] + #[test] + fn final_handle_path_rejects_unc_remote_and_unclassified_volumes() { + assert!( + validate_windows_final_path(Path::new(r"\\?\UNC\server\share\repo"), |_| { Some(3) }) + .is_err() + ); + assert!( + validate_windows_final_path(Path::new(r"\\?\Z:\repo"), |_| Some(DRIVE_REMOTE)).is_err() + ); + assert!( + validate_windows_final_path(Path::new(r"\\?\Volume{unknown}\repo"), |_| None).is_err() + ); + assert!(validate_windows_final_path(Path::new(r"\\?\C:\repo"), |_| Some(3)).is_ok()); + } + + #[cfg(windows)] + #[test] + fn initial_handle_path_must_match_the_canonical_input() { + use std::os::windows::fs::symlink_dir; + + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("target"); + let linked = root.path().join("linked"); + std::fs::create_dir_all(target.join("feature")).unwrap(); + if symlink_dir(&target, &linked).is_err() { + return; + } + assert_eq!( + VerifiedDirectory::open(linked.join("feature")) + .err() + .unwrap(), + "Worktree changed before it could be opened" + ); + } + + #[test] + fn accepts_only_the_dynamic_loopback_shell_document() { + let renderer = Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(); + assert!(is_managed_renderer_document( + &renderer, + "http://127.0.0.1:43123" + )); + assert!(!is_managed_renderer_document( + &renderer, + "http://127.0.0.1:43124" + )); + assert!(!is_managed_renderer_document( + &renderer, + "https://example.com" + )); + assert!(!is_managed_renderer_document( + &Url::parse("http://127.0.0.1:43123/previews/token").unwrap(), + "http://127.0.0.1:43123" + )); + } +} diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index 48c374b1e..8a59b799b 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,5 +1,6 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" +import { buildPreviewNavigationUrl, resolvePreviewUrl } from "./preview-isolation" export interface BrowserFrameElementTarget { pagePath: string @@ -78,9 +79,7 @@ function getElementSelector(element: Element): string { const parentElement: Element | null = current.parentElement if (parentElement) { const siblings = Array.from(parentElement.children as HTMLCollectionOf).filter((child) => child.tagName === current?.tagName) - if (siblings.length > 1) { - part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})` - } + if (siblings.length > 1) part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})` } parts.unshift(part) current = parentElement @@ -89,16 +88,17 @@ function getElementSelector(element: Element): string { } export const BrowserFrame: Component = (props) => { - const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) + const [frameSrc, setFrameSrc] = createSignal(resolvePreviewUrl(props.initialUrl, window.location.href)) const [pathInput, setPathInput] = createSignal("/") const [viewportPreset, setViewportPreset] = createSignal("responsive") const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false) const [highlight, setHighlight] = createSignal<{ x: number; y: number; width: number; height: number } | null>(null) + const [commentAvailable, setCommentAvailable] = createSignal(false) let iframeRef: HTMLIFrameElement | undefined let frameWrapRef: HTMLDivElement | undefined let cleanupFrameListeners: (() => void) | null = null - const canComment = createMemo(() => Boolean(props.onToggleCommentMode && props.onCommentTarget)) + const canComment = createMemo(() => commentAvailable() && Boolean(props.onToggleCommentMode && props.onCommentTarget)) const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) @@ -124,22 +124,18 @@ export const BrowserFrame: Component = (props) => { } const buildNormalizedTargetUrl = (rawInput: string): string => { - const trimmed = rawInput.trim() - const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}` - const parsed = new URL(withLeadingSlash || "/", window.location.origin) - - const safeSegments: string[] = [] - for (const segment of parsed.pathname.split("/")) { - if (!segment || segment === ".") continue - if (segment === "..") { - if (safeSegments.length > 0) safeSegments.pop() - continue - } - safeSegments.push(segment) - } + return buildPreviewNavigationUrl(rawInput, props.proxyBasePath, props.initialUrl, window.location.href) + } - const normalizedPath = `/${safeSegments.join("/")}` || "/" - return `${props.proxyBasePath}${normalizedPath}${parsed.search}${parsed.hash}` + const getAccessibleFrameDocument = (): Document | null => { + try { + const doc = iframeRef?.contentDocument + if (!doc || !iframeRef?.contentWindow) return null + void iframeRef.contentWindow.location.href + return doc + } catch { + return null + } } const buildElementTarget = (element: Element): BrowserFrameElementTarget => { @@ -161,15 +157,14 @@ export const BrowserFrame: Component = (props) => { cleanupFrameListeners = null setHighlight(null) - if (!props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return - const doc = iframeRef.contentDocument + const doc = getAccessibleFrameDocument() + if (!props.commentMode || !doc || !iframeRef?.contentWindow || !frameWrapRef) return const frameWindow = iframeRef.contentWindow const handleMove = (event: MouseEvent) => { const target = event.target if (!target || !(target instanceof (frameWindow as any).Element)) return - const element = target as Element - const rect = element.getBoundingClientRect() + const rect = (target as Element).getBoundingClientRect() const frameRect = iframeRef?.getBoundingClientRect() const wrapRect = frameWrapRef?.getBoundingClientRect() if (!frameRect || !wrapRect) return @@ -208,11 +203,14 @@ export const BrowserFrame: Component = (props) => { } catch { setPathInput(getEditablePathFromUrl(frameSrc())) } + const available = Boolean(getAccessibleFrameDocument()) + setCommentAvailable(available) + if (!available && props.commentMode) props.onToggleCommentMode?.() attachCommentListeners() } createEffect(() => { - setFrameSrc(props.initialUrl) + setFrameSrc(resolvePreviewUrl(props.initialUrl, window.location.href)) setPathInput(getEditablePathFromUrl(props.initialUrl)) }) @@ -241,7 +239,7 @@ export const BrowserFrame: Component = (props) => { // Fall back to resetting the iframe source if the frame cannot be reloaded directly. } setFrameSrc("about:blank") - requestAnimationFrame(() => setFrameSrc(props.initialUrl)) + requestAnimationFrame(() => setFrameSrc(resolvePreviewUrl(props.initialUrl, window.location.href))) } const handleGo = (event?: Event) => { @@ -363,7 +361,7 @@ export const BrowserFrame: Component = (props) => { {(rect) => (
= (props) => { // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) + const [sessionSidebarQuery, setSessionSidebarQuery] = createSignal("") + const [sessionSidebarSortMode, setSessionSidebarSortMode] = createSignal("activity") + const [sessionSidebarWorktreeFilter, setSessionSidebarWorktreeFilter] = createSignal(null) const { allInstanceSessions, @@ -656,6 +660,12 @@ const InstanceShell2: Component = (props) => { draftModel={draftModel} showSearch={showSessionSearch} onToggleSearch={() => setShowSessionSearch((current) => !current)} + filterQuery={sessionSidebarQuery} + setFilterQuery={setSessionSidebarQuery} + sortMode={sessionSidebarSortMode} + setSortMode={setSessionSidebarSortMode} + selectedWorktree={sessionSidebarWorktreeFilter} + setSelectedWorktree={setSessionSidebarWorktreeFilter} keyboardShortcuts={keyboardShortcuts} isPhoneLayout={isPhoneLayout} drawerState={leftDrawerState} @@ -732,6 +742,12 @@ const InstanceShell2: Component = (props) => { draftModel={draftModel} showSearch={showSessionSearch} onToggleSearch={() => setShowSessionSearch((current) => !current)} + filterQuery={sessionSidebarQuery} + setFilterQuery={setSessionSidebarQuery} + sortMode={sessionSidebarSortMode} + setSortMode={setSessionSidebarSortMode} + selectedWorktree={sessionSidebarWorktreeFilter} + setSelectedWorktree={setSessionSidebarWorktreeFilter} keyboardShortcuts={keyboardShortcuts} isPhoneLayout={isPhoneLayout} drawerState={leftDrawerState} diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index fa80c1db7..7830bafab 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -1,10 +1,11 @@ -import { Show, type Accessor, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onCleanup, type Accessor, type Component, type Setter } from "solid-js" +import { DropdownMenu } from "@kobalte/core/dropdown-menu" import type { SessionThread } from "../../../stores/session-state" import type { Session } from "../../../types/session" import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry" import type { DrawerViewState } from "./types" -import { PlusSquare, Search } from "lucide-solid" +import { Check, ListFilter, PlusSquare, Search } from "lucide-solid" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -19,6 +20,18 @@ import ModelSelector from "../../model-selector" import ThinkingSelector from "../../thinking-selector" import { getLogger } from "../../../lib/logger" import { shouldMountSessionList } from "../../session-list-visibility" +import { getWorktreeSlugForParentSession, getWorktrees } from "../../../stores/worktrees" +import type { SessionThreadSortMode } from "../../../stores/session-tree" +import { + getSessionSearchQuery, + getSessionSearchThreads, + isSessionExpanded, + isSessionSearchLoading, + clearSessionSearch, + searchSessions, + sessions as sessionStateSessions, +} from "../../../stores/sessions" +import { buildSessionListProjection, getSessionListQueryThreads, registerSessionListProjection } from "../../session-list-projection" const log = getLogger("session") @@ -33,6 +46,12 @@ interface SessionSidebarProps { showSearch: Accessor onToggleSearch: () => void + filterQuery: Accessor + setFilterQuery: Setter + sortMode: Accessor + setSortMode: Setter + selectedWorktree: Accessor + setSelectedWorktree: Setter keyboardShortcuts: Accessor isPhoneLayout: Accessor @@ -52,7 +71,64 @@ interface SessionSidebarProps { setContentEl: (el: HTMLElement | null) => void } -const SessionSidebar: Component = (props) => ( +const SessionSidebar: Component = (props) => { + const worktrees = createMemo(() => getWorktrees(props.instanceId)) + const worktreeFilter = createMemo(() => { + const selected = props.selectedWorktree() + return selected && worktrees().some((worktree) => worktree.slug === selected) ? selected : null + }) + const optionsActive = createMemo(() => props.sortMode() !== "activity" || worktreeFilter() !== null) + const sortOptions: Array<{ value: SessionThreadSortMode; labelKey: string }> = [ + { value: "activity", labelKey: "sessionList.options.sort.activity" }, + { value: "name", labelKey: "sessionList.options.sort.name" }, + { value: "worktree", labelKey: "sessionList.options.sort.worktree" }, + ] + + const normalizeSessionLabel = (sessionId: string) => { + const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) + return session?.title?.trim() || props.t("sessionList.session.untitled") + } + const normalizedQuery = createMemo(() => props.showSearch() ? props.filterQuery().trim().toLowerCase() : "") + let searchDebounceTimer: ReturnType | null = null + createEffect(() => { + const query = normalizedQuery() + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) + if (!query) { + clearSessionSearch(props.instanceId) + return + } + searchDebounceTimer = setTimeout(() => { + void searchSessions(props.instanceId, query).catch((error) => { + log.error("Failed to search sessions:", error) + }) + }, 150) + onCleanup(() => { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) + }) + }) + const projectedThreads = createMemo(() => getSessionListQueryThreads({ + threads: props.threads(), + query: normalizedQuery(), + serverQuery: getSessionSearchQuery(props.instanceId), + serverLoading: isSessionSearchLoading(props.instanceId), + serverThreads: getSessionSearchThreads(props.instanceId), + getLabel: (thread) => normalizeSessionLabel(thread.session.id), + })) + const persistentProjection = createMemo(() => buildSessionListProjection({ + threads: projectedThreads(), + sortMode: props.sortMode(), + worktreeFilter: worktreeFilter(), + getLabel: (thread) => normalizeSessionLabel(thread.session.id), + getWorktree: (thread) => getWorktreeSlugForParentSession(props.instanceId, thread.session.id), + isExpanded: (sessionId) => Boolean(normalizedQuery()) || isSessionExpanded(props.instanceId, sessionId), + })) + + createEffect(() => { + const unregister = registerSessionListProjection(props.instanceId, () => persistentProjection().ids) + onCleanup(unregister) + }) + + return (
@@ -91,6 +167,76 @@ const SessionSidebar: Component = (props) => ( > + + + + + + +
+ {props.t("sessionList.options.sort.label")} +
+ + {(option) => ( + props.setSortMode(option.value)} + > + + {props.t(option.labelKey)} + + )} + +
) +} export default SessionSidebar diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index 462b20a1e..0bf08e4e7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -4,7 +4,7 @@ import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import type { DiffWordWrapMode, RightPanelTab } from "../types" import { getRootClient } from "../../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces" +import { requireWorktreeWorkspacePayload } from "../../../../../stores/session-worktree-binding" import { requestData } from "../../../../../lib/opencode-api" import { serverApi } from "../../../../../lib/api-client" import { showConfirmDialog } from "../../../../../stores/alerts" @@ -62,8 +62,7 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS ) const fileWorkspacePayload = async () => { - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug()) - return workspace ? { workspace } : {} + return requireWorktreeWorkspacePayload(options.instanceId, options.worktreeSlug()) } createEffect(() => { diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index de1899893..d2c174fd2 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -4,7 +4,7 @@ import type { PromptInputApi } from "../../../prompt-input/types" import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types" import { getRootClient } from "../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" +import { requireWorktreeWorkspacePayload } from "../../../../stores/session-worktree-binding" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { serverEvents } from "../../../../lib/server-events" @@ -168,12 +168,12 @@ export function useGitChanges(options: UseGitChangesOptions) { if (!force && gitStatusEntries() !== null) return const slug = options.worktreeSlug() const client = getRootClient(options.instanceId) - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, slug) + const workspace = await requireWorktreeWorkspacePayload(options.instanceId, slug) const requestVersion = ++gitStatusRequestVersion setGitStatusLoading(true) setGitStatusError(null) try { - const sdkStatusPromise = requestData(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status") + const sdkStatusPromise = requestData(client.file.status(workspace), "file.status") const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug) if (requestVersion !== gitStatusRequestVersion) return if (slug !== options.worktreeSlug()) return diff --git a/packages/ui/src/components/preview-isolation.test.ts b/packages/ui/src/components/preview-isolation.test.ts new file mode 100644 index 000000000..40dcc5fd1 --- /dev/null +++ b/packages/ui/src/components/preview-isolation.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { buildPreviewNavigationUrl, resolvePreviewUrl } from "./preview-isolation" + +test("preview URLs retain HTTP networking in local and remote windows", () => { + assert.equal(resolvePreviewUrl("/previews/token/page?q=1", "http://127.0.0.1:43123/app"), "http://127.0.0.1:43123/previews/token/page?q=1") + assert.equal(resolvePreviewUrl("/previews/token", "https://remote.example/app"), "https://remote.example/previews/token") +}) + +test("preview address navigation preserves local and remote proxy bases", () => { + assert.equal( + buildPreviewNavigationUrl("nested/../next?q=1", "/previews/token", "/previews/token", "http://127.0.0.1:43123/app"), + "http://127.0.0.1:43123/previews/token/next?q=1", + ) + assert.equal( + buildPreviewNavigationUrl("/docs", "/sidecars/dev", "/sidecars/dev/", "https://remote.example/app"), + "https://remote.example/sidecars/dev/docs", + ) +}) diff --git a/packages/ui/src/components/preview-isolation.ts b/packages/ui/src/components/preview-isolation.ts new file mode 100644 index 000000000..2af70357b --- /dev/null +++ b/packages/ui/src/components/preview-isolation.ts @@ -0,0 +1,16 @@ +export function resolvePreviewUrl(url: string, baseUrl: string): string { + return new URL(url, baseUrl).href +} + +export function buildPreviewNavigationUrl(rawInput: string, proxyBasePath: string, initialUrl: string, baseUrl: string): string { + const trimmed = rawInput.trim() + const frameUrl = new URL(initialUrl, baseUrl) + const parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, frameUrl) + const safeSegments: string[] = [] + for (const segment of parsed.pathname.split("/")) { + if (!segment || segment === ".") continue + if (segment === "..") safeSegments.pop() + else safeSegments.push(segment) + } + return new URL(`${proxyBasePath}/${safeSegments.join("/")}${parsed.search}${parsed.hash}`, frameUrl).href +} diff --git a/packages/ui/src/components/session-list-projection.test.ts b/packages/ui/src/components/session-list-projection.test.ts new file mode 100644 index 000000000..bbc79123c --- /dev/null +++ b/packages/ui/src/components/session-list-projection.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { describe, it } from "node:test" + +import { + buildSessionListProjection, + getAdjacentSessionId, + getSessionDeletionFallback, + getSessionListProjectionIds, + getSessionListQueryThreads, + registerSessionListProjection, +} from "./session-list-projection" +import type { SessionThread, SessionThreadSortMode } from "../stores/session-tree" + +function thread(id: string, title: string, updated: number): SessionThread { + return { + session: { id, title, parentId: null, time: { created: updated, updated } } as SessionThread["session"], + children: [], + depth: 0, + hasChildren: false, + latestUpdated: updated, + } +} + +describe("session list projection registry", () => { + it("owns sidebar query, sort, and worktree filters above both drawer branches", () => { + const shell = readFileSync(new URL("./instance/instance-shell2.tsx", import.meta.url), "utf8") + const sidebar = readFileSync(new URL("./instance/shell/SessionSidebar.tsx", import.meta.url), "utf8") + const list = readFileSync(new URL("./session-list.tsx", import.meta.url), "utf8") + + assert.equal(shell.match(/sortMode=\{sessionSidebarSortMode\}/g)?.length, 2) + assert.equal(shell.match(/filterQuery=\{sessionSidebarQuery\}/g)?.length, 2) + assert.equal(shell.match(/selectedWorktree=\{sessionSidebarWorktreeFilter\}/g)?.length, 2) + assert.doesNotMatch(sidebar, /createSignal|createSignal/) + assert.match(list, /filterQuery: string/) + assert.match(sidebar, /searchSessions\(props\.instanceId, query\)/) + assert.doesNotMatch(list, /searchSessions\(props\.instanceId/) + }) + + it("reads live projection updates and falls back after cleanup", () => { + let ids = ["first", "second"] + const unregister = registerSessionListProjection("live", () => ids) + + assert.deepEqual(getSessionListProjectionIds("live", () => ["raw"]), ["first", "second"]) + ids = ["second"] + assert.deepEqual(getSessionListProjectionIds("live", () => ["raw"]), ["second"]) + + unregister() + assert.deepEqual(getSessionListProjectionIds("live", () => ["raw"]), ["raw"]) + }) + + it("does not let stale cleanup remove a replacement", () => { + const unregisterFirst = registerSessionListProjection("replacement", () => ["first"]) + const unregisterSecond = registerSessionListProjection("replacement", () => ["second"]) + + unregisterFirst() + assert.deepEqual(getSessionListProjectionIds("replacement", () => ["raw"]), ["second"]) + + unregisterSecond() + assert.deepEqual(getSessionListProjectionIds("replacement", () => ["raw"]), ["raw"]) + }) + + it("restores retained sort and worktree filtering after the mounted list closes", () => { + const threads = [ + thread("workspace", "Middle", 300), + thread("feature-z", "Zebra", 200), + thread("feature-a", "Alpha", 100), + ] + const worktrees: Record = { + workspace: "root", + "feature-z": "feature", + "feature-a": "feature", + } + let sortMode: SessionThreadSortMode = "name" + let worktreeFilter: string | null = "feature" + const build = () => buildSessionListProjection({ + threads, + sortMode, + worktreeFilter, + getLabel: (item) => item.session.title ?? item.session.id, + getWorktree: (item) => worktrees[item.session.id] ?? "root", + isExpanded: () => false, + }).ids + + const unregisterPersistent = registerSessionListProjection("closed", build) + const unregisterMounted = registerSessionListProjection("closed", build) + assert.deepEqual(getSessionListProjectionIds("closed", () => ["workspace"]), ["feature-a", "feature-z"]) + + sortMode = "activity" + assert.deepEqual(getSessionListProjectionIds("closed", () => ["workspace"]), ["feature-z", "feature-a"]) + sortMode = "name" + worktreeFilter = "feature" + unregisterMounted() + assert.deepEqual(getSessionListProjectionIds("closed", () => ["workspace"]), ["feature-a", "feature-z"]) + + unregisterPersistent() + }) + + it("retains server search results and ancestors after mounted-list cleanup", () => { + const hidden = thread("hidden", "Hidden", 300) + const parent = thread("parent", "Parent", 200) + const child = thread("server-result", "Needle", 100) + parent.children = [child] + parent.hasChildren = true + const query = "needle" + const build = () => buildSessionListProjection({ + threads: getSessionListQueryThreads({ + threads: [hidden], + query, + serverQuery: query, + serverLoading: false, + serverThreads: [parent], + getLabel: (item) => item.session.title ?? item.session.id, + }), + sortMode: "activity", + worktreeFilter: null, + getLabel: (item) => item.session.title ?? item.session.id, + getWorktree: () => "root", + isExpanded: () => true, + }).ids + + const unregisterPersistent = registerSessionListProjection("closed-search", build) + const unregisterMounted = registerSessionListProjection("closed-search", build) + assert.deepEqual(getSessionListProjectionIds("closed-search", () => ["hidden"]), ["parent", "server-result"]) + + unregisterMounted() + assert.deepEqual(getSessionListProjectionIds("closed-search", () => ["hidden"]), ["parent", "server-result"]) + + unregisterPersistent() + }) +}) + +describe("session list navigation", () => { + it("never selects IDs omitted from the rendered projection", () => { + const rendered = ["visible-a", "visible-b"] + + assert.equal(getAdjacentSessionId(rendered, "filtered-out", "next"), "visible-a") + assert.equal(getAdjacentSessionId(rendered, "filtered-out", "previous"), "visible-b") + assert.equal(getSessionDeletionFallback(rendered, "filtered-out", new Set(["filtered-out"])), undefined) + }) + + it("prefers forward deletion fallback, then walks backward", () => { + const rendered = ["before", "active", "after", "last"] + + assert.equal(getSessionDeletionFallback(rendered, "active", new Set(["active"])), "after") + assert.equal(getSessionDeletionFallback(rendered, "active", new Set(["active", "after", "last"])), "before") + assert.equal(getSessionDeletionFallback(rendered, "active", new Set(rendered)), undefined) + }) +}) diff --git a/packages/ui/src/components/session-list-projection.ts b/packages/ui/src/components/session-list-projection.ts new file mode 100644 index 000000000..7737803eb --- /dev/null +++ b/packages/ui/src/components/session-list-projection.ts @@ -0,0 +1,110 @@ +import type { SessionThread } from "../stores/session-state" +import { + flattenVisibleSessionThreads, + projectSessionThreads, + type SessionThreadSortMode, +} from "../stores/session-tree" + +type ProjectionRegistration = { + getIds: () => readonly string[] +} + +const projections = new Map() + +export function getSessionListQueryThreads(options: { + threads: readonly SessionThread[] + query: string + serverQuery: string + serverLoading: boolean + serverThreads: readonly SessionThread[] + getLabel: (thread: SessionThread) => string +}): readonly SessionThread[] { + if (!options.query) return options.threads + if (options.serverQuery === options.query && !options.serverLoading) return options.serverThreads + + const filter = (thread: SessionThread): SessionThread | null => { + const children = thread.children.map(filter).filter((child): child is SessionThread => child !== null) + const labelMatches = options.getLabel(thread).toLowerCase().includes(options.query) + if (!labelMatches && !thread.session.id.toLowerCase().includes(options.query) && children.length === 0) return null + return { ...thread, children } + } + return options.threads.map(filter).filter((thread): thread is SessionThread => thread !== null) +} + +export function buildSessionListProjection(options: { + threads: readonly SessionThread[] + sortMode: SessionThreadSortMode + worktreeFilter: string | null + getLabel: (thread: SessionThread) => string + getWorktree: (thread: SessionThread) => string + isExpanded: (sessionId: string) => boolean +}) { + const threads = projectSessionThreads(options.threads, { + sort: options.sortMode, + worktree: options.worktreeFilter, + getLabel: options.getLabel, + getWorktree: options.getWorktree, + }) + const rows = flattenVisibleSessionThreads(threads, options.isExpanded) + const ids: string[] = [] + const rowsById = new Map() + const indexById = new Map() + rows.forEach((row, index) => { + ids.push(row.sessionId) + rowsById.set(row.sessionId, row) + indexById.set(row.sessionId, index) + }) + return { threads, ids, rowsById, indexById } +} + +export function registerSessionListProjection(instanceId: string, getIds: () => readonly string[]): () => void { + const registration = { getIds } + const registrations = projections.get(instanceId) ?? [] + registrations.push(registration) + projections.set(instanceId, registrations) + + return () => { + const current = projections.get(instanceId) + if (!current) return + const index = current.indexOf(registration) + if (index === -1) return + current.splice(index, 1) + if (current.length === 0) projections.delete(instanceId) + } +} + +export function getSessionListProjectionIds(instanceId: string, fallback: () => readonly string[]): readonly string[] { + const registrations = projections.get(instanceId) + return registrations?.[registrations.length - 1]?.getIds() ?? fallback() +} + +export function getAdjacentSessionId( + ids: readonly string[], + currentId: string | null, + direction: "next" | "previous", +): string | undefined { + if (ids.length === 0) return undefined + const currentIndex = currentId ? ids.indexOf(currentId) : -1 + if (currentIndex === -1) return direction === "next" ? ids[0] : ids[ids.length - 1] + const offset = direction === "next" ? 1 : -1 + return ids[(currentIndex + offset + ids.length) % ids.length] +} + +export function getSessionDeletionFallback( + ids: readonly string[], + activeId: string, + deletedIds: ReadonlySet, +): string | undefined { + const activeIndex = ids.indexOf(activeId) + if (activeIndex === -1) return undefined + + for (let index = activeIndex + 1; index < ids.length; index++) { + const candidate = ids[index] + if (candidate && !deletedIds.has(candidate)) return candidate + } + for (let index = activeIndex - 1; index >= 0; index--) { + const candidate = ids[index] + if (candidate && !deletedIds.has(candidate)) return candidate + } + return undefined +} diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index db1549432..f42a19820 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -14,7 +14,6 @@ import { showConfirmDialog } from "../stores/alerts" import { deleteSession, ensureSessionAncestorsExpanded, - getVisibleSessionIds, isSessionExpanded, loadMessages, loading, @@ -23,21 +22,30 @@ import { setActiveSessionFromList, toggleSessionExpanded, loadMoreSessions, - searchSessions, getSessionHasMore, getSessionListError, - clearSessionSearch, fetchSessions, getSessionSearchQuery, getSessionSearchThreads, isSessionSearchLoading, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { + collectSessionThreadIds, + findSessionThread, + sortSessionIdsDeepestFirst, + type SessionThreadSortMode, +} from "../stores/session-tree" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" import { isSessionListViewportAttached, shouldRenderSessionRows } from "./session-list-visibility" +import { + buildSessionListProjection, + getSessionListQueryThreads, + getSessionDeletionFallback, + registerSessionListProjection, +} from "./session-list-projection" const log = getLogger("session") @@ -53,6 +61,10 @@ interface SessionListProps { headerContent?: JSX.Element footerContent?: JSX.Element enableFilterBar?: boolean + filterQuery: string + onFilterQueryChange: (query: string) => void + sortMode: SessionThreadSortMode + worktreeFilter: string | null } function formatSessionStatus(status: SessionStatus): string { @@ -65,8 +77,7 @@ const SessionList: Component = (props) => { const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) - const [filterQuery, setFilterQuery] = createSignal("") - const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) + const normalizedQuery = createMemo(() => (props.enableFilterBar ? props.filterQuery.trim().toLowerCase() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) const [reloadingSessionIds, setReloadingSessionIds] = createSignal>(new Set()) @@ -139,97 +150,43 @@ const SessionList: Component = (props) => { onCleanup(() => observer.disconnect()) }) - let searchDebounceTimer: ReturnType | null = null - createEffect(() => { - const query = normalizedQuery() - if (!props.enableFilterBar) { - clearSessionSearch(props.instanceId) - return - } - - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } - - if (!query) { - clearSessionSearch(props.instanceId) - return - } - - // Always run server search in background for workspace-complete results. - // Client-side filtering (filteredThreads) shows instant results from loaded sessions. - const queryAtDispatch = query - searchDebounceTimer = setTimeout(() => { - void searchSessions(props.instanceId, queryAtDispatch) - .catch((error) => { - log.error("Failed to search sessions:", error) - }) - }, 150) - - onCleanup(() => { - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } - }) - }) - const normalizeSessionLabel = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const title = (session?.title ?? "").trim() return title || t("sessionList.session.untitled") } - const sessionMatchesQuery = (sessionId: string, query: string) => { - if (!query) return true - const label = normalizeSessionLabel(sessionId).toLowerCase() - if (label.includes(query)) return true - return sessionId.toLowerCase().includes(query) - } - - const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { - const matchingChildren: SessionThread[] = [] - for (const child of thread.children) { - const filteredChild = filterThreadTree(child, query) - if (filteredChild !== null) matchingChildren.push(filteredChild) - } - if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null - return { ...thread, children: matchingChildren } - } - - const filteredThreads = createMemo(() => { + const filteredThreads = createMemo(() => { const query = normalizedQuery() - if (!query) return props.threads - - const searchQuery = getSessionSearchQuery(props.instanceId) - const searchLoading = isSessionSearchLoading(props.instanceId) - if (searchQuery === query && !searchLoading) { - return getSessionSearchThreads(props.instanceId) - } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) - } - return result + return getSessionListQueryThreads({ + threads: props.threads, + query, + serverQuery: getSessionSearchQuery(props.instanceId), + serverLoading: isSessionSearchLoading(props.instanceId), + serverThreads: getSessionSearchThreads(props.instanceId), + getLabel: (thread) => normalizeSessionLabel(thread.session.id), + }) }) const visibleProjection = createMemo(() => { const expandAll = Boolean(normalizedQuery()) - const rows = flattenVisibleSessionThreads( - filteredThreads(), - (sessionId) => expandAll || isSessionExpanded(props.instanceId, sessionId), - ) - const ids: string[] = [] - const rowsById = new Map() - const indexById = new Map() - rows.forEach((row, index) => { - ids.push(row.sessionId) - rowsById.set(row.sessionId, row) - indexById.set(row.sessionId, index) + return buildSessionListProjection({ + threads: filteredThreads(), + sortMode: props.sortMode, + worktreeFilter: props.worktreeFilter, + getLabel: (thread) => normalizeSessionLabel(thread.session.id), + getWorktree: (thread) => getWorktreeSlugForParentSession(props.instanceId, thread.session.id), + isExpanded: (sessionId) => expandAll || isSessionExpanded(props.instanceId, sessionId), }) - return { ids, rowsById, indexById } }) + + const projectedThreads = createMemo(() => visibleProjection().threads) + + createEffect(() => { + const unregister = registerSessionListProjection(props.instanceId, () => visibleProjection().ids) + onCleanup(unregister) + }) + const keptMountedIndexes = createMemo(() => { const sessionId = focusedSessionId() if (!sessionId) return undefined @@ -245,10 +202,18 @@ const SessionList: Component = (props) => { collectIds(thread.children) } } - collectIds(filteredThreads()) + collectIds(projectedThreads()) return ids }) + createEffect(() => { + const matching = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + if (Array.from(selected).every((sessionId) => matching.has(sessionId))) return selected + return new Set(Array.from(selected).filter((sessionId) => matching.has(sessionId))) + }) + }) + const selectedCount = createMemo(() => selectedSessionIds().size) const isAllSelected = createMemo(() => { @@ -314,37 +279,9 @@ const SessionList: Component = (props) => { if (!confirmed) return const shouldSelectFallback = props.activeSessionId === sessionId - let fallbackSessionId: string | undefined - - if (shouldSelectFallback) { - const visible = getVisibleSessionIds(props.instanceId) - const currentIndex = visible.indexOf(sessionId) - const remaining = visible.filter((id) => id !== sessionId) - - if (remaining.length > 0) { - if (currentIndex !== -1) { - for (let i = currentIndex; i < visible.length; i++) { - const candidate = visible[i] - if (candidate && candidate !== sessionId) { - fallbackSessionId = candidate - break - } - } - - if (!fallbackSessionId) { - for (let i = currentIndex - 1; i >= 0; i--) { - const candidate = visible[i] - if (candidate && candidate !== sessionId) { - fallbackSessionId = candidate - break - } - } - } - } - - fallbackSessionId ??= remaining[0] - } - } + const fallbackSessionId = shouldSelectFallback + ? getSessionDeletionFallback(visibleProjection().ids, sessionId, new Set([sessionId])) + : undefined try { await deleteSession(props.instanceId, sessionId) @@ -423,15 +360,10 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(projectedThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } - const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => { - return collectSessionThreadIds(threads) - } - const handleToggleSelectAll = (checked: boolean) => { const ids = allMatchingSessionIds() setSelectedMany(ids, checked) @@ -467,25 +399,7 @@ const SessionList: Component = (props) => { let fallbackSessionId: string | undefined if (currentActiveId && deletedSet.has(currentActiveId)) { - const ordered = getAllSessionIdsInOrder(props.threads) - const currentIndex = ordered.indexOf(currentActiveId) - - for (let i = Math.max(0, currentIndex); i < ordered.length; i++) { - const candidate = ordered[i] - if (candidate && !deletedSet.has(candidate)) { - fallbackSessionId = candidate - break - } - } - if (!fallbackSessionId) { - for (let i = currentIndex - 1; i >= 0; i--) { - const candidate = ordered[i] - if (candidate && !deletedSet.has(candidate)) { - fallbackSessionId = candidate - break - } - } - } + fallbackSessionId = getSessionDeletionFallback(visibleProjection().ids, currentActiveId, deletedSet) } const deletionOrder = sortSessionIdsDeepestFirst(sessionStateSessions().get(props.instanceId) ?? new Map(), selected) @@ -800,8 +714,8 @@ const SessionList: Component = (props) => { setFilterQuery(e.currentTarget.value)} + value={props.filterQuery} + onInput={(e) => props.onFilterQueryChange(e.currentTarget.value)} placeholder={t("sessionList.filter.placeholder")} aria-label={t("sessionList.filter.ariaLabel")} /> diff --git a/packages/ui/src/components/session-preview-comment.ts b/packages/ui/src/components/session-preview-comment.ts new file mode 100644 index 000000000..11846dca2 --- /dev/null +++ b/packages/ui/src/components/session-preview-comment.ts @@ -0,0 +1,23 @@ +import type { BrowserFrameElementTarget } from "./browser-frame" + +function normalizeMetadata(value: string | undefined, maxLength: number): string { + if (typeof value !== "string") return "" + return value.replace(/[\u0000-\u001f\u007f-\u009f`]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength) +} + +export function buildPreviewCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string { + const pagePath = normalizeMetadata(target.pagePath, 300) + const tagName = normalizeMetadata(target.tagName, 40) || "element" + const label = normalizeMetadata(target.ariaLabel, 160) || normalizeMetadata(target.text, 160) + const normalizedRole = normalizeMetadata(target.role, 80) + const selector = normalizeMetadata(target.selector, 300) + const role = normalizedRole ? ` role="${normalizedRole}"` : "" + const element = label ? `${tagName}${role} "${label}"` : `${tagName}${role}` + const lines = [ + "> Web preview comment", + `> Page: \`${pagePath}\``, + `> Element: \`${element}\``, + ] + if (selector) lines.push(`> Selector: \`${selector}\``) + return `${lines.join("\n")}\n\n${comment}\n\n` +} diff --git a/packages/ui/src/components/session-preview-view.test.ts b/packages/ui/src/components/session-preview-view.test.ts new file mode 100644 index 000000000..7335f343f --- /dev/null +++ b/packages/ui/src/components/session-preview-view.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { buildPreviewCommentMarkdown } from "./session-preview-comment" + +test("web preview comments retain the selected element context", () => { + assert.equal( + buildPreviewCommentMarkdown({ + pagePath: "/settings?tab=profile", + tagName: "button", + text: "Fallback text", + role: "switch", + ariaLabel: "Enable alerts", + selector: "main > button#alerts", + rect: { x: 10, y: 20, width: 30, height: 40 }, + }, "Use the compact style"), + [ + "> Web preview comment", + "> Page: `/settings?tab=profile`", + '> Element: `button role="switch" "Enable alerts"`', + "> Selector: `main > button#alerts`", + "", + "Use the compact style", + "", + "", + ].join("\n"), + ) +}) + +test("web preview comments contain hostile metadata inside bounded single-line code spans", () => { + const markdown = buildPreviewCommentMarkdown({ + pagePath: "/settings", + tagName: `${"t".repeat(50)}\`\n> injected tag`, + text: "fallback", + role: `${"r".repeat(90)}\`\r\n> injected role`, + ariaLabel: `${"l".repeat(170)}\`\n> injected label\u0000`, + selector: `${"s".repeat(310)}\`\n> injected selector`, + rect: { x: 0, y: 0, width: 0, height: 0 }, + }, "Keep this comment") + const lines = markdown.split("\n") + + assert.equal( + lines[2], + `> Element: \`${"t".repeat(40)} role="${"r".repeat(80)}" "${"l".repeat(160)}"\``, + ) + assert.equal(lines[3], `> Selector: \`${"s".repeat(300)}\``) + assert.equal(markdown.includes("injected"), false) + assert.equal(markdown.endsWith("\n\nKeep this comment\n\n"), true) +}) + +test("web preview comments normalize Markdown-breaking metadata", () => { + assert.equal( + buildPreviewCommentMarkdown({ + pagePath: "/settings`\n> injected page", + tagName: "but`ton\n> injected tag", + text: "fallback", + role: "sw`itch\r\n> injected role", + ariaLabel: "Save`\n> injected label\u0000", + selector: "main`\n> injected selector", + rect: { x: 0, y: 0, width: 0, height: 0 }, + }, "Keep this comment"), + [ + "> Web preview comment", + "> Page: `/settings > injected page`", + '> Element: `but ton > injected tag role="sw itch > injected role" "Save > injected label"`', + "> Selector: `main > injected selector`", + "", + "Keep this comment", + "", + "", + ].join("\n"), + ) +}) diff --git a/packages/ui/src/components/session-preview-view.tsx b/packages/ui/src/components/session-preview-view.tsx index c81b2a299..2a261cac3 100644 --- a/packages/ui/src/components/session-preview-view.tsx +++ b/packages/ui/src/components/session-preview-view.tsx @@ -4,6 +4,8 @@ import { useI18n } from "../lib/i18n" import { showPromptDialog } from "../stores/alerts" import type { SessionPreviewRecord } from "../stores/session-previews" import { BrowserFrame, type BrowserFrameElementTarget } from "./browser-frame" +import { buildPreviewCommentMarkdown } from "./session-preview-comment" +import { resolvePreviewUrl } from "./preview-isolation" interface SessionPreviewViewProps { preview: SessionPreviewRecord @@ -12,24 +14,6 @@ interface SessionPreviewViewProps { onInsertComment: (markdown: string) => void } -function describeElement(target: BrowserFrameElementTarget): string { - const label = target.ariaLabel || target.text - const role = target.role ? ` role="${target.role}"` : "" - return label ? `${target.tagName}${role} "${label}"` : `${target.tagName}${role}` -} - -function buildCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string { - const lines = [ - "> Web preview comment", - `> Page: \`${target.pagePath}\``, - `> Element: \`${describeElement(target)}\``, - ] - if (target.selector) { - lines.push(`> Selector: \`${target.selector}\``) - } - return `${lines.join("\n")}\n\n${comment}\n\n` -} - export const SessionPreviewView: Component = (props) => { const { t } = useI18n() const [commentMode, setCommentMode] = createSignal(false) @@ -44,7 +28,7 @@ export const SessionPreviewView: Component = (props) => }) const normalized = comment?.trim() if (!normalized) return - props.onInsertComment(buildCommentMarkdown(elementTarget, normalized)) + props.onInsertComment(buildPreviewCommentMarkdown(elementTarget, normalized)) } return ( @@ -65,7 +49,7 @@ export const SessionPreviewView: Component = (props) =>
= (props) => { async function handleRevert(messageId: string) { const instance = instances().get(props.instanceId) if (!instance || !instance.client) return + const client = instance.client try { - await requestData( - instance.client.session.revert({ + await withSessionWorkspace(props.instanceId, props.sessionId, (workspace) => requestData( + client.session.revert({ sessionID: props.sessionId, + ...workspace, messageID: messageId, }), "session.revert", - ) + )) const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index a3d3f209f..f2085b8ca 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -1,11 +1,11 @@ import { Select } from "@kobalte/core/select" import { Dialog } from "@kobalte/core/dialog" import { For, Show, createMemo, createSignal } from "solid-js" -import { ChevronDown, Copy, Trash2 } from "lucide-solid" +import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid" import type { WorktreeDescriptor } from "../../../server/src/api-types" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" -import { showToastNotification } from "../lib/notifications" +import { getToastHistory, showToastNotification } from "../lib/notifications" import { createWorktree, deleteWorktree, @@ -15,10 +15,12 @@ import { getWorktrees, reloadWorktreeMap, reloadWorktrees, - setWorktreeSlugForParentSession, } from "../stores/worktrees" +import { moveSessionToWorktree } from "../stores/session-worktree-binding" import { sessions } from "../stores/sessions" import { useI18n } from "../lib/i18n" +import { openLocalDirectory, supportsLocalDirectoryOpen } from "../lib/native/native-functions" +import { isUncPath } from "../lib/native/directory-path" const log = getLogger("session") @@ -205,6 +207,11 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { } } + const handleOpenDirectory = async (event: Pick, worktreeSlug: string) => { + if (await openLocalDirectory(event, props.instanceId, worktreeSlug)) return + showToastNotification({ message: t("instanceShell.worktree.openDirectory.error"), variant: "error" }) + } + const sanitizeDeleteError = (input: string) => { let sanitized = (input ?? "").trim() if (!sanitized) { @@ -297,7 +304,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { setCreateOpen(true) return } - await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug) + await moveSessionToWorktree(props.instanceId, parentId(), value.slug) } return ( @@ -307,7 +314,15 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onOpenChange={setIsOpen} value={selectedOption() ?? null} onChange={(value) => { - void handleChange(value).catch((error) => log.warn("Failed to change worktree", error)) + const existingErrorToasts = new Set(getToastHistory({ variant: "error" }).map((item) => item.id)) + void handleChange(value).catch((error) => { + log.warn("Failed to change worktree", error) + const message = t("instanceShell.worktree.moveFailed") + const alreadyShown = getToastHistory({ variant: "error" }).some((item) => ( + !existingErrorToasts.has(item.id) && item.message === message + )) + if (!alreadyShown) showToastNotification({ message, variant: "error" }) + }) }} options={worktreeOptions()} optionValue="key" @@ -361,6 +376,25 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { > {displayPathFor(opt.directory)} + + +