From 359d5443a957792cf8102ccaeed9dac305c535a1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 12 Aug 2026 13:02:21 +0200 Subject: [PATCH] feat(workspaces): probe providers in selected runtimes Pre-creation provider truth now comes from an invisible real runtime workspace and reuses the normal provider snapshot pipeline. Probe records are owned separately, keeping them structurally absent from ordinary workspace projections. Setup eligibility follows fresh content materialization rather than lifecycle resource ownership. Adopted Local directories never execute repository setup, while fresh Worktree, Docker, and external content may; provider probes never execute repository setup. --- .../app/e2e/support/helpers/e2e-worker.ts | 49 ++++- .../support/helpers/new-workspace-runtime.ts | 183 ++++++++++++----- .../app/src/composer/agent-controls/index.tsx | 2 +- .../app/src/composer/draft/input-draft.ts | 19 +- .../app/src/hooks/use-agent-form-state.ts | 9 +- .../app/src/hooks/use-providers-snapshot.ts | 14 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../src/new-workspace-runtime/model.test.ts | 61 +++++- .../app/src/new-workspace-runtime/model.ts | 36 ++++ .../use-provider-probe.ts | 46 +++++ .../provider-selection.test.ts | 2 +- .../provider-selection/provider-selection.ts | 3 - .../app/src/screens/new-workspace-screen.tsx | 130 ++++++++++-- packages/client/src/daemon-client.test.ts | 26 +++ packages/client/src/daemon-client.ts | 12 ++ .../desktop/e2e/new-workspace-runtime.spec.ts | 45 ++++- .../docker-workspace-runtime/src/index.ts | 18 +- .../fixture-workspace-runtime/src/index.mjs | 46 ++++- packages/protocol/src/messages.ts | 22 ++ .../protocol/src/messages.workspaces.test.ts | 45 +++++ packages/server/src/server/bootstrap.ts | 191 +++++++++++++----- ...space-runtime-characterization.e2e.test.ts | 97 +++++++++ .../server/src/server/provider-probe/index.ts | 47 +++++ .../provider-probe/internal/probe-store.ts | 132 ++++++++++++ .../server/provider-probe/internal/service.ts | 117 +++++++++++ .../provider-probe.posix.test.ts | 191 ++++++++++++++++++ packages/server/src/server/session.ts | 44 +++- .../server/src/server/websocket-server.ts | 5 + .../command/internal/command-runtime.ts | 10 +- .../server/workspace-runtime/drivers/index.ts | 8 +- .../src/server/workspace-runtime/index.ts | 1 + .../internal/local-runtime.ts | 10 +- .../workspace-runtime/internal/service.ts | 3 +- .../internal/worktree-runtime.ts | 54 ++++- .../workspace-runtime.command.test.ts | 23 +++ .../workspace-runtime.posix.test.ts | 54 ++++- packages/workspace-runtime-contract/README.md | 2 +- .../examples/v1.json | 10 +- .../workspace-runtime-contract/src/index.ts | 2 + .../test/contract.test.mjs | 2 + 48 files changed, 1592 insertions(+), 188 deletions(-) create mode 100644 packages/app/src/new-workspace-runtime/use-provider-probe.ts create mode 100644 packages/server/src/server/provider-probe/index.ts create mode 100644 packages/server/src/server/provider-probe/internal/probe-store.ts create mode 100644 packages/server/src/server/provider-probe/internal/service.ts create mode 100644 packages/server/src/server/provider-probe/provider-probe.posix.test.ts diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index b5131eba75..f3caba2d12 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -145,12 +145,34 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise const configPath = path.join(paseoHome, "config.json"); const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {}; const stateDirectory = path.join(paseoHome, "fixture-runtime"); + const materializeRoot = path.join(paseoHome, "fixture-workspaces"); + const fixtureProviderSource = path.resolve( + __dirname, + "../../../../server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs", + ); await mkdir(stateDirectory, { recursive: true }); await writeFile( configPath, `${JSON.stringify({ ...existing, version: 1, + agents: { + ...existing.agents, + providers: { + ...existing.agents?.providers, + claude: { ...existing.agents?.providers?.claude, enabled: false }, + codex: { ...existing.agents?.providers?.codex, enabled: false }, + copilot: { ...existing.agents?.providers?.copilot, enabled: false }, + opencode: { ...existing.agents?.providers?.opencode, enabled: false }, + pi: { ...existing.agents?.providers?.pi, enabled: false }, + "fixture-agent": { + extends: "acp", + label: "Fixture Agent", + command: [process.execPath, "./.paseo-fixture-agent.mjs"], + models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }], + }, + }, + }, workspaceRuntimes: { ...existing.workspaceRuntimes, fixture: { @@ -167,7 +189,32 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise "../../../../server/src/server/workspace-helper/executable.mjs", ), ], - options: { stateDirectory }, + options: { + stateDirectory, + materializeRoot, + fixtureProviderSource, + preserveSourceDisplayCwd: true, + }, + }, + "fixture-failure": { + type: "command", + label: "Fixture Failure", + command: [ + process.execPath, + path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"), + ], + helperCommand: [ + process.execPath, + path.resolve( + __dirname, + "../../../../server/src/server/workspace-helper/executable.mjs", + ), + ], + options: { + stateDirectory: path.join(paseoHome, "fixture-failure-runtime"), + failCreate: true, + createError: "Fixture probe creation failed", + }, }, }, })}\n`, diff --git a/packages/app/e2e/support/helpers/new-workspace-runtime.ts b/packages/app/e2e/support/helpers/new-workspace-runtime.ts index 532b69391a..f54a1c812c 100644 --- a/packages/app/e2e/support/helpers/new-workspace-runtime.ts +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -1,6 +1,6 @@ -import { readFile } from "node:fs/promises"; +import { access, readFile, readdir } from "node:fs/promises"; import path from "node:path"; -import { expect, type Page, type WebSocket } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; import { waitForConnectedHost } from "./hosts"; @@ -23,13 +23,141 @@ export interface SeededRuntimeProject { cleanup(): Promise; } +const SETUP_MARKER = "runtime-user-setup-ran.txt"; + export async function seedGitProjectForRuntime( options?: Parameters[1], ): Promise { - const repo = await createTempGitRepo("runtime-selector-", options); + const repo = await createTempGitRepo("runtime-selector-", { + ...options, + paseoConfig: options?.paseoConfig ?? { + worktree: { setup: [`printf setup > ${SETUP_MARKER}`] }, + }, + }); return seedRuntimeProject(repo); } +async function readProbeRecords(projectId: string): Promise> { + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const records = JSON.parse( + await readFile(path.join(paseoHome, "projects", "provider-probes.json"), "utf8"), + ) as Array<{ workspaceId: string; projectId: string }>; + return records.filter((record) => record.projectId === projectId); +} + +async function markerExists(root: string): Promise { + return access(path.join(root, SETUP_MARKER)).then( + () => true, + () => false, + ); +} + +export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise { + expect(await markerExists(project.sourceDirectory)).toBe(false); + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const probeIds = new Set( + (await readProbeRecords(project.projectId)).map((record) => record.workspaceId), + ); + const stateDirectory = path.join(paseoHome, "fixture-runtime"); + const stateFiles = await readdir(stateDirectory); + const states = await Promise.all( + stateFiles.map( + async (file) => + JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as { + workspaceId: string; + root: string; + }, + ), + ); + const probe = states.find((state) => probeIds.has(state.workspaceId)); + expect(probe, "fixture probe runtime state").toBeDefined(); + expect(await markerExists(probe!.root)).toBe(false); +} + +export async function expectUserWorkspaceRanProjectSetup( + project: SeededRuntimeProject, +): Promise { + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const records = JSON.parse( + await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>; + const workspace = records.find( + (record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture", + ); + expect(workspace, "fixture user workspace record").toBeDefined(); + const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime")); + const states = await Promise.all( + stateFiles.map( + async (file) => + JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as { + workspaceId: string; + root: string; + }, + ), + ); + const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId); + expect(state, "fixture user runtime state").toBeDefined(); + expect(await markerExists(state!.root)).toBe(true); +} + +export async function expectProviderAvailable(page: Page, providerLabel: string): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText(providerLabel, { exact: true }).click(); + await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +export async function expectFixtureProviderUnavailable(page: Page): Promise { + const providerLabel = "Fixture Agent"; + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText(providerLabel, { exact: true }).click(); + await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible(); + await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible(); + await page.keyboard.press("Escape"); +} + +export async function expectProbeFailureWithRetry(page: Page, message: string): Promise { + await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled(); + await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible(); +} + +export async function retryFailedProbe(page: Page): Promise { + await page.getByRole("button", { name: "Retry", exact: true }).click(); +} + +export async function expectNoProbeInWorkspaceProjection( + page: Page, + project: SeededRuntimeProject, +): Promise { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + try { + const probeIds = new Set( + (await readProbeRecords(project.projectId)).map((record) => record.workspaceId), + ); + const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); + expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false); + for (const probeId of probeIds) { + await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0); + } + } finally { + await client.close(); + } +} + export async function seedNonGitProjectForRuntime(): Promise { const directory = await createTempDirectory("runtime-selector-non-git-"); return seedRuntimeProject(directory); @@ -68,55 +196,6 @@ async function seedRuntimeProject(resource: { }; } -export function expectNewWorkspaceProviderSnapshotUsesProjectCwd( - page: Page, - sourceDirectory: string, -): Promise { - return new Promise((resolve, reject) => { - const frameListeners = new Map void>(); - const cleanup = () => { - clearTimeout(timeout); - page.off("websocket", handleWebSocket); - for (const [socket, listener] of frameListeners) socket.off("framesent", listener); - frameListeners.clear(); - }; - const finish = (error?: Error) => { - cleanup(); - if (error) reject(error); - else resolve(); - }; - const handleWebSocket = (socket: WebSocket) => { - const handleFrame = ({ payload }: { payload: string | Buffer }) => { - if (typeof payload !== "string") return; - let frame: unknown; - try { - frame = JSON.parse(payload); - } catch { - return; - } - if (!frame || typeof frame !== "object" || !("message" in frame)) return; - const message = frame.message; - if (!message || typeof message !== "object") return; - if ( - "type" in message && - message.type === "get_providers_snapshot_request" && - "cwd" in message && - message.cwd === sourceDirectory - ) { - finish(); - } - }; - frameListeners.set(socket, handleFrame); - socket.on("framesent", handleFrame); - }; - const timeout = setTimeout( - () => finish(new Error(`No provider snapshot request used cwd ${sourceDirectory}`)), - 30_000, - ); - page.on("websocket", handleWebSocket); - }); -} - export async function gotoNewWorkspaceForRuntime( page: Page, project: SeededRuntimeProject, diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 48fbcb8dfe..59fb4eca90 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -537,7 +537,7 @@ function ControlledAgentControls({ } }, [updateDensityForWidth]); - const modelDisabled = disabled; + const modelDisabled = disabled || isModelLoading; const comboboxProviderOptions = useMemo( () => toComboboxOptions(providerOptions), diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c8077e1fb6..b47d6ae060 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -5,6 +5,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { useAgentFormState, type CreateAgentInitialValues, + type UseAgentFormStateOptions, type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; @@ -29,6 +30,8 @@ type AttachmentUpdater = interface AgentInputDraftComposerOptions { workspaceId?: string | null; + providerSnapshotCwd?: string | null; + isTargetDaemonReady?: boolean; initialServerId: string | null; initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; @@ -62,16 +65,24 @@ export interface AgentInputDraft { composerState: DraftComposerState | null; } -export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { - const composerOptions = input.composer ?? null; - const formState = useAgentFormState({ +function buildAgentFormStateOptions( + composerOptions: AgentInputDraftComposerOptions | null, +): UseAgentFormStateOptions { + return { initialServerId: composerOptions?.initialServerId ?? null, initialValues: composerOptions?.initialValues, isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, onlineServerIds: composerOptions?.onlineServerIds ?? [], workspaceId: composerOptions?.workspaceId, - }); + providerSnapshotCwd: composerOptions?.providerSnapshotCwd, + isTargetDaemonReady: composerOptions?.isTargetDaemonReady, + }; +} + +export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { + const composerOptions = input.composer ?? null; + const formState = useAgentFormState(buildAgentFormStateOptions(composerOptions)); const draftKey = useMemo( () => resolveDraftKey({ diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 73c8edd0f0..037d68b2ef 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -42,6 +42,7 @@ export type { FormInitialValues } from "@/provider-selection/resolve-agent-form" export interface UseAgentFormStateOptions { workspaceId?: string | null; + providerSnapshotCwd?: string | null; initialServerId?: string | null; initialValues?: FormInitialValues; isVisible?: boolean; @@ -206,7 +207,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg initialValues, isVisible = true, isCreateFlow = true, - isTargetDaemonReady: _isTargetDaemonReady = true, + isTargetDaemonReady = true, onlineServerIds = [], } = options; @@ -274,7 +275,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, } = useProvidersSnapshot(formState.serverId, { - cwd: formState.workingDir, + enabled: isTargetDaemonReady, + cwd: + options.providerSnapshotCwd === undefined + ? formState.workingDir + : options.providerSnapshotCwd, workspaceId: options.workspaceId, }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 1fa647200e..81ff37eaa7 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -156,7 +156,8 @@ export function useProvidersSnapshot( const retainedPanelActive = useRetainedPanelActive(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); - const enabled = (options.enabled ?? true) && retainedPanelActive; + const targetEnabled = options.enabled ?? true; + const enabled = targetEnabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const cwd = normalizeProvidersSnapshotCwd(options.cwd); const supportsSnapshot = useSessionStore( @@ -225,11 +226,12 @@ export function useProvidersSnapshot( ); return { - entries: snapshotQuery.data?.entries ?? undefined, - isLoading: snapshotQuery.isLoading, - isFetching: snapshotQuery.isFetching, - isRefreshing, - error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, + entries: targetEnabled ? (snapshotQuery.data?.entries ?? undefined) : undefined, + isLoading: targetEnabled && snapshotQuery.isLoading, + isFetching: targetEnabled && snapshotQuery.isFetching, + isRefreshing: targetEnabled && isRefreshing, + error: + targetEnabled && snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, refetchIfStale, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a4eb16d285..8100b06af0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1043,6 +1043,7 @@ export const ar: TranslationResources = { title: "مساحة عمل جديدة", create: "يخلق", runtime: { local: "محلي", worktree: "شجرة العمل", docker: "Docker", label: "بيئة التشغيل" }, + runtimeProbe: { preparing: "جارٍ تجهيز البيئة" }, isolation: { local: "محلي", worktree: "شجرة عمل جديدة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b7c69b5dc8..5d91a0f264 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1053,6 +1053,7 @@ export const en = { title: "New workspace", create: "Create", runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Runtime" }, + runtimeProbe: { preparing: "Preparing environment" }, isolation: { local: "Local", worktree: "New worktree", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 96bd7d24ee..14f930f276 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1074,6 +1074,7 @@ export const es: TranslationResources = { title: "Nuevo espacio de trabajo", create: "Crear", runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Entorno" }, + runtimeProbe: { preparing: "Preparando el entorno" }, isolation: { local: "Local", worktree: "Nuevo worktree", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index f79f0a5747..946420024a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1073,6 +1073,7 @@ export const fr: TranslationResources = { title: "Nouvel espace de travail", create: "Créer", runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Environnement" }, + runtimeProbe: { preparing: "Préparation de l’environnement" }, isolation: { local: "Local", worktree: "Nouveau worktree", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 424286499b..007eac2c30 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1054,6 +1054,7 @@ export const ja: TranslationResources = { title: "新しいワークスペース", create: "作成", runtime: { local: "ローカル", worktree: "ワークツリー", docker: "Docker", label: "ランタイム" }, + runtimeProbe: { preparing: "環境を準備しています" }, isolation: { local: "ローカル", worktree: "新しいワークツリー", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0f19311867..e901a7ead5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1050,6 +1050,7 @@ export const ko: TranslationResources = { title: "새 워크스페이스", create: "생성", runtime: { local: "로컬", worktree: "워크트리", docker: "Docker", label: "런타임" }, + runtimeProbe: { preparing: "환경 준비 중" }, isolation: { local: "로컬", worktree: "새 워크트리", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 6ab175f151..be60baa0bb 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1065,6 +1065,7 @@ export const ptBR: TranslationResources = { title: "Novo workspace", create: "Criar", runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Ambiente" }, + runtimeProbe: { preparing: "Preparando o ambiente" }, isolation: { local: "Local", worktree: "Novo worktree", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f3713a8c07..56b3b0d845 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1065,6 +1065,7 @@ export const ru: TranslationResources = { title: "Новое рабочее пространство", create: "Создавать", runtime: { local: "Локально", worktree: "Worktree", docker: "Docker", label: "Среда" }, + runtimeProbe: { preparing: "Подготовка среды" }, isolation: { local: "Локально", worktree: "Новый worktree", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e305416179..58640f3780 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1032,6 +1032,7 @@ export const zhCN: TranslationResources = { title: "新建 workspace", create: "创建", runtime: { local: "本地", worktree: "Worktree", docker: "Docker", label: "运行环境" }, + runtimeProbe: { preparing: "正在准备环境" }, isolation: { local: "本地", worktree: "新建 worktree", diff --git a/packages/app/src/new-workspace-runtime/model.test.ts b/packages/app/src/new-workspace-runtime/model.test.ts index cc1f88d3bc..edacffa322 100644 --- a/packages/app/src/new-workspace-runtime/model.test.ts +++ b/packages/app/src/new-workspace-runtime/model.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "vitest"; import type { WorkspaceRuntimeCatalogEntry } from "@getpaseo/protocol/messages"; -import { resolveWorkspaceRuntimeSelection, runtimeLabel } from "./model"; +import { + resolveWorkspaceProviderProbeTarget, + resolveWorkspaceProviderSnapshotScope, + resolveWorkspaceRuntimeSelection, + runtimeLabel, +} from "./model"; const LOCAL: WorkspaceRuntimeCatalogEntry = { runtimeId: "local", @@ -91,3 +96,57 @@ describe("new workspace runtime selection", () => { ).toBe("Worktree"); }); }); + +describe("new workspace provider probe target", () => { + test("retains the tagged cwd snapshot only below the runtime feature gate", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: false, + probe: { status: "legacy" }, + }), + ).toEqual({ kind: "legacy-cwd" }); + }); + + test("disables provider snapshots until the selected runtime probe is ready", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "loading" }, + }), + ).toEqual({ kind: "unavailable" }); + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "error", error: "fixture failed" }, + }), + ).toEqual({ kind: "unavailable" }); + }); + + test("keys the existing provider snapshot pipeline by the ensured probe workspace", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "ready", workspaceId: "probe-fixture" }, + }), + ).toEqual({ kind: "workspace", workspaceId: "probe-fixture" }); + }); + + test("makes a project cwd unreachable while a feature-gated probe is unavailable", () => { + expect(resolveWorkspaceProviderSnapshotScope({ kind: "unavailable" }, "/host/repo")).toEqual({ + enabled: false, + cwd: null, + workspaceId: null, + }); + expect( + resolveWorkspaceProviderSnapshotScope( + { kind: "workspace", workspaceId: "probe-fixture" }, + "/host/repo", + ), + ).toEqual({ enabled: true, cwd: null, workspaceId: "probe-fixture" }); + expect(resolveWorkspaceProviderSnapshotScope({ kind: "legacy-cwd" }, "/host/repo")).toEqual({ + enabled: true, + cwd: "/host/repo", + workspaceId: null, + }); + }); +}); diff --git a/packages/app/src/new-workspace-runtime/model.ts b/packages/app/src/new-workspace-runtime/model.ts index 419ffa825d..4eea1abdfb 100644 --- a/packages/app/src/new-workspace-runtime/model.ts +++ b/packages/app/src/new-workspace-runtime/model.ts @@ -6,6 +6,42 @@ export type WorkspaceRuntimeCatalogState = | { status: "ready"; runtimes: readonly WorkspaceRuntimeCatalogEntry[] } | { status: "error"; error: string }; +export type WorkspaceProviderProbeState = + | { status: "legacy" } + | { status: "loading" } + | { status: "ready"; workspaceId: string } + | { status: "error"; error: string }; + +export type WorkspaceProviderSnapshotTarget = + | { kind: "legacy-cwd" } + | { kind: "workspace"; workspaceId: string } + | { kind: "unavailable" }; + +export function resolveWorkspaceProviderProbeTarget(input: { + supportsWorkspaceRuntimes: boolean; + probe: WorkspaceProviderProbeState; +}): WorkspaceProviderSnapshotTarget { + if (!input.supportsWorkspaceRuntimes) { + // COMPAT(newWorkspaceCwdProbe): added in v0.3.2, remove after 2027-02-12. + return { kind: "legacy-cwd" }; + } + if (input.probe.status !== "ready") return { kind: "unavailable" }; + return { kind: "workspace", workspaceId: input.probe.workspaceId }; +} + +export function resolveWorkspaceProviderSnapshotScope( + target: WorkspaceProviderSnapshotTarget, + projectCwd: string | null, +): { enabled: boolean; cwd: string | null; workspaceId: string | null } { + if (target.kind === "legacy-cwd") { + return { enabled: true, cwd: projectCwd, workspaceId: null }; + } + if (target.kind === "workspace") { + return { enabled: true, cwd: null, workspaceId: target.workspaceId }; + } + return { enabled: false, cwd: null, workspaceId: null }; +} + export type WorkspaceRuntimeVocabulary = "isolation" | "runtime"; export interface WorkspaceRuntimeSelection { diff --git a/packages/app/src/new-workspace-runtime/use-provider-probe.ts b/packages/app/src/new-workspace-runtime/use-provider-probe.ts new file mode 100644 index 0000000000..e5fa715d60 --- /dev/null +++ b/packages/app/src/new-workspace-runtime/use-provider-probe.ts @@ -0,0 +1,46 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useFetchQuery } from "@/data/query"; + +import type { WorkspaceProviderProbeState } from "./model"; + +export function useWorkspaceProviderProbe(input: { + serverId: string; + projectId: string | null; + runtimeId: string; + supportsWorkspaceRuntimes: boolean; + isConnected: boolean; + client: DaemonClient | null; +}): WorkspaceProviderProbeState & { retry(): void } { + const query = useFetchQuery({ + queryKey: ["workspace-provider-probe", input.serverId, input.projectId, input.runtimeId], + dataShape: "value", + staleTimeMs: 5 * 60_000, + queryFn: async () => { + if (!input.client || !input.projectId) throw new Error("Choose a connected project"); + const result = await input.client.ensureWorkspaceRuntimeProbe({ + projectId: input.projectId, + runtimeId: input.runtimeId, + }); + if (result.status === "error" || !result.workspaceId) { + throw new Error(result.error ?? "Failed to prepare the runtime environment"); + } + return result.workspaceId; + }, + enabled: + input.supportsWorkspaceRuntimes && + input.isConnected && + Boolean(input.client && input.projectId), + retry: false, + }); + const retry = () => void query.refetch(); + if (!input.supportsWorkspaceRuntimes) return { status: "legacy", retry }; + if (query.isError) { + return { + status: "error", + error: query.error instanceof Error ? query.error.message : String(query.error), + retry, + }; + } + if (query.data) return { status: "ready", workspaceId: query.data, retry }; + return { status: "loading", retry }; +} diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 4c40fe61b2..4468e2739e 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -136,7 +136,7 @@ describe("combined model selector data", () => { provider: "error-provider", status: "error", error: "boom", - models: [], + models: [codexModel], }), snapshotEntry({ provider: "unavailable-provider", diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 6e3a48de34..8087e0f3d7 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -88,9 +88,6 @@ function buildEntryModelSelection( entry: ProviderSnapshotEntry, label: string, ): ProviderModelSelection { - if ((entry.models?.length ?? 0) > 0) { - return buildModelSelection(entry.provider, label, entry.models ?? null); - } if (entry.status === "ready") { return buildModelSelection(entry.provider, label, entry.models ?? null); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 33cf8ede34..a1ff505398 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -126,11 +126,15 @@ import { } from "./new-workspace-initial-context"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; import { + resolveWorkspaceProviderProbeTarget, + resolveWorkspaceProviderSnapshotScope, resolveWorkspaceRuntimeSelection, runtimeLabel, + type WorkspaceProviderProbeState, type WorkspaceRuntimeCatalogState, type WorkspaceRuntimeVocabulary, } from "@/new-workspace-runtime/model"; +import { useWorkspaceProviderProbe } from "@/new-workspace-runtime/use-provider-probe"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -172,14 +176,43 @@ function isNewWorkspacePending(input: { function isWorkspaceCreationBlocked(input: { isPending: boolean; canCreateWorkspace: boolean; + isProviderProbeReady: boolean; }): boolean { - return input.isPending || !input.canCreateWorkspace; + return input.isPending || !input.canCreateWorkspace || !input.isProviderProbeReady; } function WorkspaceCreationError({ error }: { error: string | null }): ReactElement | null { return error ? {error} : null; } +function WorkspaceProviderProbeFeedback({ + probe, + onRetry, +}: { + probe: WorkspaceProviderProbeState; + onRetry: () => void; +}): ReactElement | null { + const { t } = useTranslation(); + if (probe.status === "legacy" || probe.status === "ready") return null; + if (probe.status === "loading") { + return ( + + {t("newWorkspace.runtimeProbe.preparing")} + + ); + } + return ( + + + {probe.error} + + + {t("common.actions.retry")} + + + ); +} + function resolveWorkspaceCreationError( actionError: string | null, catalogError: string | null, @@ -864,6 +897,14 @@ async function createMultiplicityWorkspace(input: { return normalizedWorkspace; } +function resolveSelectedHostProjectId( + project: HostProjectListItem | null, + serverId: string, +): string | null { + if (!project) return null; + return getHostProjectId(project, serverId) ?? null; +} + interface CreateChatAgentInput { payload: MessagePayload; composerState: ReturnType["composerState"]; @@ -988,13 +1029,28 @@ function buildComposerConfig(input: { workspaceDirectory: string | null; sourceDirectory: string | null; initialSetup?: WorkspaceDraftTabSetup | null; + providerSnapshotTarget: ReturnType; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const { + serverId, + isConnected, + workspaceDirectory, + sourceDirectory, + initialSetup, + providerSnapshotTarget, + } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; + const providerSnapshotScope = resolveWorkspaceProviderSnapshotScope( + providerSnapshotTarget, + workingDir ?? null, + ); return { initialServerId: serverId || null, initialValues: buildComposerInitialValues({ workingDir, initialSetup }), initialFeatureValues: initialSetup?.featureValues, + workspaceId: providerSnapshotScope.workspaceId, + providerSnapshotCwd: providerSnapshotScope.cwd, + isTargetDaemonReady: providerSnapshotScope.enabled, isVisible: true, onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, @@ -1753,6 +1809,36 @@ export function NewWorkspaceScreen({ const projectIconDataByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); + const worktreeSupport = selectedProject + ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) + : "unsupported"; + const { + runtimeId, + setRuntimeId, + availableRuntimes, + showRuntimeControl, + showRefPicker, + canCreateWorkspace, + catalogError, + vocabulary: runtimeVocabulary, + } = useWorkspaceRuntime({ + supportsMultiplicity: supportsWorkspaceMultiplicity, + worktreeSupport, + catalog: runtimeCatalog, + }); + const selectedHostProjectId = resolveSelectedHostProjectId(selectedProject, selectedServerId); + const providerProbe = useWorkspaceProviderProbe({ + serverId: selectedServerId, + projectId: selectedHostProjectId, + runtimeId, + supportsWorkspaceRuntimes, + isConnected, + client, + }); + const providerProbeTarget = resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes, + probe: providerProbe, + }); const draftKey = buildNewWorkspaceDraftKey(draftId); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); @@ -1768,6 +1854,7 @@ export function NewWorkspaceScreen({ workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, initialSetup: forkDraftSetup?.setup, + providerSnapshotTarget: providerProbeTarget, }), }); const composerState = chatDraft.composerState; @@ -1804,25 +1891,12 @@ export function NewWorkspaceScreen({ cwd: selectedSourceDirectory ?? "", }); - const worktreeSupport = selectedProject - ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) - : "unsupported"; const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); - const { - runtimeId, - setRuntimeId, - availableRuntimes, - showRuntimeControl, - showRefPicker, + const isCreationBlocked = isWorkspaceCreationBlocked({ + isPending, canCreateWorkspace, - catalogError, - vocabulary: runtimeVocabulary, - } = useWorkspaceRuntime({ - supportsMultiplicity: supportsWorkspaceMultiplicity, - worktreeSupport, - catalog: runtimeCatalog, + isProviderProbeReady: providerProbeTarget.kind !== "unavailable", }); - const isCreationBlocked = isWorkspaceCreationBlocked({ isPending, canCreateWorkspace }); const branchSuggestionsQuery = useQuery({ queryKey: [ @@ -2311,10 +2385,10 @@ export function NewWorkspaceScreen({ composerState ? { ...composerState.agentControls, - disabled: isPending, + disabled: isCreationBlocked, } : undefined, - [composerState, isPending], + [composerState, isCreationBlocked], ); const pickerEmptyText = @@ -2458,6 +2532,7 @@ export function NewWorkspaceScreen({ + @@ -2505,6 +2580,21 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, lineHeight: 20, }, + probeStatusText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + lineHeight: 20, + }, + probeErrorRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + probeRetryText: { + fontSize: theme.fontSize.sm, + color: theme.colors.accent, + lineHeight: 20, + }, formStack: { marginBottom: theme.spacing[8], gap: theme.spacing[2], diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 9395cbd0e3..43ef54b3b2 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2746,6 +2746,32 @@ test("lists workspace runtimes and sends an explicit creation runtime", async () runtimes: [{ runtimeId: "fixture", builtin: false, requiresGitProject: true }], }); + const probePromise = client.ensureWorkspaceRuntimeProbe( + { projectId: "project-1", runtimeId: "fixture" }, + "req-runtime-probe", + ); + expect(parseSentFrame(mock.sent.at(-1)!)).toEqual({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "req-runtime-probe", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: "req-runtime-probe", + workspaceId: "probe-fixture", + status: "ready", + error: null, + }, + }), + ); + await expect(probePromise).resolves.toMatchObject({ + workspaceId: "probe-fixture", + status: "ready", + }); + const createPromise = client.createWorkspace( { source: { kind: "directory", path: "/tmp/project" }, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 2914aab1f5..1dd3db5619 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -102,6 +102,7 @@ import type { WorkspaceCreateRequest, WorkspaceRecoveryState, WorkspaceRuntimeListPayload, + WorkspaceRuntimeEnsureProbePayload, } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest, @@ -2204,6 +2205,17 @@ export class DaemonClient { }); } + async ensureWorkspaceRuntimeProbe( + input: { projectId: string; runtimeId: string }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.runtime.ensure_probe.request", ...input }, + responseType: "workspace.runtime.ensure_probe.response", + }); + } + async openProject(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/desktop/e2e/new-workspace-runtime.spec.ts b/packages/desktop/e2e/new-workspace-runtime.spec.ts index e27b614491..0934da308e 100644 --- a/packages/desktop/e2e/new-workspace-runtime.spec.ts +++ b/packages/desktop/e2e/new-workspace-runtime.spec.ts @@ -1,14 +1,20 @@ import { test as base } from "../../app/e2e/support/fixtures"; import { createWorkspaceInSelectedRuntime, + expectNoProbeInWorkspaceProjection, + expectProbeFailureWithRetry, + expectProbeSkippedProjectSetup, + expectProviderAvailable, + expectFixtureProviderUnavailable, expectRuntimeChoices, - expectNewWorkspaceProviderSnapshotUsesProjectCwd, expectRuntimeSelected, + expectUserWorkspaceRanProjectSetup, expectWorkspaceOpenInRuntime, gotoNewWorkspaceForRuntime, seedGitProjectForRuntime, seedNonGitProjectForRuntime, selectRuntime, + retryFailedProbe, type SeededRuntimeProject, } from "../../app/e2e/support/helpers/new-workspace-runtime"; import { getServerId } from "../../app/e2e/support/helpers/server-id"; @@ -40,22 +46,28 @@ const test = base.extend<{ }, }); -test("creates a workspace in a selected runtime", async ({ page, runtimeProject }) => { +test("probes and creates a workspace in a selected runtime", async ({ page, runtimeProject }) => { await test.step("choose the project and runtime", async () => { - const cwdSnapshotRequest = expectNewWorkspaceProviderSnapshotUsesProjectCwd( - page, - runtimeProject.sourceDirectory, - ); await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); await gotoNewWorkspaceForRuntime(page, runtimeProject); - await cwdSnapshotRequest; - await expectRuntimeChoices(page, ["Local", "Worktree", "Docker", "Fixture"]); + await expectRuntimeChoices(page, ["Local", "Worktree", "Docker", "Fixture", "Fixture Failure"]); await selectRuntime(page, "Fixture"); }); + await test.step("show the selected runtime's provider truth", async () => { + await expectProviderAvailable(page, "Fixture Agent"); + await expectProbeSkippedProjectSetup(runtimeProject); + await selectRuntime(page, "Local"); + await expectFixtureProviderUnavailable(page); + await selectRuntime(page, "Fixture"); + await expectProviderAvailable(page, "Fixture Agent"); + }); + await test.step("create and open the workspace", async () => { await createWorkspaceInSelectedRuntime(page); await expectWorkspaceOpenInRuntime(page, runtimeProject, "fixture"); + await expectUserWorkspaceRanProjectSetup(runtimeProject); + await expectNoProbeInWorkspaceProjection(page, runtimeProject); }); await test.step("remember the selected runtime", async () => { @@ -65,6 +77,23 @@ test("creates a workspace in a selected runtime", async ({ page, runtimeProject }); }); +test("shows probe failure with retry without host providers", async ({ page, runtimeProject }) => { + await test.step("choose the failing runtime", async () => { + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await selectRuntime(page, "Fixture Failure"); + }); + + await test.step("show the runtime error without provider fallback", async () => { + await expectProbeFailureWithRetry(page, "Fixture probe creation failed"); + }); + + await test.step("retry the failed probe", async () => { + await retryFailedProbe(page); + await expectProbeFailureWithRetry(page, "Fixture probe creation failed"); + }); +}); + test("hides Git runtimes for a non-Git project", async ({ page, nonGitRuntimeProject }) => { await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); await gotoNewWorkspaceForRuntime(page, nonGitRuntimeProject); diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts index 56cd882d71..625dbc1485 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -70,12 +70,13 @@ try { switch (operation) { case "create": { - const state = await create(requestedWorkspaceId, request); + const result = await create(requestedWorkspaceId, request); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "state", - state: publicState(state), - placement: runtimePlacement(state), + state: publicState(result.state), + placement: runtimePlacement(result.state), + materializedFreshContent: result.materializedFreshContent, }); } break; @@ -123,9 +124,11 @@ try { async function create( workspaceId: string, request: LifecycleRequest, -): Promise { +): Promise<{ state: PrivateRuntimeState; materializedFreshContent: boolean }> { const existing = await inspectPrivate(workspaceId); - if (existing.status === "ready" || existing.status === "paused") return existing.state; + if (existing.status === "ready" || existing.status === "paused") { + return { state: existing.state, materializedFreshContent: false }; + } const input = request.input; if (!input) throw new Error("create input is required"); const image = requireImage(request.options); @@ -223,7 +226,10 @@ async function create( ]); await docker(["start", names.container]); await waitUntilReady(names.container); - return stateFor(workspaceId, root, commit, names.container); + return { + state: stateFor(workspaceId, root, commit, names.container), + materializedFreshContent: true, + }; } catch (error) { await docker(["rm", "-f", names.container], true); await docker(["volume", "rm", "-f", names.volume], true); diff --git a/packages/fixture-workspace-runtime/src/index.mjs b/packages/fixture-workspace-runtime/src/index.mjs index 5f8d808bb9..5142ebc253 100755 --- a/packages/fixture-workspace-runtime/src/index.mjs +++ b/packages/fixture-workspace-runtime/src/index.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { watch } from "node:fs"; -import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { chmod, cp, copyFile, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Socket } from "node:net"; import { createInterface } from "node:readline"; @@ -67,12 +67,13 @@ try { switch (operation) { case "create": { - const state = await create(workspaceId, request); + const result = await create(workspaceId, request); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "state", - state: publicState(state), - placement: publicPlacement(state), + state: publicState(result.state), + placement: publicPlacement(result.state), + materializedFreshContent: result.materializedFreshContent, }); } break; @@ -102,7 +103,7 @@ try { } break; case "destroy": - await rm(stateFile(workspaceId, request.options), { force: true }); + await destroy(workspaceId, request.options); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "ok", @@ -119,18 +120,45 @@ try { async function create(id, request) { const existing = await readState(id, request.options); - if (existing) return existing; - const root = request.input.project.source.path; + if (existing) return { state: existing, materializedFreshContent: false }; + if (request.options.failCreate === true) { + throw new Error(String(request.options.createError ?? "Fixture workspace creation failed")); + } + const sourceRoot = request.input.project.source.path; + const ownedRoot = request.options.materializeRoot + ? path.join(request.options.materializeRoot, id) + : null; + const root = ownedRoot ?? sourceRoot; + if (ownedRoot) { + await rm(ownedRoot, { recursive: true, force: true }); + await mkdir(path.dirname(ownedRoot), { recursive: true }); + await cp(sourceRoot, ownedRoot, { recursive: true }); + } + if (request.options.fixtureProviderSource) { + const providerPath = path.join(root, ".paseo-fixture-agent.mjs"); + await copyFile(request.options.fixtureProviderSource, providerPath); + await chmod(providerPath, 0o755); + } const state = { workspaceId: id, root, - displayCwd: request.options.displayCwd ?? root, + displayCwd: request.options.preserveSourceDisplayCwd + ? sourceRoot + : (request.options.displayCwd ?? root), hostVisiblePath: request.options.exposeHostVisiblePath === false ? undefined : root, lifecycle: "ready", + ownedRoot, + createInput: request.input, }; await mkdir(request.options.stateDirectory, { recursive: true }); await writeFile(stateFile(id, request.options), JSON.stringify(state)); - return state; + return { state, materializedFreshContent: true }; +} + +async function destroy(id, options) { + const state = await readState(id, options); + if (state?.ownedRoot) await rm(state.ownedRoot, { recursive: true, force: true }); + await rm(stateFile(id, options), { force: true }); } async function inspect(id, options) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d36f776430..4ef4b6e9e9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2184,6 +2184,13 @@ export const WorkspaceRuntimeListRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceRuntimeEnsureProbeRequestSchema = z.object({ + type: z.literal("workspace.runtime.ensure_probe.request"), + projectId: z.string(), + runtimeId: z.string(), + requestId: z.string(), +}); + // Create a new workspace record. Unlike open_project, this never deduplicates by // directory: it always produces a fresh workspace. The source discriminates // between an existing local directory and a newly created paseo worktree. @@ -2747,6 +2754,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceRuntimeListRequestSchema, + WorkspaceRuntimeEnsureProbeRequestSchema, WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, @@ -3987,6 +3995,16 @@ export const WorkspaceRuntimeListResponseSchema = z.object({ }), }); +export const WorkspaceRuntimeEnsureProbeResponseSchema = z.object({ + type: z.literal("workspace.runtime.ensure_probe.response"), + payload: z.object({ + workspaceId: z.string().nullable(), + status: z.enum(["ready", "error"]), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const WorkspaceClearAttentionResponseSchema = z.object({ type: z.literal("workspace.clear_attention.response"), payload: z.object({ @@ -5576,6 +5594,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, WorkspaceRuntimeListResponseSchema, + WorkspaceRuntimeEnsureProbeResponseSchema, WorkspaceClearAttentionResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, @@ -5807,6 +5826,9 @@ export type WorkspaceRuntimeCatalogEntry = z.infer["payload"]; +export type WorkspaceRuntimeEnsureProbePayload = z.infer< + typeof WorkspaceRuntimeEnsureProbeResponseSchema +>["payload"]; export type ProjectRenameResponsePayload = z.infer; export type ProjectRemoveResponsePayload = z.infer; export type WaitForFinishResponseMessage = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index b2c0b00663..bedeeb0829 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -7,6 +7,8 @@ import { WorkspaceCreateRequestSchema, WorkspaceRuntimeListRequestSchema, WorkspaceRuntimeListResponseSchema, + WorkspaceRuntimeEnsureProbeRequestSchema, + WorkspaceRuntimeEnsureProbeResponseSchema, WorkspaceDescriptorPayloadSchema, WorkspaceScriptPayloadSchema, } from "./messages.js"; @@ -1161,4 +1163,47 @@ describe("workspace message schemas", () => { }).runtimeId, ).toBe("fixture"); }); + + test("workspace runtime probe ensure has explicit ready and error responses", () => { + expect( + WorkspaceRuntimeEnsureProbeRequestSchema.parse({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "probe-1", + }), + ).toEqual({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "probe-1", + }); + expect( + WorkspaceRuntimeEnsureProbeResponseSchema.parse({ + type: "workspace.runtime.ensure_probe.response", + payload: { + workspaceId: "probe-123", + status: "ready", + error: null, + requestId: "probe-1", + }, + }).payload, + ).toEqual({ + workspaceId: "probe-123", + status: "ready", + error: null, + requestId: "probe-1", + }); + expect( + WorkspaceRuntimeEnsureProbeResponseSchema.parse({ + type: "workspace.runtime.ensure_probe.response", + payload: { + workspaceId: null, + status: "error", + error: "fixture failed", + requestId: "probe-2", + }, + }).payload, + ).toMatchObject({ status: "error", error: "fixture failed" }); + }); }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ced6a77a97..4a5bc73c29 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -153,9 +153,12 @@ import { } from "./workspace-registry.js"; import { createWorkspaceRuntimeService, + type BoundWorkspaceRuntime, type WorkspaceRuntimeConfig, + type WorkspaceRuntimeRecordStore, type WorkspaceRuntimeService, } from "./workspace-runtime/index.js"; +import { createProviderProbeService, type ProviderProbeService } from "./provider-probe/index.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; @@ -611,6 +614,7 @@ export async function createPaseoDaemon( let boundListenTarget: ListenTarget | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; let workspaceRuntime: WorkspaceRuntimeService | null = null; + let providerProbe: ProviderProbeService | null = null; const terminalManager = createConfiguredTerminalManager({ getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), launchPty: async (input) => { @@ -863,51 +867,72 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); - workspaceRuntime = createWorkspaceRuntimeService({ - paseoHome: config.paseoHome, - worktreesRoot: config.worktreesRoot, - externalRuntimes: config.workspaceRuntimes, - resolveRuntimeId: async (workspaceId) => { - const workspace = await workspaceRegistry?.get(workspaceId); - return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null; + const providerWorkspaceBindings = new Map< + string, + { runtime: BoundWorkspaceRuntime; workspace: ReturnType } + >(); + // Record removal evicts bindings below. Until Phase 4 adds probe invalidation/TTL destruction, + // live probe entries remain bounded to one deterministic workspace per project/runtime pair. + const bindWorkspaceProviderCapability = async (workspaceId: string, runtimeId: string) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + const runtime = await workspaceRuntime.bind(workspaceId); + const cached = providerWorkspaceBindings.get(workspaceId); + if (cached?.runtime === runtime) return cached.workspace; + const workspace = bindProviderWorkspace({ + runtime, + cwd: ".", + policy: resolveProviderPlacementPolicy({ + runtimeId, + hostEnvironment: process.env, + isolatedEnvironment: config.workspaceRuntimes?.[runtimeId]?.providerEnvironment, + }), + }); + providerWorkspaceBindings.set(workspaceId, { runtime, workspace }); + return workspace; + }; + providerProbe = createProviderProbeService({ + filePath: path.join(config.paseoHome, "projects", "provider-probes.json"), + logger, + projects: projectRegistry, + runtime: { + create: (input) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.create(input); + }, + inspect: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.inspect(workspaceId); + }, + resume: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.resume(workspaceId); + }, }, + runtimeConfiguration: config.workspaceRuntimes, + bindWorkspaceProviderCapability, + }); + const workspaceRecords: WorkspaceRuntimeRecordStore = { + resolveRuntimeId: async (workspaceId) => + (await providerProbe?.records.resolveRuntimeId(workspaceId)) ?? + resolveRegistryRuntimeId(workspaceId), persistRuntimeId: async (workspaceId, runtimeId, placement) => { - const existing = await workspaceRegistry?.get(workspaceId); - if ( - existing?.runtime?.runtimeId === runtimeId && - existing.cwd === placement.cwd && - existing.hostVisiblePath === (placement.hostVisiblePath ?? null) - ) { + if (await providerProbe?.records.resolveRuntimeId(workspaceId)) { + await providerProbe.records.persistRuntimeId(workspaceId, runtimeId, placement); return; } - const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ - ...workspace, - cwd: placement.cwd, - hostVisiblePath: placement.hostVisiblePath ?? null, - runtime: { runtimeId }, - })); - if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + await persistRegistryRuntimeId(workspaceId, runtimeId, placement); }, - archiveWorkspaceRecord: async (workspaceId) => { - await workspaceRegistry?.archive(workspaceId, new Date().toISOString()); - }, - restoreWorkspaceRecord: async (workspaceId) => { - const restoredAt = new Date().toISOString(); - const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ - ...workspace, - archivedAt: null, - updatedAt: restoredAt, - })); - if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); - }, - beginWorkspaceDeletion: async (workspaceId) => { - await workspaceRegistry?.requestDeletion(workspaceId, new Date().toISOString()); - }, - removeWorkspaceRecord: async (workspaceId) => { - await workspaceRegistry?.remove(workspaceId); - }, - listRuntimeRecords: async () => - ((await workspaceRegistry?.list()) ?? []).flatMap((workspace) => + archiveWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "archiveWorkspaceRecord"), + restoreWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "restoreWorkspaceRecord"), + beginWorkspaceDeletion: (workspaceId) => + routeRuntimeRecord(workspaceId, "beginWorkspaceDeletion"), + removeWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "removeWorkspaceRecord"), + listRuntimeRecords: async () => [ + ...((await providerProbe?.records.listRuntimeRecords?.()) ?? []), + ...((await workspaceRegistry?.list()) ?? []).flatMap((workspace) => workspace.runtime ? [ { @@ -919,7 +944,79 @@ export async function createPaseoDaemon( ] : [], ), + ], + }; + workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + externalRuntimes: config.workspaceRuntimes, + ...workspaceRecords, }); + + async function resolveRegistryRuntimeId(workspaceId: string): Promise { + const workspace = await workspaceRegistry?.get(workspaceId); + return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null; + } + + async function persistRegistryRuntimeId( + workspaceId: string, + runtimeId: string, + placement: { cwd: string; hostVisiblePath?: string }, + ): Promise { + const existing = await workspaceRegistry?.get(workspaceId); + if ( + existing?.runtime?.runtimeId === runtimeId && + existing.cwd === placement.cwd && + existing.hostVisiblePath === (placement.hostVisiblePath ?? null) + ) { + return; + } + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + } + + async function routeRuntimeRecord( + workspaceId: string, + operation: + | "archiveWorkspaceRecord" + | "restoreWorkspaceRecord" + | "beginWorkspaceDeletion" + | "removeWorkspaceRecord", + ): Promise { + const probeRecords = providerProbe?.records; + if (probeRecords && (await probeRecords.resolveRuntimeId(workspaceId))) { + await probeRecords[operation]?.(workspaceId); + if (operation === "removeWorkspaceRecord") { + providerWorkspaceBindings.delete(workspaceId); + } + return; + } + if (operation === "archiveWorkspaceRecord") { + await workspaceRegistry?.archive(workspaceId, new Date().toISOString()); + return; + } + if (operation === "restoreWorkspaceRecord") { + const restoredAt = new Date().toISOString(); + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + archivedAt: null, + updatedAt: restoredAt, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + return; + } + if (operation === "beginWorkspaceDeletion") { + await workspaceRegistry?.requestDeletion(workspaceId, new Date().toISOString()); + return; + } + await workspaceRegistry?.remove(workspaceId); + providerWorkspaceBindings.delete(workspaceId); + } const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, logger, @@ -947,20 +1044,13 @@ export async function createPaseoDaemon( }); const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); const resolveProviderWorkspace = async (workspaceId: string) => { + const probeWorkspace = await providerProbe?.resolveProviderWorkspace(workspaceId); + if (probeWorkspace) return probeWorkspace; const workspace = await workspaceRegistry?.get(workspaceId); if (!workspace) return undefined; const runtimeId = resolveSelectedWorkspaceRuntimeId(workspace); if (!runtimeId) return null; - if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); - return bindProviderWorkspace({ - runtime: await workspaceRuntime.bind(workspaceId), - cwd: ".", - policy: resolveProviderPlacementPolicy({ - runtimeId, - hostEnvironment: process.env, - isolatedEnvironment: config.workspaceRuntimes?.[runtimeId]?.providerEnvironment, - }), - }); + return bindWorkspaceProviderCapability(workspaceId, runtimeId); }; const providerSnapshotManager = new ProviderSnapshotManager({ logger: providerSnapshotLogger, @@ -1741,6 +1831,7 @@ export async function createPaseoDaemon( hubRelationships, workspaceSetupRuntime, workspaceRuntime, + providerProbe, ); relayRuntime = createRelayRuntime({ config: { diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index d6941180c9..1c47cab8b7 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -334,6 +334,7 @@ describe("current workspace runtime journeys", () => { expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({ runtimeId: "local", }); + expect(existsSync(path.join(workspace.cwd, "setup-output.txt"))).toBe(false); await expectFileEditAndWatch(workspace); await expectGitObservation(workspace); await expectTerminalCommand(workspace); @@ -490,6 +491,102 @@ test("catalog selection creates through a configured runtime while omission rema } }); +test("provider probes match user workspace snapshots for every host-backed runtime", async () => { + const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-provider-probes-")); + cleanupRoots.push(root); + const stateDirectory = path.join(root, "fixture-state"); + const materializeRoot = path.join(root, "fixture-workspaces"); + mkdirSync(stateDirectory, { recursive: true }); + const probeDaemon = await createTestPaseoDaemon({ + mcpEnabled: false, + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [process.execPath, fixtureAgentPath], + models: [{ id: FIXTURE_MODEL, label: "Fixture Model", isDefault: true }], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + workspaceRuntimes: { + fixture: { + type: "command", + label: "Fixture", + command: [process.execPath, fixtureRuntimePath], + helperCommand: [process.execPath, workspaceHelperPath], + options: { stateDirectory, materializeRoot }, + }, + }, + }); + const probeClient = new DaemonClient({ + url: `ws://127.0.0.1:${probeDaemon.port}/ws`, + appVersion: "0.3.1", + reconnect: { enabled: false }, + }); + try { + await probeClient.connect(); + for (const runtimeId of ["local", "worktree", "fixture"] as const) { + const repo = path.join(root, `repo-${runtimeId}`); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); + copyFileSync(fixtureAgentPath, path.join(repo, "fixture-agent.mjs")); + chmodSync(path.join(repo, "fixture-agent.mjs"), 0o755); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo }); + const added = await probeClient.addProject(repo); + if (!added.project) throw new Error(added.error ?? `Failed to add ${runtimeId} project`); + + const ensured = await probeClient.ensureWorkspaceRuntimeProbe({ + projectId: added.project.projectId, + runtimeId, + }); + expect(ensured).toMatchObject({ status: "ready", error: null }); + const probeSnapshot = await probeClient.getProvidersSnapshot({ + workspaceId: ensured.workspaceId!, + }); + const created = await probeClient.createWorkspace({ + source: + runtimeId === "worktree" + ? { + kind: "worktree", + projectId: added.project.projectId, + action: "branch-off", + branchName: `probe-${runtimeId}`, + worktreeSlug: `probe-${runtimeId}`, + baseBranch: "main", + } + : { kind: "directory", path: repo, projectId: added.project.projectId }, + runtimeId, + }); + if (!created.workspace) throw new Error(created.error ?? `Failed to create ${runtimeId}`); + const workspaceSnapshot = await probeClient.getProvidersSnapshot({ + workspaceId: created.workspace.id, + }); + expect(probeSnapshot.entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry)).toEqual( + workspaceSnapshot.entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry), + ); + expect(probeSnapshot.entries).toContainEqual( + expect.objectContaining({ provider: FIXTURE_PROVIDER, status: "ready" }), + ); + const listed = await probeClient.fetchWorkspaces({ + filter: { projectId: added.project.projectId }, + }); + expect(listed.entries.map((entry) => entry.id)).toEqual([created.workspace.id]); + await probeClient.removeProject(added.project.projectId); + } + } finally { + await probeClient.close().catch(() => undefined); + await probeDaemon.close(); + } +}, 120_000); + test("selected worktree provider journey stays behind the public daemon and client boundary", async () => { const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-selected-worktree-")); cleanupRoots.push(root); diff --git a/packages/server/src/server/provider-probe/index.ts b/packages/server/src/server/provider-probe/index.ts new file mode 100644 index 0000000000..c1d38b2afb --- /dev/null +++ b/packages/server/src/server/provider-probe/index.ts @@ -0,0 +1,47 @@ +import type pino from "pino"; + +import type { ProviderWorkspace } from "../agent/providers/workspace/index.js"; +import type { + CreateWorkspaceInput, + WorkspaceRuntimeInspection, + WorkspaceRuntimePlacement, + WorkspaceRuntimeRecordStore, +} from "../workspace-runtime/index.js"; +import { createProbeStore } from "./internal/probe-store.js"; +import { createService } from "./internal/service.js"; + +export interface ProviderProbeService { + ensure(input: { projectId: string; runtimeId: string }): Promise<{ workspaceId: string }>; + resolveProviderWorkspace(workspaceId: string): Promise; + readonly records: WorkspaceRuntimeRecordStore; +} + +export interface ProviderProbeServiceOptions { + filePath: string; + logger: pino.Logger; + projects: { + get(projectId: string): Promise<{ + projectId: string; + rootPath: string; + updatedAt: string; + archivedAt?: string | null; + } | null>; + }; + runtime: { + create(input: CreateWorkspaceInput): Promise; + inspect(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + }; + runtimeConfiguration?: Readonly>; + bindWorkspaceProviderCapability( + workspaceId: string, + runtimeId: string, + ): Promise; +} + +export function createProviderProbeService( + options: ProviderProbeServiceOptions, +): ProviderProbeService { + const store = createProbeStore(options.filePath, options.logger); + return createService({ ...options, store }); +} diff --git a/packages/server/src/server/provider-probe/internal/probe-store.ts b/packages/server/src/server/provider-probe/internal/probe-store.ts new file mode 100644 index 0000000000..cd39eab76d --- /dev/null +++ b/packages/server/src/server/provider-probe/internal/probe-store.ts @@ -0,0 +1,132 @@ +import { promises as fs } from "node:fs"; + +import type pino from "pino"; +import { z } from "zod"; + +import { writeJsonFileAtomic } from "../../atomic-file.js"; +import type { WorkspaceRuntimeRecordStore } from "../../workspace-runtime/index.js"; + +const ProbeRecordSchema = z + .object({ + workspaceId: z.string(), + projectId: z.string(), + runtimeId: z.string(), + fingerprint: z.string(), + status: z.enum(["materializing", "ready", "paused"]), + lastUsedAt: z.string(), + createdAt: z.string(), + }) + .strict(); + +export type ProviderProbeRecord = z.infer; + +export interface ProbeStore { + readonly records: WorkspaceRuntimeRecordStore; + get(workspaceId: string): Promise; + put(record: ProviderProbeRecord): Promise; + update( + workspaceId: string, + updater: (record: ProviderProbeRecord) => ProviderProbeRecord, + ): Promise; + remove(workspaceId: string): Promise; +} + +export function createProbeStore(filePath: string, logger: pino.Logger): ProbeStore { + let loaded = false; + let queue = Promise.resolve(); + const cache = new Map(); + const log = logger.child({ module: "provider-probe-store" }); + + async function load(): Promise { + if (loaded) return; + try { + const records = z + .array(ProbeRecordSchema) + .parse(JSON.parse(await fs.readFile(filePath, "utf8"))); + for (const record of records) cache.set(record.workspaceId, record); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + log.error({ err: error, filePath }, "Failed to load provider probes"); + } + } + loaded = true; + } + + async function mutate( + operation: (draft: Map) => TResult, + ): Promise { + await load(); + const previous = queue; + let release!: () => void; + queue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + const draft = new Map(cache); + const result = operation(draft); + await writeJsonFileAtomic(filePath, [...draft.values()]); + cache.clear(); + for (const [workspaceId, record] of draft) cache.set(workspaceId, record); + return result; + } finally { + release(); + } + } + + const store: ProbeStore = { + records: { + async resolveRuntimeId(workspaceId) { + return (await store.get(workspaceId))?.runtimeId ?? null; + }, + async persistRuntimeId(workspaceId, runtimeId) { + const updated = await store.update(workspaceId, (record) => ({ + ...record, + runtimeId, + status: "ready", + lastUsedAt: new Date().toISOString(), + })); + if (!updated) throw new Error(`Provider probe record not found: ${workspaceId}`); + }, + async archiveWorkspaceRecord(workspaceId) { + await store.update(workspaceId, (record) => ({ ...record, status: "paused" })); + }, + async restoreWorkspaceRecord(workspaceId) { + await store.update(workspaceId, (record) => ({ ...record, status: "ready" })); + }, + async beginWorkspaceDeletion() {}, + async removeWorkspaceRecord(workspaceId) { + await store.remove(workspaceId); + }, + async listRuntimeRecords() { + await load(); + return [...cache.values()].map((record) => ({ + workspaceId: record.workspaceId, + runtimeId: record.runtimeId, + archived: record.status === "paused", + })); + }, + }, + async get(workspaceId) { + await load(); + return cache.get(workspaceId) ?? null; + }, + async put(record) { + const parsed = ProbeRecordSchema.parse(record); + await mutate((draft) => draft.set(parsed.workspaceId, parsed)); + }, + async update(workspaceId, updater) { + return mutate((draft) => { + const current = draft.get(workspaceId); + if (!current) return null; + const next = ProbeRecordSchema.parse(updater(current)); + draft.set(workspaceId, next); + return next; + }); + }, + async remove(workspaceId) { + await mutate((draft) => draft.delete(workspaceId)); + }, + }; + return store; +} diff --git a/packages/server/src/server/provider-probe/internal/service.ts b/packages/server/src/server/provider-probe/internal/service.ts new file mode 100644 index 0000000000..7fcea88c32 --- /dev/null +++ b/packages/server/src/server/provider-probe/internal/service.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; + +import type { ProviderProbeService, ProviderProbeServiceOptions } from "../index.js"; +import type { ProbeStore, ProviderProbeRecord } from "./probe-store.js"; + +interface ServiceOptions extends ProviderProbeServiceOptions { + store: ProbeStore; +} + +export function createService(options: ServiceOptions): ProviderProbeService { + const inFlight = new Map>(); + + return { + records: options.store.records, + ensure(input) { + const workspaceId = probeWorkspaceId(input.projectId, input.runtimeId); + const pending = inFlight.get(workspaceId); + if (pending) return pending; + const ensurePromise = materialize(input, workspaceId).finally(() => { + if (inFlight.get(workspaceId) === ensurePromise) inFlight.delete(workspaceId); + }); + inFlight.set(workspaceId, ensurePromise); + return ensurePromise; + }, + async resolveProviderWorkspace(workspaceId) { + const record = await options.store.get(workspaceId); + if (!record || record.status !== "ready") return null; + return options.bindWorkspaceProviderCapability(workspaceId, record.runtimeId); + }, + }; + + async function materialize( + input: { projectId: string; runtimeId: string }, + workspaceId: string, + ): Promise<{ workspaceId: string }> { + const project = await options.projects.get(input.projectId); + if (!project || project.archivedAt) throw new Error(`Project not found: ${input.projectId}`); + const timestamp = new Date().toISOString(); + const fingerprint = probeFingerprint({ + projectId: project.projectId, + rootPath: project.rootPath, + runtimeId: input.runtimeId, + runtimeConfiguration: options.runtimeConfiguration?.[input.runtimeId], + }); + const existing = await options.store.get(workspaceId); + if (existing?.status === "ready") { + const inspection = await options.runtime.inspect(workspaceId); + if (inspection.status === "ready") { + await touch(existing, timestamp); + return { workspaceId }; + } + if (inspection.status === "paused") { + await options.runtime.resume(workspaceId); + await options.store.update(workspaceId, (record) => ({ + ...record, + status: "ready", + lastUsedAt: timestamp, + })); + return { workspaceId }; + } + } + + const record: ProviderProbeRecord = existing ?? { + workspaceId, + projectId: project.projectId, + runtimeId: input.runtimeId, + fingerprint, + status: "materializing", + lastUsedAt: timestamp, + createdAt: timestamp, + }; + await options.store.put({ + ...record, + fingerprint, + status: "materializing", + lastUsedAt: timestamp, + }); + try { + await options.runtime.create({ + workspaceId, + runtimeId: input.runtimeId, + project: { + id: project.projectId, + source: { kind: "host-directory", path: project.rootPath }, + }, + placement: { kind: "existing" }, + purpose: "provider-probe", + setupFromPaseoConfig: false, + }); + return { workspaceId }; + } catch (error) { + await options.store.remove(workspaceId); + throw error; + } + } + + async function touch(record: ProviderProbeRecord, timestamp: string): Promise { + await options.store.put({ ...record, lastUsedAt: timestamp }); + } +} + +function probeWorkspaceId(projectId: string, runtimeId: string): string { + return `wksprobe_${hash({ projectId, runtimeId }).slice(0, 32)}`; +} + +function probeFingerprint(input: { + projectId: string; + rootPath: string; + runtimeId: string; + runtimeConfiguration: unknown; +}): string { + return hash(input); +} + +function hash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} diff --git a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts new file mode 100644 index 0000000000..4414e64b00 --- /dev/null +++ b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts @@ -0,0 +1,191 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { afterEach, describe, expect, test } from "vitest"; + +import type { ProviderWorkspace } from "../agent/providers/workspace/index.js"; +import type { + CreateWorkspaceInput, + WorkspaceRuntimeInspection, + WorkspaceRuntimePlacement, +} from "../workspace-runtime/index.js"; +import { createProviderProbeService } from "./index.js"; + +const roots: string[] = []; +const posixDescribe = describe.runIf(process.platform !== "win32"); + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +posixDescribe("provider probe service", () => { + test("converges concurrent ensures on one deterministic persisted runtime create", async () => { + const fixture = await createFixture(); + let releaseCreate!: () => void; + const createBarrier = new Promise((resolve) => { + releaseCreate = resolve; + }); + fixture.runtime.create = async (input) => { + fixture.creates.push(input); + await createBarrier; + fixture.inspection = { status: "ready", cwd: fixture.projectRoot }; + await fixture.service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: fixture.projectRoot, + }); + return placement(input, fixture.projectRoot); + }; + + const first = fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const second = fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + releaseCreate(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toEqual(secondResult); + expect(firstResult.workspaceId).toMatch(/^wksprobe_[a-f0-9]{32}$/u); + expect(fixture.creates).toHaveLength(1); + expect(fixture.creates[0]).toEqual({ + workspaceId: firstResult.workspaceId, + runtimeId: "fixture", + project: { + id: "project-1", + source: { kind: "host-directory", path: fixture.projectRoot }, + }, + placement: { kind: "existing" }, + purpose: "provider-probe", + setupFromPaseoConfig: false, + }); + await expect(fixture.service.records.resolveRuntimeId(firstResult.workspaceId)).resolves.toBe( + "fixture", + ); + }); + + test("reuses persisted ready identity and resolves only probe provider workspaces", async () => { + const first = await createFixture(); + const ensured = await first.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const reopened = createService(first); + + first.inspection = { status: "ready", cwd: first.projectRoot }; + await expect( + reopened.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toEqual(ensured); + expect(first.creates).toHaveLength(1); + await expect(reopened.resolveProviderWorkspace(ensured.workspaceId)).resolves.toBe( + first.providerWorkspace, + ); + await expect(reopened.resolveProviderWorkspace("ordinary-workspace")).resolves.toBeNull(); + }); + + test("removes a failed materialization so an explicit retry can create again", async () => { + const fixture = await createFixture(); + fixture.runtime.create = async (input) => { + fixture.creates.push(input); + if (fixture.creates.length === 1) throw new Error("fixture materialization failed"); + fixture.inspection = { status: "ready", cwd: fixture.projectRoot }; + await fixture.service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: fixture.projectRoot, + }); + return placement(input, fixture.projectRoot); + }; + + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).rejects.toThrow("fixture materialization failed"); + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toMatchObject({ workspaceId: expect.stringMatching(/^wksprobe_/u) }); + expect(fixture.creates).toHaveLength(2); + }); + + test("recreation replaces a persisted stale fingerprint with the freshly computed value", async () => { + const fixture = await createFixture(); + const ensured = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const recordsPath = path.join(fixture.root, "provider-probes.json"); + const [record] = JSON.parse(await readFile(recordsPath, "utf8")) as Array< + Record + >; + await writeFile( + recordsPath, + `${JSON.stringify([{ ...record, fingerprint: "stale", status: "materializing" }], null, 2)}\n`, + ); + fixture.inspection = { status: "missing" }; + const reopened = createService(fixture); + + await reopened.ensure({ projectId: "project-1", runtimeId: "fixture" }); + + const [recreated] = JSON.parse(await readFile(recordsPath, "utf8")) as Array<{ + workspaceId: string; + fingerprint: string; + }>; + expect(recreated.workspaceId).toBe(ensured.workspaceId); + expect(recreated.fingerprint).not.toBe("stale"); + }); +}); + +async function createFixture() { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-probe-")); + roots.push(root); + const projectRoot = path.join(root, "project"); + let inspection: WorkspaceRuntimeInspection = { status: "missing" }; + const creates: CreateWorkspaceInput[] = []; + const providerWorkspace = { cwd: "." } as ProviderWorkspace; + const runtime = { + create: async (input: CreateWorkspaceInput) => { + creates.push(input); + inspection = { status: "ready", cwd: projectRoot }; + await service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: projectRoot, + }); + return placement(input, projectRoot); + }, + inspect: async () => inspection, + resume: async () => {}, + }; + const fixture = { + root, + projectRoot, + creates, + providerWorkspace, + runtime, + get inspection() { + return inspection; + }, + set inspection(value: WorkspaceRuntimeInspection) { + inspection = value; + }, + service: undefined as unknown as ReturnType, + }; + const service = createService(fixture); + fixture.service = service; + return fixture; +} + +function createService(fixture: { + root: string; + projectRoot: string; + providerWorkspace: ProviderWorkspace; + runtime: { + create(input: CreateWorkspaceInput): Promise; + inspect(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + }; +}) { + return createProviderProbeService({ + filePath: path.join(fixture.root, "provider-probes.json"), + logger: pino({ enabled: false }), + projects: { + get: async (projectId: string) => + projectId === "project-1" + ? { projectId, rootPath: fixture.projectRoot, updatedAt: "2026-08-12T00:00:00.000Z" } + : null, + }, + runtime: fixture.runtime, + runtimeConfiguration: { fixture: { type: "command", options: { sentinel: true } } }, + bindWorkspaceProviderCapability: async () => fixture.providerWorkspace, + }); +} + +function placement(input: CreateWorkspaceInput, cwd: string): WorkspaceRuntimePlacement { + return { workspaceId: input.workspaceId, runtimeId: input.runtimeId, cwd }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1908746e06..5eebdbe386 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -147,6 +147,7 @@ import { type WorkspaceRegistry, } from "./workspace-registry.js"; import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { ProviderProbeService } from "./provider-probe/index.js"; import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { @@ -454,6 +455,7 @@ export interface SessionOptions { projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; workspaceRuntime?: WorkspaceRuntimeService; + providerProbe?: ProviderProbeService; filesystem?: SessionFileSystem; chatService: FileBackedChatService; scheduleService: ScheduleService; @@ -626,6 +628,7 @@ export class Session { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; + private readonly providerProbe: ProviderProbeService | undefined; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; private readonly workspaceGitService: WorkspaceGitService; @@ -708,6 +711,7 @@ export class Session { projectRegistry, workspaceRegistry, workspaceRuntime, + providerProbe, filesystem, chatService, scheduleService, @@ -777,6 +781,7 @@ export class Session { this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; this.workspaceRuntime = workspaceRuntime; + this.providerProbe = providerProbe; this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService; @@ -2213,6 +2218,8 @@ export class Session { switch (msg.type) { case "workspace.runtime.list.request": return this.handleWorkspaceRuntimeListRequest(msg.requestId); + case "workspace.runtime.ensure_probe.request": + return this.handleWorkspaceRuntimeEnsureProbeRequest(msg); default: return undefined; } @@ -5442,6 +5449,34 @@ export class Session { }); } + private async handleWorkspaceRuntimeEnsureProbeRequest( + request: Extract, + ): Promise { + try { + if (!this.providerProbe) throw new Error("Provider probe service was not composed"); + const ensured = await this.providerProbe.ensure(request); + this.emit({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: request.requestId, + workspaceId: ensured.workspaceId, + status: "ready", + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: request.requestId, + workspaceId: null, + status: "error", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleWorkspaceCreateLocal( request: Extract, ): Promise { @@ -5494,13 +5529,18 @@ export class Session { source: { kind: "host-directory", path: cwd }, }, placement: { kind: "existing" }, + setupFromPaseoConfig: true, }); } catch (error) { await this.workspaceRegistry.remove(workspace.workspaceId); throw error; } - await this.syncWorkspaceGitObserverForWorkspace(workspace); - const descriptor = await this.describeWorkspaceRecord(workspace); + const materializedWorkspace = await this.workspaceRegistry.get(workspace.workspaceId); + if (!materializedWorkspace) { + throw new Error(`Workspace not found after runtime creation: ${workspace.workspaceId}`); + } + await this.syncWorkspaceGitObserverForWorkspace(materializedWorkspace); + const descriptor = await this.describeWorkspaceRecord(materializedWorkspace); this.emit({ type: "workspace.create.response", payload: { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ce5634bfd4..0278e0316f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -47,6 +47,7 @@ import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runt import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { ProviderProbeService } from "./provider-probe/index.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { PushTokenStore } from "./push/token-store.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; @@ -559,6 +560,7 @@ export class VoiceAssistantWebSocketServer { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; + private readonly providerProbe: ProviderProbeService | undefined; private readonly chatService: FileBackedChatService; private readonly loopService: LoopService; private readonly scheduleService: ScheduleService; @@ -657,6 +659,7 @@ export class VoiceAssistantWebSocketServer { hubRelationships?: HubRelationshipManagement | null, workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), workspaceRuntime?: WorkspaceRuntimeService, + providerProbe?: ProviderProbeService, ) { this.logger = logger.child({ module: "websocket-server" }); this.workspaceSetupRuntime = workspaceSetupRuntime; @@ -675,6 +678,7 @@ export class VoiceAssistantWebSocketServer { this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceRuntime = workspaceRuntime; + this.providerProbe = providerProbe; const requiredServices = requireWebSocketServices({ chatService, loopService, @@ -1387,6 +1391,7 @@ export class VoiceAssistantWebSocketServer { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceRuntime: this.workspaceRuntime, + providerProbe: this.providerProbe, chatService: this.chatService, loopService: this.loopService, scheduleService: this.scheduleService, diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts index e1d7343770..49114ab9be 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -80,7 +80,15 @@ export function createCommandRuntime( async create(input) { const response = await lifecycle("create", input.workspaceId, input); if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`); - return commandReady(response.state, response.placement); + if (response.materializedFreshContent === undefined) { + throw new Error( + `Workspace runtime ${runtimeId} create response is missing materializedFreshContent`, + ); + } + return { + ...commandReady(response.state, response.placement), + materializedFreshContent: response.materializedFreshContent, + }; }, async inspect(workspaceId): Promise { const response = await lifecycle("inspect", workspaceId); diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 6b4e943a49..75b58d0b1f 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -23,6 +23,7 @@ export interface WorkspaceDriverCreateInput { workspaceId: WorkspaceId; project: { projectId: string; source: WorkspaceProjectSource }; placement: WorkspacePlacementIntent; + purpose?: "provider-probe"; markFirstAgentBranchAutoName?: boolean; seedPaseoConfigFrom?: string; } @@ -43,6 +44,11 @@ export interface WorkspaceDriverReady { placement: WorkspacePublicPlacement; } +export interface WorkspaceDriverCreation extends WorkspaceDriverReady { + /** This create materialized fresh workspace content/resources, so user setup may run. */ + materializedFreshContent: boolean; +} + export type WorkspaceDriverInspection = | { status: "missing" } | ({ status: "paused" } & WorkspaceDriverReady) @@ -97,7 +103,7 @@ export interface WorkspaceRuntimeDriver { readonly reconciliationDomainId?: string; /** Runtime-local command for Paseo's compatible workspace helper. */ readonly workspaceHelperCommand: readonly [string, ...string[]]; - create(input: WorkspaceDriverCreateInput): Promise; + create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; observeGit?( diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 591abe089b..836e949dc0 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -73,6 +73,7 @@ export interface CreateWorkspaceInput { runtimeId: string; project: { id: string; source: WorkspaceSource }; placement: WorkspacePlacement; + purpose?: "provider-probe"; setup?: readonly WorkspaceSetupCommand[]; setupFromPaseoConfig?: boolean; markFirstAgentBranchAutoName?: boolean; diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts index 368a0ec9a0..5ff41c76ec 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -58,7 +58,9 @@ export function createLocalRuntime( workspaceHelperCommand: hostWorkspaceHelperCommand, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); - if (existing.status === "ready" || existing.status === "paused") return existing; + if (existing.status === "ready" || existing.status === "paused") { + return { ...existing, materializedFreshContent: false }; + } if (input.project.source.kind !== "host-directory" || input.placement.kind !== "existing") { throw new Error("The local runtime adopts an existing host directory"); } @@ -73,7 +75,11 @@ export function createLocalRuntime( compatibilityCwd, }; await states.write(state); - return { state: publicState(state), placement: hostPlacement(compatibilityCwd) }; + return { + state: publicState(state), + placement: hostPlacement(compatibilityCwd), + materializedFreshContent: false, + }; }, inspect, async spawn(input: WorkspaceDriverSpawnInput) { diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 73aef4e4eb..ddccc98e97 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -192,7 +192,7 @@ export function createService( await helper.close(); } } - if (ownsNewResource) { + if (ready.materializedFreshContent && input.purpose !== "provider-probe") { const setupCommands = input.setupFromPaseoConfig ? await readConfiguredSetupCommands(input.workspaceId) : (input.setup ?? []); @@ -741,6 +741,7 @@ function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreate source: input.project.source, }, placement: input.placement, + ...(input.purpose ? { purpose: input.purpose } : {}), ...(input.markFirstAgentBranchAutoName ? { markFirstAgentBranchAutoName: true } : {}), ...(input.seedPaseoConfigFrom ? { seedPaseoConfigFrom: input.seedPaseoConfigFrom } : {}), }; diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index b05a1eec66..1ba5ed53fe 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -27,6 +27,7 @@ interface WorktreeRuntimeState { worktreeRoot: string; lifecycle: "ready" | "paused"; lifecycleEnvironment: Readonly>; + ownsWorktree?: boolean; } export function createWorktreeRuntime(options: { @@ -68,10 +69,35 @@ export function createWorktreeRuntime(options: { workspaceHelperCommand: hostWorkspaceHelperCommand, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); - if (existing.status === "ready" || existing.status === "paused") return existing; + if (existing.status === "ready" || existing.status === "paused") { + return { ...existing, materializedFreshContent: false }; + } if (input.project.source.kind !== "host-directory") { throw new Error("The worktree runtime requires a host Git checkout"); } + if (input.purpose === "provider-probe") { + if (input.placement.kind !== "existing") { + throw new Error("A worktree provider probe adopts an existing Git checkout"); + } + const sourceRoot = path.resolve(input.project.source.path); + const root = await resolveRuntimeCwd(sourceRoot, input.placement.relativeCwd); + if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`); + const state: WorktreeRuntimeState = { + workspaceId: input.workspaceId, + root, + worktreeRoot: sourceRoot, + sourceRoot, + lifecycle: "ready", + lifecycleEnvironment: {}, + ownsWorktree: false, + }; + await states.write(state); + return { + state: publicState(state), + placement: hostPlacement(root), + materializedFreshContent: false, + }; + } if (input.placement.kind === "existing") { throw new Error("The worktree runtime creates and owns its worktree"); } @@ -127,9 +153,14 @@ export function createWorktreeRuntime(options: { PASEO_WORKTREE_PATH: ".", PASEO_BRANCH_NAME: worktree.branchName, }, + ownsWorktree: true, }; await states.write(state); - return { state: publicState(state), placement: hostPlacement(root) }; + return { + state: publicState(state), + placement: hostPlacement(root), + materializedFreshContent: true, + }; } catch (error) { await deletePaseoWorktree({ cwd: sourceRoot, @@ -165,13 +196,15 @@ export function createWorktreeRuntime(options: { async destroy(workspaceId) { const state = await states.read(workspaceId); if (!state) return; - await deletePaseoWorktree({ - cwd: state.sourceRoot, - worktreePath: state.worktreeRoot, - teardownCwds: [], - paseoHome: options.paseoHome, - worktreesBaseRoot: options.worktreesRoot, - }); + if (state.ownsWorktree !== false) { + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + } await states.remove(workspaceId); }, }; @@ -198,7 +231,8 @@ function isWorktreeRuntimeState( typeof state.worktreeRoot === "string" && (state.lifecycle === "ready" || state.lifecycle === "paused") && !!state.lifecycleEnvironment && - typeof state.lifecycleEnvironment === "object" + typeof state.lifecycleEnvironment === "object" && + (state.ownsWorktree === undefined || typeof state.ownsWorktree === "boolean") ); } diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index 93c9786e3b..634eb84de3 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -69,6 +69,29 @@ test("a trusted registered command is selected and receives secret launch data o await fixture.service.destroy(fixture.workspaceId); }); +test("the fixture executable receives provider probe purpose through the strict lifecycle contract", async () => { + const fixture = await createFixture("provider-probe-purpose"); + await fixture.service.create({ + ...fixture.createInput, + purpose: "provider-probe", + setupFromPaseoConfig: false, + }); + const [stateFile] = await readdir(fixture.stateDirectory); + const state = JSON.parse( + await readFile(path.join(fixture.stateDirectory, stateFile), "utf8"), + ) as { createInput: unknown }; + expect(state.createInput).toEqual({ + workspaceId: fixture.workspaceId, + project: { + projectId: fixture.createInput.project.id, + source: fixture.createInput.project.source, + }, + placement: fixture.createInput.placement, + purpose: "provider-probe", + }); + await fixture.service.destroy(fixture.workspaceId); +}); + test("equal display cwd values never share external runtime execution, files, Git, or caches", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-command-runtime-equal-display-")); cleanupRoots.push(root); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index ccb3bc2c4b..b8d9621998 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -96,7 +96,53 @@ test("omits the built-in Docker runtime when its registration is disabled", asyn ).rejects.toThrow("Workspace runtime is not registered: docker"); }); +posixDescribe("setup eligibility", () => { + test("adopting an existing Local directory never executes its paseo.json setup", async () => { + const fixture = await createFixture("local"); + const marker = path.join(fixture.repo, "local-adoption-setup-ran.txt"); + await writeFile( + path.join(fixture.repo, "paseo.json"), + JSON.stringify({ + worktree: { setup: ["printf setup > local-adoption-setup-ran.txt"] }, + }), + ); + + await expect( + fixture.service.create({ + ...fixture.createInput, + setupFromPaseoConfig: true, + }), + ).resolves.toMatchObject({ runtimeId: "local", cwd: fixture.repo }); + + expect(existsSync(marker)).toBe(false); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => { + test("creates a provider probe by adopting the project root without setup", async () => { + const fixture = await createFixture(runtimeId); + const setupMarker = path.join(fixture.repo, "probe-setup-ran.txt"); + + const created = await fixture.service.create({ + ...fixture.createInput, + placement: { kind: "existing" }, + purpose: "provider-probe", + setup: [{ argv: ["/bin/sh", "-c", "printf setup > probe-setup-ran.txt"], env: {} }], + setupFromPaseoConfig: false, + }); + + expect(await realpath(created.cwd)).toBe(await realpath(fixture.repo)); + expect(existsSync(setupMarker)).toBe(false); + if (runtimeId === "worktree") { + await expect( + Promise.all(listLinkedWorktrees(fixture.repo).map((cwd) => realpath(cwd))), + ).resolves.toEqual([await realpath(fixture.repo)]); + } + await fixture.service.destroy(fixture.workspaceId); + expect(existsSync(fixture.repo)).toBe(true); + }); + test("bound runtime exposes only process, file, and command-resolution primitives", async () => { const fixture = await createFixture(runtimeId); await fixture.service.create(fixture.createInput); @@ -306,7 +352,7 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) ).rejects.toThrow(`Workspace runtime is not selected: ${fixture.workspaceId}`); }); - test("repeated create does not rerun setup or change paused state", async () => { + test("repeated create preserves setup eligibility and paused state", async () => { const fixture = await createFixture(runtimeId); const setupMarker = "idempotent-setup.txt"; const input = { @@ -321,7 +367,11 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) await fixture.service.create(input); await fixture.service.pause(fixture.workspaceId); await fixture.service.create(input); - expect(await readFile(path.join(fixture.runtimeRoot(), setupMarker), "utf8")).toBe("setup\n"); + if (runtimeId === "local") { + expect(existsSync(path.join(fixture.runtimeRoot(), setupMarker))).toBe(false); + } else { + expect(await readFile(path.join(fixture.runtimeRoot(), setupMarker), "utf8")).toBe("setup\n"); + } await expect( fixture.service.run({ workspaceId: fixture.workspaceId, diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md index 010e09d888..9f43b14923 100644 --- a/packages/workspace-runtime-contract/README.md +++ b/packages/workspace-runtime-contract/README.md @@ -20,7 +20,7 @@ reconcile `describe` writes `CommandRuntimeDescribeResponse` JSON to stdout. Lifecycle commands read one `CommandRuntimeLifecycleRequest` JSON value from stdin and write one `CommandRuntimeLifecycleResponse` JSON value to stdout. `create` and `resume` return `state`; `inspect` returns `inspection`; `pause`, `destroy`, and `reconcile` return `ok`. Diagnostics go to stderr. A non-zero wrapper exit means the operation failed. -Paseo sends options from trusted daemon configuration. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys—including private authority—fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. Any public field change requires a new protocol version. +Paseo sends options from trusted daemon configuration. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys—including private authority—fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. The optional create `purpose: "provider-probe"` field and create-only response field `materializedFreshContent` were added before external v1 adoption. Runtimes that optimize probe placement may use `purpose`; omission retains ordinary workspace creation. Every `create` response must set `materializedFreshContent`: `true` only when that call created fresh workspace content/resources on which repo setup is permitted, and `false` when it adopted or reused existing content. `resume` state responses omit it. ## Exec file descriptors diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json index 32c97e2000..d0c35abeba 100644 --- a/packages/workspace-runtime-contract/examples/v1.json +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -12,7 +12,8 @@ "projectId": "project-01", "source": { "kind": "host-directory", "path": "/source/project" } }, - "placement": { "kind": "existing" } + "placement": { "kind": "existing" }, + "purpose": "provider-probe" }, "options": {} }, @@ -23,7 +24,8 @@ "workspaceId": "workspace-01", "lifecycle": "ready" }, - "placement": { "cwd": "/workspace" } + "placement": { "cwd": "/workspace" }, + "materializedFreshContent": true }, "pipeSpawnControl": { "type": "spawn", @@ -75,8 +77,8 @@ }, "bytes": { "describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\n", - "lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"host-directory\",\"path\":\"/source/project\"}},\"placement\":{\"kind\":\"existing\"}},\"options\":{}}\n", - "lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"}}\n", + "lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"host-directory\",\"path\":\"/source/project\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"provider-probe\"},\"options\":{}}\n", + "lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"},\"materializedFreshContent\":true}\n", "pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n", "ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n", "resizeControl": "{\"type\":\"resize\",\"protocolVersion\":1,\"id\":1,\"rows\":40,\"cols\":120}\n", diff --git a/packages/workspace-runtime-contract/src/index.ts b/packages/workspace-runtime-contract/src/index.ts index 61464dd685..1804341f81 100644 --- a/packages/workspace-runtime-contract/src/index.ts +++ b/packages/workspace-runtime-contract/src/index.ts @@ -83,6 +83,7 @@ export const CommandRuntimeCreateInputSchema = z .object({ projectId: z.string(), source: CommandRuntimeProjectSourceSchema }) .strict(), placement: CommandRuntimePlacementIntentSchema, + purpose: z.literal("provider-probe").optional(), markFirstAgentBranchAutoName: z.boolean().optional(), seedPaseoConfigFrom: z.string().optional(), }) @@ -138,6 +139,7 @@ export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type" protocolVersion: ProtocolVersionSchema, state: CommandRuntimeStateSchema, placement: CommandRuntimePlacementSchema, + materializedFreshContent: z.boolean().optional(), }) .strict(), z diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs index b59dbdc4f9..d2d6e1e871 100644 --- a/packages/workspace-runtime-contract/test/contract.test.mjs +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -54,10 +54,12 @@ test("documented lifecycle, fd3, control, and fd4 examples are exact newline-ter }); test("lifecycle uses one exact stdin request and one exact stdout response", async () => { + assert.equal(examples.lifecycleRequest.input.purpose, "provider-probe"); assert.deepEqual(examples.lifecycleResponse.state, { workspaceId: "workspace-01", lifecycle: "ready", }); + assert.equal(examples.lifecycleResponse.materializedFreshContent, true); assert.doesNotMatch(examples.bytes.lifecycleResponse, /root|revision|executionDomainId/); const child = spawn( process.execPath,