mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
feat(workspaces): probe providers in selected runtimes
Pre-creation provider truth now comes from an invisible real runtime workspace and reuses the normal provider snapshot pipeline. Probe records are owned separately, keeping them structurally absent from ordinary workspace projections. Setup eligibility follows fresh content materialization rather than lifecycle resource ownership. Adopted Local directories never execute repository setup, while fresh Worktree, Docker, and external content may; provider probes never execute repository setup.
This commit is contained in:
@@ -145,12 +145,34 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
|
||||
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<void>
|
||||
"../../../../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`,
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const SETUP_MARKER = "runtime-user-setup-ran.txt";
|
||||
|
||||
export async function seedGitProjectForRuntime(
|
||||
options?: Parameters<typeof createTempGitRepo>[1],
|
||||
): Promise<SeededRuntimeProject> {
|
||||
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<Array<{ workspaceId: string }>> {
|
||||
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<boolean> {
|
||||
return access(path.join(root, SETUP_MARKER)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectNoProbeInWorkspaceProjection(
|
||||
page: Page,
|
||||
project: SeededRuntimeProject,
|
||||
): Promise<void> {
|
||||
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<SeededRuntimeProject> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const frameListeners = new Map<WebSocket, (event: { payload: string | Buffer }) => 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,
|
||||
|
||||
@@ -537,7 +537,7 @@ function ControlledAgentControls({
|
||||
}
|
||||
}, [updateDensityForWidth]);
|
||||
|
||||
const modelDisabled = disabled;
|
||||
const modelDisabled = disabled || isModelLoading;
|
||||
|
||||
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
|
||||
() => toComboboxOptions(providerOptions),
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1043,6 +1043,7 @@ export const ar: TranslationResources = {
|
||||
title: "مساحة عمل جديدة",
|
||||
create: "يخلق",
|
||||
runtime: { local: "محلي", worktree: "شجرة العمل", docker: "Docker", label: "بيئة التشغيل" },
|
||||
runtimeProbe: { preparing: "جارٍ تجهيز البيئة" },
|
||||
isolation: {
|
||||
local: "محلي",
|
||||
worktree: "شجرة عمل جديدة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1054,6 +1054,7 @@ export const ja: TranslationResources = {
|
||||
title: "新しいワークスペース",
|
||||
create: "作成",
|
||||
runtime: { local: "ローカル", worktree: "ワークツリー", docker: "Docker", label: "ランタイム" },
|
||||
runtimeProbe: { preparing: "環境を準備しています" },
|
||||
isolation: {
|
||||
local: "ローカル",
|
||||
worktree: "新しいワークツリー",
|
||||
|
||||
@@ -1050,6 +1050,7 @@ export const ko: TranslationResources = {
|
||||
title: "새 워크스페이스",
|
||||
create: "생성",
|
||||
runtime: { local: "로컬", worktree: "워크트리", docker: "Docker", label: "런타임" },
|
||||
runtimeProbe: { preparing: "환경 준비 중" },
|
||||
isolation: {
|
||||
local: "로컬",
|
||||
worktree: "새 워크트리",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1065,6 +1065,7 @@ export const ru: TranslationResources = {
|
||||
title: "Новое рабочее пространство",
|
||||
create: "Создавать",
|
||||
runtime: { local: "Локально", worktree: "Worktree", docker: "Docker", label: "Среда" },
|
||||
runtimeProbe: { preparing: "Подготовка среды" },
|
||||
isolation: {
|
||||
local: "Локально",
|
||||
worktree: "Новый worktree",
|
||||
|
||||
@@ -1032,6 +1032,7 @@ export const zhCN: TranslationResources = {
|
||||
title: "新建 workspace",
|
||||
create: "创建",
|
||||
runtime: { local: "本地", worktree: "Worktree", docker: "Docker", label: "运行环境" },
|
||||
runtimeProbe: { preparing: "正在准备环境" },
|
||||
isolation: {
|
||||
local: "本地",
|
||||
worktree: "新建 worktree",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -136,7 +136,7 @@ describe("combined model selector data", () => {
|
||||
provider: "error-provider",
|
||||
status: "error",
|
||||
error: "boom",
|
||||
models: [],
|
||||
models: [codexModel],
|
||||
}),
|
||||
snapshotEntry({
|
||||
provider: "unavailable-provider",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ? <Text style={styles.errorText}>{error}</Text> : 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 (
|
||||
<Text accessibilityLiveRegion="polite" style={styles.probeStatusText}>
|
||||
{t("newWorkspace.runtimeProbe.preparing")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.probeErrorRow}>
|
||||
<Text accessibilityRole="alert" style={styles.errorText}>
|
||||
{probe.error}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onRetry}>
|
||||
<Text style={styles.probeRetryText}>{t("common.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof useAgentInputDraft>["composerState"];
|
||||
@@ -988,13 +1029,28 @@ function buildComposerConfig(input: {
|
||||
workspaceDirectory: string | null;
|
||||
sourceDirectory: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||
providerSnapshotTarget: ReturnType<typeof resolveWorkspaceProviderProbeTarget>;
|
||||
}): Parameters<typeof useAgentInputDraft>[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({
|
||||
<WorkspaceCreationError
|
||||
error={resolveWorkspaceCreationError(errorMessage, catalogError)}
|
||||
/>
|
||||
<WorkspaceProviderProbeFeedback probe={providerProbe} onRetry={providerProbe.retry} />
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
@@ -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],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<WorkspaceRuntimeEnsureProbePayload> {
|
||||
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<OpenProjectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PrivateRuntimeState> {
|
||||
): 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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof WorkspaceRuntimeCatalo
|
||||
export type WorkspaceRuntimeListPayload = z.infer<
|
||||
typeof WorkspaceRuntimeListResponseSchema
|
||||
>["payload"];
|
||||
export type WorkspaceRuntimeEnsureProbePayload = z.infer<
|
||||
typeof WorkspaceRuntimeEnsureProbeResponseSchema
|
||||
>["payload"];
|
||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||
export type ProjectRemoveResponsePayload = z.infer<typeof ProjectRemoveResponsePayloadSchema>;
|
||||
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof bindProviderWorkspace> }
|
||||
>();
|
||||
// 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<string | null> {
|
||||
const workspace = await workspaceRegistry?.get(workspaceId);
|
||||
return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null;
|
||||
}
|
||||
|
||||
async function persistRegistryRuntimeId(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
placement: { cwd: string; hostVisiblePath?: string },
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ProviderWorkspace | null>;
|
||||
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<WorkspaceRuntimePlacement>;
|
||||
inspect(workspaceId: string): Promise<WorkspaceRuntimeInspection>;
|
||||
resume(workspaceId: string): Promise<void>;
|
||||
};
|
||||
runtimeConfiguration?: Readonly<Record<string, unknown>>;
|
||||
bindWorkspaceProviderCapability(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<ProviderWorkspace>;
|
||||
}
|
||||
|
||||
export function createProviderProbeService(
|
||||
options: ProviderProbeServiceOptions,
|
||||
): ProviderProbeService {
|
||||
const store = createProbeStore(options.filePath, options.logger);
|
||||
return createService({ ...options, store });
|
||||
}
|
||||
@@ -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<typeof ProbeRecordSchema>;
|
||||
|
||||
export interface ProbeStore {
|
||||
readonly records: WorkspaceRuntimeRecordStore;
|
||||
get(workspaceId: string): Promise<ProviderProbeRecord | null>;
|
||||
put(record: ProviderProbeRecord): Promise<void>;
|
||||
update(
|
||||
workspaceId: string,
|
||||
updater: (record: ProviderProbeRecord) => ProviderProbeRecord,
|
||||
): Promise<ProviderProbeRecord | null>;
|
||||
remove(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createProbeStore(filePath: string, logger: pino.Logger): ProbeStore {
|
||||
let loaded = false;
|
||||
let queue = Promise.resolve();
|
||||
const cache = new Map<string, ProviderProbeRecord>();
|
||||
const log = logger.child({ module: "provider-probe-store" });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
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<TResult>(
|
||||
operation: (draft: Map<string, ProviderProbeRecord>) => TResult,
|
||||
): Promise<TResult> {
|
||||
await load();
|
||||
const previous = queue;
|
||||
let release!: () => void;
|
||||
queue = new Promise<void>((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;
|
||||
}
|
||||
@@ -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<string, Promise<{ workspaceId: string }>>();
|
||||
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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<void>((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<string, unknown>
|
||||
>;
|
||||
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<typeof createProviderProbeService>,
|
||||
};
|
||||
const service = createService(fixture);
|
||||
fixture.service = service;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function createService(fixture: {
|
||||
root: string;
|
||||
projectRoot: string;
|
||||
providerWorkspace: ProviderWorkspace;
|
||||
runtime: {
|
||||
create(input: CreateWorkspaceInput): Promise<WorkspaceRuntimePlacement>;
|
||||
inspect(workspaceId: string): Promise<WorkspaceRuntimeInspection>;
|
||||
resume(workspaceId: string): Promise<void>;
|
||||
};
|
||||
}) {
|
||||
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 };
|
||||
}
|
||||
@@ -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<SessionInboundMessage, { type: "workspace.runtime.ensure_probe.request" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WorkspaceDriverInspection> {
|
||||
const response = await lifecycle("inspect", workspaceId);
|
||||
|
||||
@@ -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<WorkspaceDriverReady>;
|
||||
create(input: WorkspaceDriverCreateInput): Promise<WorkspaceDriverCreation>;
|
||||
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
|
||||
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspaceDriverProcess>;
|
||||
observeGit?(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ interface WorktreeRuntimeState {
|
||||
worktreeRoot: string;
|
||||
lifecycle: "ready" | "paused";
|
||||
lifecycleEnvironment: Readonly<Record<string, string>>;
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user