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,