mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
feat(workspaces): introduce runtime execution boundary
Establish the pipe-mode vertical slice before migrating terminals, files, Git, and providers. Runtime selection is immutable per workspace, and external runtimes remain unavailable to path-based daemon surfaces until those capabilities move behind the boundary.
This commit is contained in:
@@ -16,6 +16,7 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/docker-workspace-runtime/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
@@ -155,6 +156,29 @@ jobs:
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
|
||||
workspace-runtime-test:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Test workspace runtime contract
|
||||
run: npm run test:workspace-runtime:docker --workspace=@getpaseo/server
|
||||
|
||||
publish:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish == 'true'
|
||||
|
||||
Generated
+17
-1
@@ -19,7 +19,8 @@
|
||||
"packages/relay",
|
||||
"packages/website",
|
||||
"packages/desktop",
|
||||
"packages/cli"
|
||||
"packages/cli",
|
||||
"packages/docker-workspace-runtime"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
@@ -6234,6 +6235,10 @@
|
||||
"resolved": "packages/desktop",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/docker-workspace-runtime": {
|
||||
"resolved": "packages/docker-workspace-runtime",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/expo-two-way-audio": {
|
||||
"resolved": "packages/expo-two-way-audio",
|
||||
"link": true
|
||||
@@ -36644,6 +36649,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/docker-workspace-runtime": {
|
||||
"name": "@getpaseo/docker-workspace-runtime",
|
||||
"version": "0.3.1",
|
||||
"bin": {
|
||||
"paseo-docker-workspace-runtime": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.1",
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"packages/relay",
|
||||
"packages/website",
|
||||
"packages/desktop",
|
||||
"packages/cli"
|
||||
"packages/cli",
|
||||
"packages/docker-workspace-runtime"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:server",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY src/workload.mjs /opt/paseo-workspace-runtime/workload.mjs
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@getpaseo/docker-workspace-runtime",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"paseo-docker-workspace-runtime": "./dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsgo -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
interface RuntimeState {
|
||||
workspaceId: string;
|
||||
root: string;
|
||||
revision: string;
|
||||
executionDomainId: string;
|
||||
lifecycle: "ready" | "paused";
|
||||
}
|
||||
|
||||
interface LifecycleRequest {
|
||||
protocolVersion: 1;
|
||||
input?: {
|
||||
workspaceId: string;
|
||||
project: {
|
||||
source:
|
||||
| { kind: "host-directory"; path: string }
|
||||
| { kind: "git"; url: string; revision: string; subdirectory?: string };
|
||||
};
|
||||
placement: { relativeCwd?: string };
|
||||
};
|
||||
options: { image?: unknown };
|
||||
}
|
||||
|
||||
interface ExecRequest {
|
||||
protocolVersion: 1;
|
||||
argv: [string, ...string[]];
|
||||
cwd?: string;
|
||||
env: Record<string, string>;
|
||||
purpose:
|
||||
| { kind: "agent"; agentId: string; provider: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "git" }
|
||||
| { kind: "provider-probe"; provider: string }
|
||||
| { kind: "workspace-helper" }
|
||||
| { kind: "workspace-script"; script: string }
|
||||
| { kind: "setup" }
|
||||
| { kind: "archive" };
|
||||
options: { image?: unknown };
|
||||
execId: string;
|
||||
}
|
||||
|
||||
const [operation] = process.argv.slice(2);
|
||||
const requestedWorkspaceId = argument("--workspace-id");
|
||||
|
||||
try {
|
||||
if (operation === "describe") {
|
||||
writeJson({ protocolVersion: 1, modes: ["pipes"] });
|
||||
} else if (!requestedWorkspaceId) {
|
||||
throw new Error("--workspace-id is required");
|
||||
} else if (operation === "exec") {
|
||||
await execute(requestedWorkspaceId);
|
||||
} else if (operation === "signal") {
|
||||
await readStdin();
|
||||
await signalExec(
|
||||
requestedWorkspaceId,
|
||||
requireArgument("--exec-id"),
|
||||
requireSignal(requireArgument("--signal")),
|
||||
);
|
||||
} else {
|
||||
const request = JSON.parse(await readStdin()) as LifecycleRequest;
|
||||
assertProtocol(request.protocolVersion);
|
||||
switch (operation) {
|
||||
case "create":
|
||||
writeJson({
|
||||
protocolVersion: 1,
|
||||
type: "state",
|
||||
state: await create(requestedWorkspaceId, request),
|
||||
});
|
||||
break;
|
||||
case "inspect":
|
||||
writeJson({
|
||||
protocolVersion: 1,
|
||||
type: "inspection",
|
||||
inspection: await inspect(requestedWorkspaceId),
|
||||
});
|
||||
break;
|
||||
case "pause":
|
||||
await pause(requestedWorkspaceId);
|
||||
writeJson({ protocolVersion: 1, type: "ok" });
|
||||
break;
|
||||
case "resume":
|
||||
writeJson({
|
||||
protocolVersion: 1,
|
||||
type: "state",
|
||||
state: await resume(requestedWorkspaceId),
|
||||
});
|
||||
break;
|
||||
case "destroy":
|
||||
await destroy(requestedWorkspaceId);
|
||||
writeJson({ protocolVersion: 1, type: "ok" });
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function create(workspaceId: string, request: LifecycleRequest): Promise<RuntimeState> {
|
||||
const existing = await inspect(workspaceId);
|
||||
if (existing.status === "ready" || existing.status === "paused") return existing.state;
|
||||
const input = request.input;
|
||||
if (!input) throw new Error("create input is required");
|
||||
const image = requireImage(request.options);
|
||||
const relativeRoot =
|
||||
input.project.source.kind === "git"
|
||||
? input.project.source.subdirectory
|
||||
: input.placement.relativeCwd;
|
||||
const requestedRoot = resolveWorkspaceCwd("/workspace", relativeRoot);
|
||||
const names = resourceNames(workspaceId);
|
||||
const existingVolume = await inspectVolume(names.volume);
|
||||
if (existingVolume) {
|
||||
if (existingVolume.owner !== workspaceId) {
|
||||
throw new Error(`Docker volume ownership mismatch: ${names.volume}`);
|
||||
}
|
||||
throw new Error(`Docker workspace has an incomplete owned volume: ${workspaceId}`);
|
||||
}
|
||||
await docker([
|
||||
"volume",
|
||||
"create",
|
||||
"--label",
|
||||
"sh.paseo.runtime=workspace",
|
||||
"--label",
|
||||
`sh.paseo.workspace-id=${workspaceId}`,
|
||||
names.volume,
|
||||
]);
|
||||
|
||||
try {
|
||||
const source = input.project.source;
|
||||
const cloneSource = source.kind === "host-directory" ? "/source" : source.url;
|
||||
const revision = source.kind === "git" ? source.revision : "";
|
||||
const mounts = ["-v", `${names.volume}:/workspace`];
|
||||
if (source.kind === "host-directory") {
|
||||
mounts.push("-v", `${await realpath(source.path)}:/source:ro`);
|
||||
}
|
||||
await docker([
|
||||
"run",
|
||||
"--rm",
|
||||
...mounts,
|
||||
image,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'git clone --no-hardlinks "$1" /workspace && if [ -n "$2" ]; then git -C /workspace checkout --detach "$2"; fi',
|
||||
"paseo-materialize",
|
||||
cloneSource,
|
||||
revision,
|
||||
]);
|
||||
const commit = (
|
||||
await docker([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
`${names.volume}:/workspace`,
|
||||
image,
|
||||
"git",
|
||||
"-C",
|
||||
"/workspace",
|
||||
"rev-parse",
|
||||
"HEAD",
|
||||
])
|
||||
).trim();
|
||||
const root = (
|
||||
await docker([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
`${names.volume}:/workspace`,
|
||||
image,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'cd "$1" && pwd -P',
|
||||
"paseo-resolve-root",
|
||||
requestedRoot,
|
||||
])
|
||||
).trim();
|
||||
assertContainedPath("/workspace", root);
|
||||
await docker([
|
||||
"create",
|
||||
"--init",
|
||||
"--name",
|
||||
names.container,
|
||||
"--label",
|
||||
"sh.paseo.runtime=workspace",
|
||||
"--label",
|
||||
`sh.paseo.workspace-id=${workspaceId}`,
|
||||
"--label",
|
||||
`sh.paseo.workspace-root=${root}`,
|
||||
"--label",
|
||||
`sh.paseo.workspace-revision=${commit}`,
|
||||
"-v",
|
||||
`${names.volume}:/workspace`,
|
||||
image,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"exec tail -f /dev/null",
|
||||
]);
|
||||
await docker(["start", names.container]);
|
||||
await waitUntilReady(names.container);
|
||||
return stateFor(workspaceId, root, commit, names.container);
|
||||
} catch (error) {
|
||||
await docker(["rm", "-f", names.container], true);
|
||||
await docker(["volume", "rm", "-f", names.volume], true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(
|
||||
workspaceId: string,
|
||||
): Promise<{ status: "missing" } | { status: "ready" | "paused"; state: RuntimeState }> {
|
||||
const names = resourceNames(workspaceId);
|
||||
const inspection = await docker(
|
||||
[
|
||||
"inspect",
|
||||
"--format",
|
||||
'{{.State.Running}}|{{index .Config.Labels "sh.paseo.runtime"}}|{{index .Config.Labels "sh.paseo.workspace-id"}}|{{index .Config.Labels "sh.paseo.workspace-root"}}|{{index .Config.Labels "sh.paseo.workspace-revision"}}',
|
||||
names.container,
|
||||
],
|
||||
true,
|
||||
);
|
||||
if (!inspection) return { status: "missing" };
|
||||
const [running, owner, inspectedWorkspaceId, root, revision] = inspection.trim().split("|");
|
||||
if (owner !== "workspace" || inspectedWorkspaceId !== workspaceId || !root || !revision) {
|
||||
throw new Error(`Docker resource ownership mismatch: ${names.container}`);
|
||||
}
|
||||
return {
|
||||
status: running === "true" ? "ready" : "paused",
|
||||
state: stateFor(
|
||||
workspaceId,
|
||||
root,
|
||||
revision,
|
||||
names.container,
|
||||
running === "true" ? "ready" : "paused",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function pause(workspaceId: string): Promise<void> {
|
||||
const current = await inspect(workspaceId);
|
||||
if (current.status === "missing" || current.status === "paused") return;
|
||||
await docker(["stop", "--time", "5", resourceNames(workspaceId).container]);
|
||||
}
|
||||
|
||||
async function resume(workspaceId: string): Promise<RuntimeState> {
|
||||
const current = await inspect(workspaceId);
|
||||
if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`);
|
||||
if (current.status === "paused") await docker(["start", resourceNames(workspaceId).container]);
|
||||
await waitUntilReady(resourceNames(workspaceId).container);
|
||||
const ready = await inspect(workspaceId);
|
||||
if (ready.status !== "ready") throw new Error(`Docker workspace did not resume: ${workspaceId}`);
|
||||
return ready.state;
|
||||
}
|
||||
|
||||
async function destroy(workspaceId: string): Promise<void> {
|
||||
const names = resourceNames(workspaceId);
|
||||
const current = await inspect(workspaceId);
|
||||
const volume = await inspectVolume(names.volume);
|
||||
if (volume && volume.owner !== workspaceId) {
|
||||
throw new Error(`Docker volume ownership mismatch: ${names.volume}`);
|
||||
}
|
||||
if (current.status !== "missing") await docker(["rm", "-f", names.container]);
|
||||
if (volume) await docker(["volume", "rm", names.volume]);
|
||||
}
|
||||
|
||||
async function inspectVolume(name: string): Promise<{ owner: string | null } | null> {
|
||||
const output = await docker(["volume", "inspect", name], true);
|
||||
if (!output) return null;
|
||||
const inspection = JSON.parse(output) as Array<{ Labels?: Record<string, string> | null }>;
|
||||
return { owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null };
|
||||
}
|
||||
|
||||
async function execute(workspaceId: string): Promise<void> {
|
||||
const request = JSON.parse(await readOneShotEnvelope()) as ExecRequest;
|
||||
assertProtocol(request.protocolVersion);
|
||||
if (!/^[a-f0-9]{32}$/.test(request.execId)) throw new Error("Invalid exec id");
|
||||
const current = await inspect(workspaceId);
|
||||
if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`);
|
||||
const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd);
|
||||
const cwd = (
|
||||
await docker([
|
||||
"exec",
|
||||
resourceNames(workspaceId).container,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'cd "$1" && pwd -P',
|
||||
"paseo-resolve-cwd",
|
||||
requestedCwd,
|
||||
])
|
||||
).trim();
|
||||
assertContainedPath(current.state.root, cwd);
|
||||
const encoded = Buffer.from(
|
||||
JSON.stringify({ argv: request.argv, cwd, env: request.env }),
|
||||
).toString("base64url");
|
||||
const execId = request.execId;
|
||||
const container = resourceNames(workspaceId).container;
|
||||
const child = spawn(
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-e",
|
||||
"PASEO_RUNTIME_EXEC",
|
||||
"-e",
|
||||
"PASEO_RUNTIME_EXEC_ID",
|
||||
container,
|
||||
"node",
|
||||
"/opt/paseo-workspace-runtime/workload.mjs",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_RUNTIME_EXEC: encoded, PASEO_RUNTIME_EXEC_ID: execId },
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
},
|
||||
);
|
||||
const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
},
|
||||
);
|
||||
let forwardedSignal: NodeJS.Signals | null = null;
|
||||
const forwardSignal = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
forwardedSignal = signal;
|
||||
await signalExec(workspaceId, execId, signal);
|
||||
};
|
||||
const hostSignalHandlers = new Map<NodeJS.Signals, () => void>();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
const handler = () => void forwardSignal(signal).catch(() => child.kill(signal));
|
||||
hostSignalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
const exit = await exitPromise;
|
||||
for (const [signal, handler] of hostSignalHandlers) process.off(signal, handler);
|
||||
if (forwardedSignal) {
|
||||
process.removeAllListeners(forwardedSignal);
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
return;
|
||||
}
|
||||
if (exit.signal) {
|
||||
process.kill(process.pid, exit.signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = exit.code ?? 1;
|
||||
}
|
||||
|
||||
async function signalExec(
|
||||
workspaceId: string,
|
||||
execId: string,
|
||||
signal: NodeJS.Signals,
|
||||
): Promise<void> {
|
||||
if (!/^[a-f0-9]{32}$/.test(execId)) throw new Error("Invalid exec id");
|
||||
const current = await inspect(workspaceId);
|
||||
if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`);
|
||||
await docker([
|
||||
"exec",
|
||||
resourceNames(workspaceId).container,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'test ! -f "$1" || { read -r supervisor workload < "$1"; signal=${2#SIG}; kill -"$signal" "-$workload" 2>/dev/null || kill -"$signal" "$workload" 2>/dev/null || true; kill -"$signal" "$supervisor" 2>/dev/null || true; }',
|
||||
"paseo-signal",
|
||||
`/tmp/paseo-runtime-exec-${execId}.pid`,
|
||||
signal,
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveWorkspaceCwd(root: string, relativeCwd?: string): string {
|
||||
if (!root.startsWith("/workspace") || path.posix.relative("/workspace", root).startsWith("..")) {
|
||||
throw new Error(`Invalid Docker workspace root: ${root}`);
|
||||
}
|
||||
if (!relativeCwd) return root;
|
||||
if (path.posix.isAbsolute(relativeCwd) || relativeCwd.includes("\\")) {
|
||||
throw new Error("Workspace cwd must be relative");
|
||||
}
|
||||
const resolved = path.posix.resolve(root, relativeCwd);
|
||||
const relative = path.posix.relative(root, resolved);
|
||||
if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) {
|
||||
throw new Error("Workspace cwd escapes its root");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertContainedPath(root: string, candidate: string): void {
|
||||
const relative = path.posix.relative(root, candidate);
|
||||
if (
|
||||
!candidate ||
|
||||
relative === ".." ||
|
||||
relative.startsWith("../") ||
|
||||
path.posix.isAbsolute(relative)
|
||||
) {
|
||||
throw new Error("Workspace cwd escapes its root");
|
||||
}
|
||||
}
|
||||
|
||||
function requireImage(options: LifecycleRequest["options"]): string {
|
||||
if (typeof options.image !== "string" || !options.image.trim()) {
|
||||
throw new Error("Docker runtime option image is required");
|
||||
}
|
||||
return options.image;
|
||||
}
|
||||
|
||||
function resourceNames(workspaceId: string): { container: string; volume: string } {
|
||||
const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20);
|
||||
return { container: `paseo-ws-${key}`, volume: `paseo-ws-${key}` };
|
||||
}
|
||||
|
||||
function stateFor(
|
||||
workspaceId: string,
|
||||
root: string,
|
||||
revision: string,
|
||||
container: string,
|
||||
lifecycle: "ready" | "paused" = "ready",
|
||||
): RuntimeState {
|
||||
return { workspaceId, root, revision, executionDomainId: `docker:${container}`, lifecycle };
|
||||
}
|
||||
|
||||
async function waitUntilReady(container: string): Promise<void> {
|
||||
let lastError = "";
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const output = await docker(
|
||||
["exec", container, "/bin/sh", "-c", "test -d /workspace && echo ready"],
|
||||
true,
|
||||
);
|
||||
if (output.trim() === "ready") return;
|
||||
lastError = `Container is not ready: ${container}`;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
async function docker(args: string[], allowFailure = false): Promise<string> {
|
||||
const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
readStream(child.stdout),
|
||||
readStream(child.stderr),
|
||||
new Promise<{ code: number | null }>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => resolve({ code }));
|
||||
}),
|
||||
]);
|
||||
if (exit.code !== 0) {
|
||||
if (allowFailure) return "";
|
||||
throw new Error(`docker ${args[0]} failed: ${stderr.trim()}`);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
return readStream(process.stdin);
|
||||
}
|
||||
|
||||
async function readOneShotEnvelope(): Promise<string> {
|
||||
const stream = new Socket({ fd: 3, readable: true, writable: false });
|
||||
try {
|
||||
return await readStream(stream);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream)
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function writeJson(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function assertProtocol(version: unknown): asserts version is 1 {
|
||||
if (version !== 1) throw new Error(`Unsupported protocol version: ${String(version)}`);
|
||||
}
|
||||
|
||||
function argument(flag: string): string | undefined {
|
||||
const index = process.argv.indexOf(flag, 2);
|
||||
return index < 0 ? undefined : process.argv[index + 1];
|
||||
}
|
||||
|
||||
function requireArgument(flag: string): string {
|
||||
const value = argument(flag);
|
||||
if (!value) throw new Error(`${flag} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireSignal(value: string): NodeJS.Signals {
|
||||
if (value !== "SIGINT" && value !== "SIGTERM" && value !== "SIGHUP" && value !== "SIGKILL") {
|
||||
throw new Error(`Unsupported signal: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
const encoded = process.env.PASEO_RUNTIME_EXEC;
|
||||
if (!encoded) throw new Error("PASEO_RUNTIME_EXEC is required");
|
||||
const request = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||
delete process.env.PASEO_RUNTIME_EXEC;
|
||||
const execId = process.env.PASEO_RUNTIME_EXEC_ID;
|
||||
delete process.env.PASEO_RUNTIME_EXEC_ID;
|
||||
if (!execId || !/^[a-f0-9]+$/.test(execId)) throw new Error("PASEO_RUNTIME_EXEC_ID is required");
|
||||
const pidFile = `/tmp/paseo-runtime-exec-${execId}.pid`;
|
||||
|
||||
const child = spawn(request.argv[0], request.argv.slice(1), {
|
||||
cwd: request.cwd,
|
||||
env: request.env,
|
||||
detached: true,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (!child.pid) throw new Error("Runtime workload did not start");
|
||||
writeFileSync(pidFile, `${process.pid} ${child.pid}`);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.on(signal, () => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
child.once("error", (error) => {
|
||||
rmSync(pidFile, { force: true });
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 127;
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
rmSync(pidFile, { force: true });
|
||||
if (signal) {
|
||||
process.removeAllListeners(signal);
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"incremental": false,
|
||||
"noEmit": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
"test:integration:local": "vitest run local.e2e.test.ts",
|
||||
"test:workspace-runtime:docker": "vitest run --maxWorkers=1 src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
|
||||
|
||||
@@ -142,8 +142,10 @@ import { WorkspaceReconciliationService } from "./workspace-reconciliation-servi
|
||||
import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
resolveSelectedWorkspaceRuntimeId,
|
||||
type WorkspaceArchiveContext,
|
||||
} from "./workspace-registry.js";
|
||||
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
|
||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
@@ -384,6 +386,16 @@ export interface PaseoDaemonConfig {
|
||||
daemonVersion?: string;
|
||||
desktopManaged?: boolean;
|
||||
worktreesRoot?: string;
|
||||
workspaceRuntimes?: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
type: "command";
|
||||
command: readonly [string, ...string[]];
|
||||
options?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
>
|
||||
>;
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
hostnames?: HostnamesConfig;
|
||||
@@ -798,6 +810,24 @@ export async function createPaseoDaemon(
|
||||
path.join(config.paseoHome, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
const 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;
|
||||
},
|
||||
persistRuntimeId: async (workspaceId, runtimeId) => {
|
||||
const existing = await workspaceRegistry?.get(workspaceId);
|
||||
if (existing?.runtime?.runtimeId === runtimeId) return;
|
||||
const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({
|
||||
...workspace,
|
||||
runtime: { runtimeId },
|
||||
}));
|
||||
if (!updated) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
},
|
||||
});
|
||||
const chatService = new FileBackedChatService({
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
@@ -1570,6 +1600,7 @@ export async function createPaseoDaemon(
|
||||
browserToolsBroker,
|
||||
hubRelationships,
|
||||
workspaceSetupRuntime,
|
||||
workspaceRuntime,
|
||||
);
|
||||
relayRuntime = createRelayRuntime({
|
||||
config: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { loadConfig, resolveBundledWebUiDistDir } from "./config.js";
|
||||
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -26,6 +27,85 @@ describe("server config", () => {
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("loads trusted external workspace runtime registrations", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-"));
|
||||
roots.push(paseoHome);
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
workspaceRuntimes: {
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: ["/trusted/runtime", "--mode", "fixture"],
|
||||
options: { image: "fixture:test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await chmod(configPath, 0o600);
|
||||
|
||||
expect(loadConfig(paseoHome, { env: {} }).workspaceRuntimes).toEqual({
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: ["/trusted/runtime", "--mode", "fixture"],
|
||||
options: { image: "fixture:test" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("composes a configured executable for runtimeId selection and rejects others", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-compose-"));
|
||||
roots.push(paseoHome);
|
||||
const source = path.join(paseoHome, "source");
|
||||
const stateDirectory = path.join(paseoHome, "runtime-state");
|
||||
await Promise.all([mkdir(source), mkdir(stateDirectory)]);
|
||||
const fixtureExecutable = fileURLToPath(
|
||||
new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url),
|
||||
);
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
workspaceRuntimes: {
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: [process.execPath, fixtureExecutable],
|
||||
options: { stateDirectory },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await chmod(configPath, 0o600);
|
||||
const config = loadConfig(paseoHome, { env: {} });
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome,
|
||||
externalRuntimes: config.workspaceRuntimes,
|
||||
resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null,
|
||||
persistRuntimeId: async (workspaceId, runtimeId) => {
|
||||
runtimeIds.set(workspaceId, runtimeId);
|
||||
},
|
||||
});
|
||||
const createInput = {
|
||||
workspaceId: "configured-runtime",
|
||||
runtimeId: "fixture",
|
||||
project: { id: "fixture", source: { kind: "host-directory" as const, path: source } },
|
||||
placement: { kind: "existing" as const },
|
||||
};
|
||||
|
||||
await expect(service.create(createInput)).resolves.toEqual({
|
||||
workspaceId: "configured-runtime",
|
||||
runtimeId: "fixture",
|
||||
});
|
||||
await expect(
|
||||
service.create({ ...createInput, workspaceId: "unknown-runtime", runtimeId: "unknown" }),
|
||||
).rejects.toThrow("Workspace runtime is not registered: unknown");
|
||||
await service.destroy("configured-runtime");
|
||||
});
|
||||
|
||||
test("resolves bundled web UI path from source-tree modules", () => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
expect(
|
||||
|
||||
@@ -509,6 +509,7 @@ export function loadConfig(
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
workspaceRuntimes: persisted.workspaceRuntimes,
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
|
||||
@@ -240,6 +240,12 @@ describe("current workspace runtime journeys", () => {
|
||||
test("local workspace uses the public daemon/client behavior", async () => {
|
||||
const workspace = await createCharacterizedWorkspace("local");
|
||||
try {
|
||||
const records = JSON.parse(
|
||||
readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
|
||||
expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({
|
||||
runtimeId: "local",
|
||||
});
|
||||
await expectFileEditAndWatch(workspace);
|
||||
await expectGitObservation(workspace);
|
||||
await expectTerminalCommand(workspace);
|
||||
@@ -256,6 +262,12 @@ describe("current workspace runtime journeys", () => {
|
||||
test("owned worktree runs setup and uses the same public daemon/client behavior", async () => {
|
||||
const workspace = await createCharacterizedWorkspace("worktree");
|
||||
try {
|
||||
const records = JSON.parse(
|
||||
readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
|
||||
expect(
|
||||
records.find((record) => record.workspaceId === workspace.id)?.runtime,
|
||||
).toBeUndefined();
|
||||
await expect
|
||||
.poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 30_000 })
|
||||
.toMatchObject({ snapshot: { status: "completed", error: null } });
|
||||
|
||||
@@ -137,6 +137,33 @@ describe("PersistedConfigSchema worktrees config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema workspace runtime config", () => {
|
||||
test("accepts trusted command registrations and rejects an empty executable", () => {
|
||||
expect(
|
||||
PersistedConfigSchema.parse({
|
||||
workspaceRuntimes: {
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: ["/trusted/runtime", "--fixture"],
|
||||
options: { image: "fixture:test" },
|
||||
},
|
||||
},
|
||||
}).workspaceRuntimes,
|
||||
).toEqual({
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: ["/trusted/runtime", "--fixture"],
|
||||
options: { image: "fixture:test" },
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
PersistedConfigSchema.parse({
|
||||
workspaceRuntimes: { invalid: { type: "command", command: [] } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema provider credentials", () => {
|
||||
test("accepts separate OpenAI STT and TTS credentials", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -82,6 +82,20 @@ const WorktreesConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const WorkspaceRuntimesConfigSchema = z.record(
|
||||
z.string().min(1),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("command"),
|
||||
command: z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.transform((command) => command as [string, ...string[]]),
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict(),
|
||||
);
|
||||
|
||||
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
|
||||
message: "Expected a bcrypt hash",
|
||||
});
|
||||
@@ -304,6 +318,7 @@ export const PersistedConfigSchema = z
|
||||
|
||||
providers: ProvidersSchema.optional(),
|
||||
worktrees: WorktreesConfigSchema.optional(),
|
||||
workspaceRuntimes: WorkspaceRuntimesConfigSchema.optional(),
|
||||
agents: z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
|
||||
@@ -142,6 +142,7 @@ import {
|
||||
type WorkspaceMutation,
|
||||
type WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js";
|
||||
import { wrapSpokenInput } from "./voice-config.js";
|
||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||
import {
|
||||
@@ -449,6 +450,7 @@ export interface SessionOptions {
|
||||
agentStorage: AgentStorage;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceRuntime?: WorkspaceRuntimeService;
|
||||
filesystem?: SessionFileSystem;
|
||||
chatService: FileBackedChatService;
|
||||
scheduleService: ScheduleService;
|
||||
@@ -623,6 +625,7 @@ export class Session {
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceRuntime: WorkspaceRuntimeService | undefined;
|
||||
private readonly filesystem: SessionFileSystem;
|
||||
private readonly github: ForgeService;
|
||||
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
||||
@@ -703,6 +706,7 @@ export class Session {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceRuntime,
|
||||
filesystem,
|
||||
chatService,
|
||||
scheduleService,
|
||||
@@ -770,6 +774,7 @@ export class Session {
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry;
|
||||
this.workspaceRegistry = workspaceRegistry;
|
||||
this.workspaceRuntime = workspaceRuntime;
|
||||
this.filesystem = filesystem ?? nodeSessionFileSystem;
|
||||
this.github = github ?? createGitHubService();
|
||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||
@@ -5375,8 +5380,27 @@ export class Session {
|
||||
cwd,
|
||||
explicitTitle ?? promptTitle,
|
||||
request.source.projectId,
|
||||
{ expectsInitialAgent: Boolean(request.firstAgentContext) },
|
||||
{ expectsInitialAgent: Boolean(request.firstAgentContext), runtimeId: "local" },
|
||||
);
|
||||
const workspaceRuntime = this.workspaceRuntime;
|
||||
if (!workspaceRuntime) {
|
||||
await this.workspaceRegistry.remove(workspace.workspaceId);
|
||||
throw new Error("Workspace runtime service was not composed");
|
||||
}
|
||||
try {
|
||||
await workspaceRuntime.create({
|
||||
workspaceId: workspace.workspaceId,
|
||||
runtimeId: "local",
|
||||
project: {
|
||||
id: workspace.projectId,
|
||||
source: { kind: "host-directory", path: cwd },
|
||||
},
|
||||
placement: { kind: "existing" },
|
||||
});
|
||||
} catch (error) {
|
||||
await this.workspaceRegistry.remove(workspace.workspaceId);
|
||||
throw error;
|
||||
}
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
this.emit({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { Session } from "./session.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
|
||||
import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js";
|
||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
@@ -80,6 +81,13 @@ const REPO_CWD = path.resolve("/tmp/repo");
|
||||
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
|
||||
|
||||
const terminalManagers: TerminalManager[] = [];
|
||||
const runtimeDirectories: string[] = [];
|
||||
|
||||
function createRuntimeDirectory(): string {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "paseo-session-runtime-"));
|
||||
runtimeDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -101,6 +109,9 @@ afterEach(async () => {
|
||||
}
|
||||
}
|
||||
await flushTerminalContributionWork();
|
||||
for (const directory of runtimeDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
interface SessionTestAccess {
|
||||
@@ -691,6 +702,23 @@ function createSessionForWorkspaceTests(
|
||||
remove: async () => {},
|
||||
},
|
||||
workspaceRegistry,
|
||||
workspaceRuntime: createWorkspaceRuntimeService({
|
||||
paseoHome: options.paseoHome ?? "/tmp/paseo-test",
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
resolveRuntimeId: async (workspaceId) => {
|
||||
const workspace = await workspaceRegistry.get(workspaceId);
|
||||
return workspace?.runtime?.runtimeId ?? null;
|
||||
},
|
||||
persistRuntimeId: async (workspaceId, runtimeId) => {
|
||||
const workspace = await workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
if (workspace.runtime?.runtimeId === runtimeId) return;
|
||||
await workspaceRegistry.upsert({
|
||||
...workspace,
|
||||
runtime: { runtimeId },
|
||||
});
|
||||
},
|
||||
}),
|
||||
filesystem: { isDirectory: async () => true },
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
@@ -9229,6 +9257,7 @@ test("checkout.rename_branch.request renames the branch without a denormalized b
|
||||
});
|
||||
|
||||
test("workspace.create.response persists the first prompt as the initial title", async () => {
|
||||
const runtimeCwd = createRuntimeDirectory();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const session = createSessionForWorkspaceTests({
|
||||
@@ -9250,7 +9279,7 @@ test("workspace.create.response persists the first prompt as the initial title",
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-create-first-prompt",
|
||||
source: { kind: "directory", path: REPO_CWD },
|
||||
source: { kind: "directory", path: runtimeCwd },
|
||||
firstAgentContext: {
|
||||
prompt: "Add retries to the payments flow\nwith exponential backoff",
|
||||
},
|
||||
@@ -9269,6 +9298,7 @@ test("workspace.create.response persists the first prompt as the initial title",
|
||||
});
|
||||
|
||||
test("workspace create emits through a matching workspace subscription", async () => {
|
||||
const runtimeCwd = createRuntimeDirectory();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | null = null;
|
||||
@@ -9310,7 +9340,7 @@ test("workspace create emits through a matching workspace subscription", async (
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-create-match",
|
||||
source: { kind: "directory", path: REPO_CWD },
|
||||
source: { kind: "directory", path: runtimeCwd },
|
||||
firstAgentContext: { prompt: "Implement the requested change" },
|
||||
});
|
||||
|
||||
@@ -9322,6 +9352,7 @@ test("workspace create emits through a matching workspace subscription", async (
|
||||
});
|
||||
|
||||
test("workspace create stays out of a non-matching workspace subscription", async () => {
|
||||
const runtimeCwd = createRuntimeDirectory();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const session = createSessionForWorkspaceTests({
|
||||
@@ -9350,20 +9381,21 @@ test("workspace create stays out of a non-matching workspace subscription", asyn
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-create-filtered",
|
||||
source: { kind: "directory", path: REPO_CWD },
|
||||
source: { kind: "directory", path: runtimeCwd },
|
||||
});
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
||||
});
|
||||
|
||||
test("workspace.create.request attaches a directory workspace to its explicit active project", async () => {
|
||||
const runtimeCwd = createRuntimeDirectory();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const projects = new Map([
|
||||
[
|
||||
"prj_explicit",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_explicit",
|
||||
rootPath: path.join(REPO_CWD, "unrelated"),
|
||||
rootPath: path.join(runtimeCwd, "unrelated"),
|
||||
kind: "non_git",
|
||||
displayName: "unrelated",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
@@ -9384,7 +9416,7 @@ test("workspace.create.request attaches a directory workspace to its explicit ac
|
||||
await session.handleMessage({
|
||||
type: "workspace.create.request",
|
||||
requestId: "req-explicit-project",
|
||||
source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" },
|
||||
source: { kind: "directory", path: runtimeCwd, projectId: "prj_explicit" },
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.create.response");
|
||||
@@ -9396,7 +9428,7 @@ test("workspace.create.request attaches a directory workspace to its explicit ac
|
||||
const workspaceId = response?.payload.workspace?.id;
|
||||
expect(workspaceId).toEqual(expect.any(String));
|
||||
expect(workspaces.get(workspaceId as string)).toMatchObject({
|
||||
cwd: REPO_CWD,
|
||||
cwd: runtimeCwd,
|
||||
projectId: "prj_explicit",
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -57,7 +57,7 @@ export interface WorkspaceProvisioningService {
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
context?: { expectsInitialAgent?: boolean },
|
||||
context?: { expectsInitialAgent?: boolean; runtimeId?: string },
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
createWorkspaceForWorktree(
|
||||
input: CreateWorktreeWorkspaceInput,
|
||||
@@ -186,7 +186,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
context?: { expectsInitialAgent?: boolean },
|
||||
context?: { expectsInitialAgent?: boolean; runtimeId?: string },
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
@@ -200,6 +200,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
...(context?.runtimeId ? { runtime: { runtimeId: context.runtimeId } } : {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Socket } from "node:net";
|
||||
|
||||
const [operation] = process.argv.slice(2);
|
||||
const workspaceId = argument("--workspace-id");
|
||||
|
||||
try {
|
||||
if (operation === "describe") {
|
||||
writeJson({ protocolVersion: 1, modes: ["pipes"] });
|
||||
} else if (!workspaceId) {
|
||||
throw new Error("--workspace-id is required");
|
||||
} else if (operation === "exec") {
|
||||
await execute(workspaceId);
|
||||
} else if (operation === "signal") {
|
||||
await signal(workspaceId, requireArgument("--exec-id"), requireArgument("--signal"));
|
||||
} else {
|
||||
const request = JSON.parse(await readStream(process.stdin));
|
||||
switch (operation) {
|
||||
case "create":
|
||||
writeJson({ protocolVersion: 1, type: "state", state: await create(workspaceId, request) });
|
||||
break;
|
||||
case "inspect":
|
||||
writeJson({
|
||||
protocolVersion: 1,
|
||||
type: "inspection",
|
||||
inspection: await inspect(workspaceId, request.options),
|
||||
});
|
||||
break;
|
||||
case "pause":
|
||||
await setLifecycle(workspaceId, request.options, "paused");
|
||||
writeJson({ protocolVersion: 1, type: "ok" });
|
||||
break;
|
||||
case "resume":
|
||||
writeJson({
|
||||
protocolVersion: 1,
|
||||
type: "state",
|
||||
state: await setLifecycle(workspaceId, request.options, "ready"),
|
||||
});
|
||||
break;
|
||||
case "destroy":
|
||||
await rm(stateFile(workspaceId, request.options), { force: true });
|
||||
writeJson({ protocolVersion: 1, type: "ok" });
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function create(id, request) {
|
||||
const existing = await readState(id, request.options);
|
||||
if (existing) return existing;
|
||||
const root = request.input.project.source.path;
|
||||
const state = {
|
||||
workspaceId: id,
|
||||
root,
|
||||
revision: "fixture:1",
|
||||
executionDomainId: "fixture",
|
||||
lifecycle: "ready",
|
||||
};
|
||||
await mkdir(request.options.stateDirectory, { recursive: true });
|
||||
await writeFile(stateFile(id, request.options), JSON.stringify(state));
|
||||
return state;
|
||||
}
|
||||
|
||||
async function inspect(id, options) {
|
||||
await applyInspectBarrier(options);
|
||||
const state = await readState(id, options);
|
||||
return state ? { status: state.lifecycle, state } : { status: "missing" };
|
||||
}
|
||||
|
||||
async function setLifecycle(id, options, lifecycle) {
|
||||
const state = await readState(id, options);
|
||||
if (!state) throw new Error(`Fixture workspace is missing: ${id}`);
|
||||
const updated = { ...state, lifecycle };
|
||||
await writeFile(stateFile(id, options), JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function execute(id) {
|
||||
const envelope = JSON.parse(await readOneShotEnvelope());
|
||||
const state = await readState(id, envelope.options);
|
||||
if (!state) throw new Error(`Fixture workspace is missing: ${id}`);
|
||||
await writeFile(
|
||||
path.join(state.root, ".runtime-launch.json"),
|
||||
JSON.stringify({ argv: process.argv, purpose: envelope.purpose }),
|
||||
);
|
||||
const child = spawn(envelope.argv[0], envelope.argv.slice(1), {
|
||||
cwd: path.resolve(state.root, envelope.cwd ?? "."),
|
||||
env: envelope.env,
|
||||
detached: true,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid));
|
||||
let forwardedSignal = null;
|
||||
const handlers = new Map();
|
||||
for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
const handler = () => {
|
||||
forwardedSignal = signalName;
|
||||
killGroup(child.pid, signalName);
|
||||
};
|
||||
handlers.set(signalName, handler);
|
||||
process.on(signalName, handler);
|
||||
}
|
||||
const exit = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signalName) => resolve({ code, signal: signalName }));
|
||||
});
|
||||
await rm(execFile(id, envelope.execId, envelope.options), { force: true });
|
||||
for (const [signalName, handler] of handlers) process.off(signalName, handler);
|
||||
const signalName = forwardedSignal ?? exit.signal;
|
||||
if (signalName) {
|
||||
process.removeAllListeners(signalName);
|
||||
process.kill(process.pid, signalName);
|
||||
} else {
|
||||
process.exitCode = exit.code ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function signal(id, execId, signalName) {
|
||||
const { options } = JSON.parse(await readStream(process.stdin));
|
||||
const pid = Number(await readFile(execFile(id, execId, options), "utf8"));
|
||||
killGroup(pid, signalName);
|
||||
}
|
||||
|
||||
async function applyInspectBarrier(options) {
|
||||
if (!options.inspectBarrierDirectory) return;
|
||||
const next = path.join(options.inspectBarrierDirectory, "block-next-inspect");
|
||||
try {
|
||||
await rm(next);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
await writeFile(path.join(options.inspectBarrierDirectory, "inspect-entered"), "entered");
|
||||
const release = path.join(options.inspectBarrierDirectory, "release-inspect");
|
||||
while (true) {
|
||||
try {
|
||||
await readFile(release);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readOneShotEnvelope() {
|
||||
const stream = new Socket({ fd: 3, readable: true, writable: false });
|
||||
try {
|
||||
return await readStream(stream);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function readState(id, options) {
|
||||
try {
|
||||
return JSON.parse(await readFile(stateFile(id, options), "utf8"));
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stateFile(id, options) {
|
||||
return path.join(options.stateDirectory, `${key(id)}.json`);
|
||||
}
|
||||
|
||||
function execFile(id, execId, options) {
|
||||
return path.join(options.stateDirectory, `${key(id)}-${execId}.pid`);
|
||||
}
|
||||
|
||||
function key(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function killGroup(pid, signalName) {
|
||||
try {
|
||||
process.kill(-pid, signalName);
|
||||
} catch {
|
||||
process.kill(pid, signalName);
|
||||
}
|
||||
}
|
||||
|
||||
function argument(flag) {
|
||||
const index = process.argv.indexOf(flag, 2);
|
||||
return index < 0 ? undefined : process.argv[index + 1];
|
||||
}
|
||||
|
||||
function requireArgument(flag) {
|
||||
const value = argument(flag);
|
||||
if (!value) throw new Error(`${flag} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readStream(stream) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function writeJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js";
|
||||
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 { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
@@ -537,6 +538,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceRuntime: WorkspaceRuntimeService | undefined;
|
||||
private readonly chatService: FileBackedChatService;
|
||||
private readonly loopService: LoopService;
|
||||
private readonly scheduleService: ScheduleService;
|
||||
@@ -634,6 +636,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
browserToolsBroker?: BrowserToolsBroker | null,
|
||||
hubRelationships?: HubRelationshipManagement | null,
|
||||
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
|
||||
workspaceRuntime?: WorkspaceRuntimeService,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||
@@ -651,6 +654,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.workspaceRuntime = workspaceRuntime;
|
||||
const requiredServices = requireWebSocketServices({
|
||||
chatService,
|
||||
loopService,
|
||||
@@ -1332,6 +1336,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private createSocketSession(options: SocketSessionOptions): Session {
|
||||
if (!this.workspaceRuntime) {
|
||||
throw new Error("Workspace runtime service was not composed");
|
||||
}
|
||||
return new Session({
|
||||
clientId: options.clientId,
|
||||
appVersion: options.appVersion,
|
||||
@@ -1359,6 +1366,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentStorage: this.agentStorage,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceRuntime: this.workspaceRuntime,
|
||||
chatService: this.chatService,
|
||||
loopService: this.loopService,
|
||||
scheduleService: this.scheduleService,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FileBackedWorkspaceRegistry,
|
||||
resolveWorkspaceDisplayName,
|
||||
resolveWorkspaceName,
|
||||
resolveSelectedWorkspaceRuntimeId,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
describe("resolveWorkspaceName", () => {
|
||||
@@ -43,6 +44,27 @@ describe("resolveWorkspaceName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace runtime selection compatibility", () => {
|
||||
test.each(["local_checkout", "directory", "worktree"] as const)(
|
||||
"keeps an existing %s record on its legacy path until explicit cutover",
|
||||
(kind) => {
|
||||
const existing = createPersistedWorkspaceRecord({
|
||||
workspaceId: `legacy-${kind}`,
|
||||
projectId: "legacy-project",
|
||||
cwd: "/tmp/legacy",
|
||||
kind,
|
||||
displayName: "legacy",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(resolveSelectedWorkspaceRuntimeId(existing)).toBeNull();
|
||||
expect(
|
||||
resolveSelectedWorkspaceRuntimeId({ ...existing, runtime: { runtimeId: "selected" } }),
|
||||
).toBe("selected");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("workspace registries", () => {
|
||||
let tmpDir: string;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
|
||||
@@ -77,6 +77,7 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.transform((value) => value ?? null),
|
||||
isPaseoOwnedWorktree: z.boolean().default(false),
|
||||
mainRepoRoot: z.string().nullable().default(null),
|
||||
runtime: z.object({ runtimeId: z.string().min(1) }).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -97,6 +98,12 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
|
||||
|
||||
export function resolveSelectedWorkspaceRuntimeId(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): string | null {
|
||||
return workspace.runtime?.runtimeId ?? null;
|
||||
}
|
||||
|
||||
export interface WorkspaceMutation {
|
||||
kind: "upsert" | "archive" | "remove";
|
||||
workspaceId: string;
|
||||
@@ -551,6 +558,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
baseBranch?: string | null;
|
||||
isPaseoOwnedWorktree?: boolean;
|
||||
mainRepoRoot?: string | null;
|
||||
runtime?: { runtimeId: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
@@ -565,6 +573,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
baseBranch: input.baseBranch ?? null,
|
||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||
...(input.runtime ? { runtime: input.runtime } : {}),
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null,
|
||||
pinnedAt: input.pinnedAt ?? null,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { WorkspaceRuntimeDriver } from "../drivers/index.js";
|
||||
import { createCommandRuntime } from "./internal/command-runtime.js";
|
||||
|
||||
export interface CommandRuntimeAdapterConfig {
|
||||
command: readonly [string, ...string[]];
|
||||
options?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function createCommandRuntimeAdapter(
|
||||
runtimeId: string,
|
||||
config: CommandRuntimeAdapterConfig,
|
||||
): WorkspaceRuntimeDriver {
|
||||
return createCommandRuntime(runtimeId, config);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import type {
|
||||
WorkspaceDriverCreateInput,
|
||||
WorkspaceDriverInspection,
|
||||
WorkspaceDriverSpawnInput,
|
||||
WorkspacePipeProcess,
|
||||
WorkspaceRuntimeDriver,
|
||||
} from "../../drivers/index.js";
|
||||
import {
|
||||
CommandRuntimeDescribeResponseSchema,
|
||||
CommandRuntimeLifecycleResponseSchema,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface CommandRuntimeConfig {
|
||||
command: readonly [string, ...string[]];
|
||||
options?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function createCommandRuntime(
|
||||
runtimeId: string,
|
||||
config: CommandRuntimeConfig,
|
||||
): WorkspaceRuntimeDriver {
|
||||
let described: Promise<void> | null = null;
|
||||
|
||||
function ensureCompatible(): Promise<void> {
|
||||
described ??= runCommand(["describe"], undefined).then((output) => {
|
||||
const description = CommandRuntimeDescribeResponseSchema.parse(JSON.parse(output));
|
||||
if (!description.modes.includes("pipes")) {
|
||||
throw new Error(`Workspace runtime ${runtimeId} does not support pipes`);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return described;
|
||||
}
|
||||
|
||||
async function lifecycle(
|
||||
operation: "create" | "inspect" | "pause" | "resume" | "destroy",
|
||||
workspaceId: string,
|
||||
input?: WorkspaceDriverCreateInput,
|
||||
) {
|
||||
await ensureCompatible();
|
||||
const output = await runCommand(
|
||||
[operation, "--workspace-id", workspaceId],
|
||||
JSON.stringify({ protocolVersion: 1, input, options: config.options ?? {} }),
|
||||
);
|
||||
return CommandRuntimeLifecycleResponseSchema.parse(JSON.parse(output));
|
||||
}
|
||||
|
||||
return {
|
||||
id: runtimeId,
|
||||
async create(input) {
|
||||
const response = await lifecycle("create", input.workspaceId, input);
|
||||
if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`);
|
||||
return response.state;
|
||||
},
|
||||
async inspect(workspaceId): Promise<WorkspaceDriverInspection> {
|
||||
const response = await lifecycle("inspect", workspaceId);
|
||||
if (response.type !== "inspection") {
|
||||
throw new Error(`Invalid inspect response from ${runtimeId}`);
|
||||
}
|
||||
return response.inspection as WorkspaceDriverInspection;
|
||||
},
|
||||
async spawn(input) {
|
||||
await ensureCompatible();
|
||||
return spawnCommandProcess(runtimeId, config.command, input, config.options ?? {});
|
||||
},
|
||||
async pause(workspaceId) {
|
||||
const response = await lifecycle("pause", workspaceId);
|
||||
if (response.type !== "ok") throw new Error(`Invalid pause response from ${runtimeId}`);
|
||||
},
|
||||
async resume(workspaceId) {
|
||||
const response = await lifecycle("resume", workspaceId);
|
||||
if (response.type !== "state") throw new Error(`Invalid resume response from ${runtimeId}`);
|
||||
return response.state;
|
||||
},
|
||||
async destroy(workspaceId) {
|
||||
const response = await lifecycle("destroy", workspaceId);
|
||||
if (response.type !== "ok") throw new Error(`Invalid destroy response from ${runtimeId}`);
|
||||
},
|
||||
};
|
||||
|
||||
async function runCommand(args: string[], stdin: string | undefined): Promise<string> {
|
||||
const child = spawn(config.command[0], [...config.command.slice(1), ...args], {
|
||||
env: commandEnvironment(),
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (stdin === undefined) child.stdin.end();
|
||||
else child.stdin.end(`${stdin}\n`);
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
collect(child.stdout),
|
||||
collect(child.stderr),
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
]);
|
||||
if (exit.code !== 0) {
|
||||
throw new Error(
|
||||
`Workspace runtime ${runtimeId} ${args[0]} failed (${exit.code ?? exit.signal}): ${stderr.trim()}`,
|
||||
);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnCommandProcess(
|
||||
runtimeId: string,
|
||||
command: readonly [string, ...string[]],
|
||||
input: WorkspaceDriverSpawnInput,
|
||||
options: Readonly<Record<string, unknown>>,
|
||||
): WorkspacePipeProcess {
|
||||
const execId = randomBytes(16).toString("hex");
|
||||
const child = spawn(
|
||||
command[0],
|
||||
[...command.slice(1), "exec", "--workspace-id", input.workspaceId],
|
||||
{
|
||||
env: commandEnvironment(),
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const metadata = child.stdio[3] as Writable;
|
||||
metadata.on("error", () => {
|
||||
// The exec process owns the actionable spawn error through `exited`.
|
||||
});
|
||||
metadata.end(
|
||||
JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
argv: input.argv,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
purpose: input.purpose,
|
||||
options,
|
||||
execId,
|
||||
}),
|
||||
);
|
||||
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve, reject) => {
|
||||
child.once("error", (error) => {
|
||||
reject(new Error(`Workspace runtime ${runtimeId} exec failed: ${error.message}`));
|
||||
});
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
},
|
||||
);
|
||||
|
||||
function forceKill(): void {
|
||||
const signalCommand = spawn(
|
||||
command[0],
|
||||
[
|
||||
...command.slice(1),
|
||||
"signal",
|
||||
"--workspace-id",
|
||||
input.workspaceId,
|
||||
"--exec-id",
|
||||
execId,
|
||||
"--signal",
|
||||
"SIGKILL",
|
||||
],
|
||||
{ env: commandEnvironment(), shell: false, stdio: ["pipe", "ignore", "ignore"] },
|
||||
);
|
||||
signalCommand.stdin.end(JSON.stringify({ protocolVersion: 1, options }));
|
||||
signalCommand.once("close", () => child.kill("SIGKILL"));
|
||||
signalCommand.once("error", () => child.kill("SIGKILL"));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "pipes",
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
exited,
|
||||
kill(signal = "SIGTERM") {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
if (signal === "SIGKILL") forceKill();
|
||||
else child.kill(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collect(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream)
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function commandEnvironment(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
if (env.FORCE_COLOR !== undefined) delete env.NO_COLOR;
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CommandRuntimeStateSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
root: z.string(),
|
||||
revision: z.string(),
|
||||
executionDomainId: z.string(),
|
||||
lifecycle: z.enum(["ready", "paused"]),
|
||||
});
|
||||
|
||||
export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("missing") }),
|
||||
z.object({ status: z.literal("paused"), state: CommandRuntimeStateSchema }),
|
||||
z.object({ status: z.literal("ready"), state: CommandRuntimeStateSchema }),
|
||||
z.object({ status: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("state"),
|
||||
protocolVersion: z.literal(1),
|
||||
state: CommandRuntimeStateSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("inspection"),
|
||||
protocolVersion: z.literal(1),
|
||||
inspection: CommandRuntimeInspectionSchema,
|
||||
}),
|
||||
z.object({ type: z.literal("ok"), protocolVersion: z.literal(1) }),
|
||||
]);
|
||||
|
||||
export const CommandRuntimeDescribeResponseSchema = z.object({
|
||||
protocolVersion: z.literal(1),
|
||||
modes: z.array(z.enum(["pipes"])),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
export type WorkspaceRuntimeId = string;
|
||||
export type WorkspaceId = string;
|
||||
|
||||
export type WorkspaceProjectSource =
|
||||
| { kind: "host-directory"; path: string }
|
||||
| { kind: "git"; url: string; revision: string; subdirectory?: string };
|
||||
|
||||
export type WorkspacePlacementIntent =
|
||||
| { kind: "existing"; relativeCwd?: string }
|
||||
| {
|
||||
kind: "branch";
|
||||
branchName: string;
|
||||
baseRef: string;
|
||||
relativeCwd?: string;
|
||||
worktreeSlug?: string;
|
||||
}
|
||||
| { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string };
|
||||
|
||||
export interface WorkspaceDriverCreateInput {
|
||||
workspaceId: WorkspaceId;
|
||||
project: { projectId: string; source: WorkspaceProjectSource };
|
||||
placement: WorkspacePlacementIntent;
|
||||
}
|
||||
|
||||
export interface WorkspaceDriverState {
|
||||
workspaceId: WorkspaceId;
|
||||
root: string;
|
||||
revision: string;
|
||||
executionDomainId: string;
|
||||
lifecycle: "ready" | "paused";
|
||||
}
|
||||
|
||||
export type WorkspaceDriverInspection =
|
||||
| { status: "missing" }
|
||||
| { status: "paused"; state: WorkspaceDriverState }
|
||||
| { status: "ready"; state: WorkspaceDriverState }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export interface WorkspaceDriverSpawnInput {
|
||||
workspaceId: WorkspaceId;
|
||||
cwd?: string;
|
||||
argv: readonly [string, ...string[]];
|
||||
env: Readonly<Record<string, string>>;
|
||||
purpose:
|
||||
| { kind: "agent"; agentId: string; provider: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "git" }
|
||||
| { kind: "provider-probe"; provider: string }
|
||||
| { kind: "workspace-helper" }
|
||||
| { kind: "workspace-script"; script: string }
|
||||
| { kind: "setup" }
|
||||
| { kind: "archive" };
|
||||
stdio: { kind: "pipes" };
|
||||
}
|
||||
|
||||
export interface WorkspaceProcessExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export interface WorkspacePipeProcess {
|
||||
kind: "pipes";
|
||||
stdin: Writable;
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
exited: Promise<WorkspaceProcessExit>;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeDriver {
|
||||
readonly id: WorkspaceRuntimeId;
|
||||
create(input: WorkspaceDriverCreateInput): Promise<WorkspaceDriverState>;
|
||||
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
|
||||
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspacePipeProcess>;
|
||||
pause(workspaceId: WorkspaceId): Promise<void>;
|
||||
resume(workspaceId: WorkspaceId): Promise<WorkspaceDriverState>;
|
||||
destroy(workspaceId: WorkspaceId): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import { createCommandRuntimeAdapter } from "./command/index.js";
|
||||
import { createLocalRuntime } from "./internal/local-runtime.js";
|
||||
import { createService } from "./internal/service.js";
|
||||
import { createWorktreeRuntime } from "./internal/worktree-runtime.js";
|
||||
|
||||
export type WorkspaceSource =
|
||||
| { kind: "host-directory"; path: string }
|
||||
| { kind: "git"; url: string; revision: string; subdirectory?: string };
|
||||
|
||||
export type WorkspacePlacement =
|
||||
| { kind: "existing"; relativeCwd?: string }
|
||||
| {
|
||||
kind: "branch";
|
||||
branchName: string;
|
||||
baseRef: string;
|
||||
relativeCwd?: string;
|
||||
worktreeSlug?: string;
|
||||
}
|
||||
| { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string };
|
||||
|
||||
export type WorkspaceProcessPurpose =
|
||||
| { kind: "agent"; agentId: string; provider: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "git" }
|
||||
| { kind: "provider-probe"; provider: string }
|
||||
| { kind: "workspace-helper" }
|
||||
| { kind: "workspace-script"; script: string }
|
||||
| { kind: "setup" }
|
||||
| { kind: "archive" };
|
||||
|
||||
export interface WorkspaceSetupCommand {
|
||||
cwd?: string;
|
||||
argv: readonly [string, ...string[]];
|
||||
env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceInput {
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
project: { id: string; source: WorkspaceSource };
|
||||
placement: WorkspacePlacement;
|
||||
setup?: readonly WorkspaceSetupCommand[];
|
||||
}
|
||||
|
||||
export interface WorkspaceProcessInput extends WorkspaceSetupCommand {
|
||||
workspaceId: string;
|
||||
purpose: WorkspaceProcessPurpose;
|
||||
}
|
||||
|
||||
export interface WorkspaceProcessExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceProcess {
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly exited: Promise<WorkspaceProcessExit>;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeService {
|
||||
create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>;
|
||||
run(input: WorkspaceProcessInput): Promise<WorkspaceProcess>;
|
||||
pause(workspaceId: string): Promise<void>;
|
||||
resume(workspaceId: string): Promise<void>;
|
||||
destroy(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeRecordStore {
|
||||
resolveRuntimeId(workspaceId: string): Promise<string | null>;
|
||||
persistRuntimeId(workspaceId: string, runtimeId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExternalWorkspaceRuntime {
|
||||
type: "command";
|
||||
command: readonly [string, ...string[]];
|
||||
options?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
externalRuntimes?: Readonly<Record<string, ExternalWorkspaceRuntime>>;
|
||||
}
|
||||
|
||||
export function createWorkspaceRuntimeService(
|
||||
options: WorkspaceRuntimeOptions,
|
||||
): WorkspaceRuntimeService {
|
||||
const externalDrivers = Object.entries(options.externalRuntimes ?? {}).map(
|
||||
([runtimeId, config]) => {
|
||||
if (runtimeId === "local" || runtimeId === "worktree") {
|
||||
throw new Error(`Workspace runtime id is reserved: ${runtimeId}`);
|
||||
}
|
||||
return createCommandRuntimeAdapter(runtimeId, config);
|
||||
},
|
||||
);
|
||||
return createService(
|
||||
[
|
||||
createLocalRuntime(options.paseoHome),
|
||||
createWorktreeRuntime({
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
}),
|
||||
...externalDrivers,
|
||||
],
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
WorkspaceDriverSpawnInput,
|
||||
WorkspacePipeProcess,
|
||||
WorkspaceProcessExit,
|
||||
} from "../drivers/index.js";
|
||||
|
||||
export async function spawnHostProcess(
|
||||
root: string,
|
||||
input: WorkspaceDriverSpawnInput,
|
||||
): Promise<WorkspacePipeProcess> {
|
||||
const cwd = await resolveRuntimeCwd(root, input.cwd);
|
||||
const child = spawn(input.argv[0], input.argv.slice(1), {
|
||||
cwd,
|
||||
env: { ...input.env },
|
||||
shell: false,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const exited = new Promise<WorkspaceProcessExit>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => {
|
||||
resolve({ code, signal: signal as NodeJS.Signals | null });
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "pipes",
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
exited,
|
||||
kill(signal = "SIGTERM") {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
if (process.platform !== "win32" && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// The process can exit between the state check and the signal.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRuntimeCwd(root: string, relativeCwd?: string): Promise<string> {
|
||||
const normalizedRoot = await realpath(root);
|
||||
const cwd = await realpath(path.resolve(normalizedRoot, relativeCwd ?? "."));
|
||||
const relative = path.relative(normalizedRoot, cwd);
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Workspace cwd escapes its runtime root: ${relativeCwd}`);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
WorkspaceDriverCreateInput,
|
||||
WorkspaceDriverInspection,
|
||||
WorkspaceDriverSpawnInput,
|
||||
WorkspaceDriverState,
|
||||
WorkspaceRuntimeDriver,
|
||||
} from "../drivers/index.js";
|
||||
import { resolveRuntimeCwd, spawnHostProcess } from "./host-process.js";
|
||||
import { createRuntimeStateStore } from "./runtime-state.js";
|
||||
|
||||
export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver {
|
||||
const states = createRuntimeStateStore(paseoHome, "local");
|
||||
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceDriverInspection> {
|
||||
const state = await states.read(workspaceId);
|
||||
if (!state) return { status: "missing" };
|
||||
try {
|
||||
if (!(await stat(state.root)).isDirectory()) return { status: "missing" };
|
||||
return { status: state.lifecycle, state };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" };
|
||||
return { status: "error", message: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function requireReady(workspaceId: string): Promise<WorkspaceDriverState> {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status !== "ready") {
|
||||
throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
return inspection.state;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "local",
|
||||
async create(input: WorkspaceDriverCreateInput) {
|
||||
const existing = await inspect(input.workspaceId);
|
||||
if (existing.status === "ready" || existing.status === "paused") return existing.state;
|
||||
if (input.project.source.kind !== "host-directory" || input.placement.kind !== "existing") {
|
||||
throw new Error("The local runtime adopts an existing host directory");
|
||||
}
|
||||
const root = await resolveRuntimeCwd(
|
||||
path.resolve(input.project.source.path),
|
||||
input.placement.relativeCwd,
|
||||
);
|
||||
if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`);
|
||||
const state: WorkspaceDriverState = {
|
||||
workspaceId: input.workspaceId,
|
||||
root,
|
||||
revision: "local:1",
|
||||
executionDomainId: "host",
|
||||
lifecycle: "ready",
|
||||
};
|
||||
await states.write(state);
|
||||
return state;
|
||||
},
|
||||
inspect,
|
||||
async spawn(input: WorkspaceDriverSpawnInput) {
|
||||
return spawnHostProcess((await requireReady(input.workspaceId)).root, input);
|
||||
},
|
||||
async pause(workspaceId) {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status === "paused") return;
|
||||
if (inspection.status !== "ready") {
|
||||
throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
await states.write({ ...inspection.state, lifecycle: "paused" });
|
||||
},
|
||||
async resume(workspaceId) {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status === "missing" || inspection.status === "error") {
|
||||
throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
const state = { ...inspection.state, lifecycle: "ready" as const };
|
||||
await states.write(state);
|
||||
return state;
|
||||
},
|
||||
async destroy(workspaceId) {
|
||||
await states.remove(workspaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WorkspaceDriverState } from "../drivers/index.js";
|
||||
|
||||
export function createRuntimeStateStore(paseoHome: string, runtimeId: string) {
|
||||
const directory = path.join(paseoHome, "workspace-runtimes", runtimeId);
|
||||
const fileFor = (workspaceId: string) =>
|
||||
path.join(directory, `${createHash("sha256").update(workspaceId).digest("hex")}.json`);
|
||||
return {
|
||||
async read(workspaceId: string): Promise<WorkspaceDriverState | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(fileFor(workspaceId), "utf8")) as unknown;
|
||||
if (!isWorkspaceDriverState(parsed, workspaceId)) return null;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async write(state: WorkspaceDriverState): Promise<void> {
|
||||
await mkdir(directory, { recursive: true });
|
||||
const target = fileFor(state.workspaceId);
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
||||
await rename(temporary, target);
|
||||
},
|
||||
async remove(workspaceId: string): Promise<void> {
|
||||
await rm(fileFor(workspaceId), { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isWorkspaceDriverState(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
): value is WorkspaceDriverState {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const state = value as Partial<WorkspaceDriverState>;
|
||||
return (
|
||||
state.workspaceId === workspaceId &&
|
||||
typeof state.root === "string" &&
|
||||
typeof state.revision === "string" &&
|
||||
typeof state.executionDomainId === "string" &&
|
||||
(state.lifecycle === "ready" || state.lifecycle === "paused")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
CreateWorkspaceInput,
|
||||
WorkspaceProcess,
|
||||
WorkspaceProcessInput,
|
||||
WorkspaceRuntimeRecordStore,
|
||||
WorkspaceRuntimeService,
|
||||
} from "../index.js";
|
||||
import type {
|
||||
WorkspaceDriverCreateInput,
|
||||
WorkspacePipeProcess,
|
||||
WorkspaceRuntimeDriver,
|
||||
} from "../drivers/index.js";
|
||||
|
||||
const gracefulStopMilliseconds = 1_000;
|
||||
const forcedStopMilliseconds = 1_000;
|
||||
|
||||
export function createService(
|
||||
drivers: readonly WorkspaceRuntimeDriver[],
|
||||
records: WorkspaceRuntimeRecordStore,
|
||||
): WorkspaceRuntimeService {
|
||||
const driversById = new Map(drivers.map((driver) => [driver.id, driver]));
|
||||
const processesByWorkspaceId = new Map<string, Set<WorkspacePipeProcess>>();
|
||||
const workspaceTails = new Map<string, Promise<void>>();
|
||||
|
||||
function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver {
|
||||
const driver = driversById.get(runtimeId);
|
||||
if (!driver) throw new Error(`Workspace runtime is not registered: ${runtimeId}`);
|
||||
return driver;
|
||||
}
|
||||
|
||||
async function resolve(workspaceId: string): Promise<WorkspaceRuntimeDriver> {
|
||||
const runtimeId = await records.resolveRuntimeId(workspaceId);
|
||||
if (!runtimeId) throw new Error(`Workspace runtime is not selected: ${workspaceId}`);
|
||||
return requireRegistered(runtimeId);
|
||||
}
|
||||
|
||||
async function runWithDriver(
|
||||
driver: WorkspaceRuntimeDriver,
|
||||
input: WorkspaceProcessInput,
|
||||
): Promise<WorkspaceProcess> {
|
||||
validateRelativeCwd(input.cwd);
|
||||
const inspection = await driver.inspect(input.workspaceId);
|
||||
if (inspection.status !== "ready") {
|
||||
throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`);
|
||||
}
|
||||
const runtimeProcess = await driver.spawn({ ...input, stdio: { kind: "pipes" } });
|
||||
const processes = processesByWorkspaceId.get(input.workspaceId) ?? new Set();
|
||||
processes.add(runtimeProcess);
|
||||
processesByWorkspaceId.set(input.workspaceId, processes);
|
||||
void runtimeProcess.exited.then(
|
||||
() => forgetProcess(input.workspaceId, runtimeProcess),
|
||||
() => forgetProcess(input.workspaceId, runtimeProcess),
|
||||
);
|
||||
return runtimeProcess;
|
||||
}
|
||||
|
||||
return {
|
||||
async create(input) {
|
||||
return sequence(input.workspaceId, async () => {
|
||||
const selectedRuntimeId = await records.resolveRuntimeId(input.workspaceId);
|
||||
if (selectedRuntimeId && selectedRuntimeId !== input.runtimeId) {
|
||||
throw new Error(
|
||||
`Workspace runtime is already selected as ${selectedRuntimeId}: ${input.workspaceId}`,
|
||||
);
|
||||
}
|
||||
const driver = requireRegistered(input.runtimeId);
|
||||
const before = await driver.inspect(input.workspaceId);
|
||||
let ownsNewResource = false;
|
||||
try {
|
||||
await driver.create(toDriverCreateInput(input));
|
||||
ownsNewResource = before.status === "missing";
|
||||
if (ownsNewResource) {
|
||||
for (const command of input.setup ?? []) {
|
||||
const process = await runWithDriver(driver, {
|
||||
workspaceId: input.workspaceId,
|
||||
...command,
|
||||
purpose: { kind: "setup" },
|
||||
});
|
||||
process.stdin.end();
|
||||
const [, , exit] = await Promise.all([
|
||||
drain(process.stdout),
|
||||
drain(process.stderr),
|
||||
process.exited,
|
||||
]);
|
||||
if (exit.code !== 0 || exit.signal !== null) {
|
||||
throw new Error(`Workspace setup failed: ${exit.code ?? exit.signal}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await records.persistRuntimeId(input.workspaceId, input.runtimeId);
|
||||
return { workspaceId: input.workspaceId, runtimeId: input.runtimeId };
|
||||
} catch (error) {
|
||||
if (ownsNewResource) {
|
||||
try {
|
||||
await stopProcesses(input.workspaceId);
|
||||
await driver.destroy(input.workspaceId);
|
||||
} catch (cleanupError) {
|
||||
throw new Error(`Workspace creation failed before cleanup: ${String(error)}`, {
|
||||
cause: cleanupError,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async run(input) {
|
||||
return sequence(input.workspaceId, async () =>
|
||||
runWithDriver(await resolve(input.workspaceId), input),
|
||||
);
|
||||
},
|
||||
async pause(workspaceId) {
|
||||
await sequence(workspaceId, async () => {
|
||||
const driver = await resolve(workspaceId);
|
||||
await stopProcesses(workspaceId);
|
||||
await driver.pause(workspaceId);
|
||||
});
|
||||
},
|
||||
async resume(workspaceId) {
|
||||
await sequence(workspaceId, async () => {
|
||||
await (await resolve(workspaceId)).resume(workspaceId);
|
||||
});
|
||||
},
|
||||
async destroy(workspaceId) {
|
||||
await sequence(workspaceId, async () => {
|
||||
const driver = await resolve(workspaceId);
|
||||
await stopProcesses(workspaceId);
|
||||
await driver.destroy(workspaceId);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function sequence<T>(workspaceId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = workspaceTails.get(workspaceId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolveGate) => {
|
||||
release = resolveGate;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
workspaceTails.set(workspaceId, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
if (workspaceTails.get(workspaceId) === tail) workspaceTails.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
function forgetProcess(workspaceId: string, runtimeProcess: WorkspacePipeProcess): void {
|
||||
const processes = processesByWorkspaceId.get(workspaceId);
|
||||
processes?.delete(runtimeProcess);
|
||||
if (processes?.size === 0) processesByWorkspaceId.delete(workspaceId);
|
||||
}
|
||||
|
||||
async function stopProcesses(workspaceId: string): Promise<void> {
|
||||
const processes = [...(processesByWorkspaceId.get(workspaceId) ?? [])];
|
||||
for (const runtimeProcess of processes) runtimeProcess.kill("SIGTERM");
|
||||
const graceful = await waitForAll(processes, gracefulStopMilliseconds);
|
||||
if (graceful) return;
|
||||
for (const runtimeProcess of processes) runtimeProcess.kill("SIGKILL");
|
||||
if (!(await waitForAll(processes, forcedStopMilliseconds))) {
|
||||
throw new Error(`Workspace processes did not stop: ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
for await (const _chunk of stream) {
|
||||
// Setup output is consumed here so a verbose command cannot block on a full pipe.
|
||||
}
|
||||
}
|
||||
|
||||
function validateRelativeCwd(cwd: string | undefined): void {
|
||||
if (cwd === undefined) return;
|
||||
const parts = cwd.split(/[\\/]/u);
|
||||
if (cwd.startsWith("/") || cwd.includes("\\") || parts.includes("..")) {
|
||||
throw new Error(`Workspace cwd must stay within the runtime root: ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreateInput {
|
||||
return {
|
||||
workspaceId: input.workspaceId,
|
||||
project: {
|
||||
projectId: input.project.id,
|
||||
source: input.project.source,
|
||||
},
|
||||
placement: input.placement,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForAll(
|
||||
processes: readonly WorkspacePipeProcess[],
|
||||
timeoutMilliseconds: number,
|
||||
): Promise<boolean> {
|
||||
if (processes.length === 0) return true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMilliseconds);
|
||||
});
|
||||
const exited = Promise.allSettled(processes.map((runtimeProcess) => runtimeProcess.exited)).then(
|
||||
() => true as const,
|
||||
);
|
||||
const result = await Promise.race([exited, timedOut]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createWorktree,
|
||||
deletePaseoWorktree,
|
||||
getGitCommonDir,
|
||||
type WorktreeSource,
|
||||
} from "../../../utils/worktree.js";
|
||||
import type {
|
||||
WorkspaceDriverCreateInput,
|
||||
WorkspaceDriverInspection,
|
||||
WorkspaceDriverSpawnInput,
|
||||
WorkspaceDriverState,
|
||||
WorkspaceRuntimeDriver,
|
||||
} from "../drivers/index.js";
|
||||
import { resolveRuntimeCwd, spawnHostProcess } from "./host-process.js";
|
||||
import { createRuntimeStateStore } from "./runtime-state.js";
|
||||
|
||||
interface WorktreeRuntimeState extends WorkspaceDriverState {
|
||||
sourceRoot: string;
|
||||
worktreeRoot: string;
|
||||
}
|
||||
|
||||
export function createWorktreeRuntime(options: {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
}): WorkspaceRuntimeDriver {
|
||||
const states = createRuntimeStateStore(options.paseoHome, "worktree");
|
||||
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceDriverInspection> {
|
||||
const state = await states.read(workspaceId);
|
||||
if (!state) return { status: "missing" };
|
||||
try {
|
||||
if (!(await stat(state.root)).isDirectory()) return { status: "missing" };
|
||||
return { status: state.lifecycle, state };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" };
|
||||
return { status: "error", message: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function requireReady(workspaceId: string): Promise<WorkspaceDriverState> {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status !== "ready") {
|
||||
throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
return inspection.state;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "worktree",
|
||||
async create(input: WorkspaceDriverCreateInput) {
|
||||
const existing = await inspect(input.workspaceId);
|
||||
if (existing.status === "ready" || existing.status === "paused") return existing.state;
|
||||
if (input.project.source.kind !== "host-directory") {
|
||||
throw new Error("The worktree runtime requires a host Git checkout");
|
||||
}
|
||||
if (input.placement.kind === "existing") {
|
||||
throw new Error("The worktree runtime creates and owns its worktree");
|
||||
}
|
||||
const sourceRoot = path.resolve(input.project.source.path);
|
||||
const worktree = await createWorktree({
|
||||
cwd: sourceRoot,
|
||||
source: toWorktreeSource(input.placement),
|
||||
worktreeSlug: input.placement.worktreeSlug ?? input.workspaceId,
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
});
|
||||
try {
|
||||
const root = await resolveRuntimeCwd(worktree.worktreePath, input.placement.relativeCwd);
|
||||
if (!(await stat(root)).isDirectory()) {
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${root}`);
|
||||
}
|
||||
const state: WorktreeRuntimeState = {
|
||||
workspaceId: input.workspaceId,
|
||||
root,
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
sourceRoot,
|
||||
revision: `worktree:${Date.now()}`,
|
||||
executionDomainId: "host",
|
||||
lifecycle: "ready",
|
||||
};
|
||||
await states.write(state);
|
||||
return state;
|
||||
} catch (error) {
|
||||
await deletePaseoWorktree({
|
||||
cwd: sourceRoot,
|
||||
worktreePath: worktree.worktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
inspect,
|
||||
async spawn(input: WorkspaceDriverSpawnInput) {
|
||||
return spawnHostProcess((await requireReady(input.workspaceId)).root, input);
|
||||
},
|
||||
async pause(workspaceId) {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status === "paused") return;
|
||||
if (inspection.status !== "ready") {
|
||||
throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
await states.write({ ...inspection.state, lifecycle: "paused" });
|
||||
},
|
||||
async resume(workspaceId) {
|
||||
const inspection = await inspect(workspaceId);
|
||||
if (inspection.status === "missing" || inspection.status === "error") {
|
||||
throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`);
|
||||
}
|
||||
const state = { ...inspection.state, lifecycle: "ready" as const };
|
||||
await states.write(state);
|
||||
return state;
|
||||
},
|
||||
async destroy(workspaceId) {
|
||||
const state = (await states.read(workspaceId)) as WorktreeRuntimeState | null;
|
||||
if (!state) return;
|
||||
const sourceRoot =
|
||||
typeof state.sourceRoot === "string"
|
||||
? state.sourceRoot
|
||||
: path.dirname(await getGitCommonDir(state.root));
|
||||
const worktreeRoot = typeof state.worktreeRoot === "string" ? state.worktreeRoot : state.root;
|
||||
await deletePaseoWorktree({
|
||||
cwd: sourceRoot,
|
||||
worktreePath: worktreeRoot,
|
||||
teardownCwds: [],
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
});
|
||||
await states.remove(workspaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toWorktreeSource(
|
||||
placement: Exclude<WorkspaceDriverCreateInput["placement"], { kind: "existing" }>,
|
||||
): WorktreeSource {
|
||||
if (placement.kind === "branch") {
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: placement.baseRef,
|
||||
branchName: placement.branchName,
|
||||
};
|
||||
}
|
||||
return { kind: "checkout-branch", branchName: placement.ref };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { createWorkspaceRuntimeService } from "./index.js";
|
||||
|
||||
const fixtureExecutable = fileURLToPath(
|
||||
new URL("../test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url),
|
||||
);
|
||||
const cleanupRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("a trusted registered command is selected and receives secret launch data off argv", async () => {
|
||||
const fixture = await createFixture("registered");
|
||||
await fixture.service.create({
|
||||
...fixture.createInput,
|
||||
setup: [{ argv: ["/bin/sh", "-c", "printf setup > setup-purpose.txt"], env: {} }],
|
||||
});
|
||||
const setupLaunch = JSON.parse(
|
||||
await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"),
|
||||
) as { purpose: unknown };
|
||||
expect(setupLaunch.purpose).toEqual({ kind: "setup" });
|
||||
const secret = "secret-shaped-workload-value";
|
||||
const process = await fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: [
|
||||
processExecPath(),
|
||||
"-e",
|
||||
"require('fs').writeFileSync('workload-secret.txt', process.env.SECRET_TOKEN)",
|
||||
],
|
||||
env: { SECRET_TOKEN: secret },
|
||||
purpose: { kind: "workspace-script", script: "secure-envelope-contract" },
|
||||
});
|
||||
process.stdin.end();
|
||||
await expect(process.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
expect(await readFile(path.join(fixture.source, "workload-secret.txt"), "utf8")).toBe(secret);
|
||||
const launch = JSON.parse(
|
||||
await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"),
|
||||
) as { argv: string[]; purpose: unknown };
|
||||
expect(JSON.stringify(launch.argv)).not.toContain(secret);
|
||||
expect(launch.purpose).toEqual({
|
||||
kind: "workspace-script",
|
||||
script: "secure-envelope-contract",
|
||||
});
|
||||
|
||||
await expect(
|
||||
fixture.service.create({
|
||||
...fixture.createInput,
|
||||
workspaceId: "unregistered",
|
||||
runtimeId: "nope",
|
||||
}),
|
||||
).rejects.toThrow("Workspace runtime is not registered: nope");
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
test("run admission racing pause cannot leave an unregistered workload running", async () => {
|
||||
const fixture = await createFixture("race", true);
|
||||
await fixture.service.create(fixture.createInput);
|
||||
await writeFile(path.join(fixture.barrierDirectory, "block-next-inspect"), "block");
|
||||
|
||||
const runPromise = fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: [processExecPath(), "-e", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "pause-race-contract" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(existsSync(path.join(fixture.barrierDirectory, "inspect-entered"))).toBe(true),
|
||||
);
|
||||
const pausePromise = fixture.service.pause(fixture.workspaceId);
|
||||
await writeFile(path.join(fixture.barrierDirectory, "release-inspect"), "release");
|
||||
|
||||
const workload = await runPromise;
|
||||
workload.stdin.end();
|
||||
await pausePromise;
|
||||
await expect(workload.exited).resolves.toMatchObject({ code: null });
|
||||
await expect(
|
||||
fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "paused-admission-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`);
|
||||
await fixture.service.resume(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
}, 15_000);
|
||||
|
||||
test("an existing runtime selection cannot be switched before target driver dispatch", async () => {
|
||||
const fixture = await createFixture("immutable-selection");
|
||||
await fixture.service.create({ ...fixture.createInput, runtimeId: "local" });
|
||||
|
||||
await expect(fixture.service.create(fixture.createInput)).rejects.toThrow(
|
||||
`Workspace runtime is already selected as local: ${fixture.workspaceId}`,
|
||||
);
|
||||
expect(await readdir(fixture.stateDirectory)).toEqual([]);
|
||||
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
async function createFixture(name: string, withBarrier = false) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `paseo-command-runtime-${name}-`));
|
||||
cleanupRoots.push(root);
|
||||
const source = path.join(root, "source");
|
||||
const stateDirectory = path.join(root, "state");
|
||||
const barrierDirectory = path.join(root, "barrier");
|
||||
await Promise.all([mkdir(source), mkdir(stateDirectory), mkdir(barrierDirectory)]);
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const workspaceId = `${name}-workspace`;
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
|
||||
persistRuntimeId: async (id, runtimeId) => {
|
||||
runtimeIds.set(id, runtimeId);
|
||||
},
|
||||
externalRuntimes: {
|
||||
fixture: {
|
||||
type: "command",
|
||||
command: [processExecPath(), fixtureExecutable],
|
||||
options: {
|
||||
stateDirectory,
|
||||
...(withBarrier ? { inspectBarrierDirectory: barrierDirectory } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
source,
|
||||
stateDirectory,
|
||||
barrierDirectory,
|
||||
workspaceId,
|
||||
service,
|
||||
createInput: {
|
||||
workspaceId,
|
||||
runtimeId: "fixture",
|
||||
project: { id: `${name}-project`, source: { kind: "host-directory" as const, path: source } },
|
||||
placement: { kind: "existing" as const },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function processExecPath(): string {
|
||||
return process.execPath;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
||||
|
||||
import { createWorkspaceRuntimeService } from "./index.js";
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
|
||||
const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime");
|
||||
const runtimeExecutable = path.join(dockerRuntimeRoot, "src/index.ts");
|
||||
const image = "paseo-workspace-runtime-slice1:test";
|
||||
const cleanupRoots: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync("docker", ["build", "-t", image, dockerRuntimeRoot], { stdio: "pipe" });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(cleanupRoots.map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function collect(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
test("the command runtime owns Docker workspace materialization, pipes, and lifecycle", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-"));
|
||||
cleanupRoots.push(root);
|
||||
const repo = await createRepository(root);
|
||||
await writeFile(path.join(repo, ".env"), "must-not-enter-runtime\n");
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const options = {
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async (workspaceId: string) => runtimeIds.get(workspaceId) ?? null,
|
||||
persistRuntimeId: async (workspaceId: string, runtimeId: string) => {
|
||||
runtimeIds.set(workspaceId, runtimeId);
|
||||
},
|
||||
externalRuntimes: {
|
||||
docker: {
|
||||
type: "command",
|
||||
command: [process.execPath, "--import", "tsx", runtimeExecutable] as const,
|
||||
options: { image },
|
||||
},
|
||||
},
|
||||
};
|
||||
const service = createWorkspaceRuntimeService(options);
|
||||
const workspaceId = `docker-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const workspace = await service.create({
|
||||
workspaceId,
|
||||
runtimeId: "docker",
|
||||
project: {
|
||||
id: "docker-project",
|
||||
source: { kind: "host-directory", path: repo },
|
||||
},
|
||||
placement: { kind: "existing" },
|
||||
});
|
||||
expect(workspace).toEqual({ workspaceId, runtimeId: "docker" });
|
||||
expect("cwd" in workspace).toBe(false);
|
||||
|
||||
const write = await service.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/sh", "-c", "cat > runtime-owned.txt; printf runtime-stderr >&2; exit 19"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "write-contract" },
|
||||
});
|
||||
write.stdin.end("docker-state");
|
||||
const [stderr, exit] = await Promise.all([collect(write.stderr), write.exited]);
|
||||
expect(stderr).toBe("runtime-stderr");
|
||||
expect(exit).toEqual({ code: 19, signal: null });
|
||||
|
||||
const env = await service.run({
|
||||
workspaceId,
|
||||
argv: ["/usr/bin/env"],
|
||||
env: { RUNTIME_EXACT_ENV: "docker" },
|
||||
purpose: { kind: "workspace-script", script: "environment-contract" },
|
||||
});
|
||||
env.stdin.end();
|
||||
await expect(collect(env.stdout)).resolves.toBe("RUNTIME_EXACT_ENV=docker\n");
|
||||
await expect(env.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
|
||||
await service.pause(workspaceId);
|
||||
await expect(
|
||||
service.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "paused-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is paused: ${workspaceId}`);
|
||||
|
||||
const recovered = createWorkspaceRuntimeService(options);
|
||||
await recovered.resume(workspaceId);
|
||||
await expect(
|
||||
recovered.run({
|
||||
workspaceId,
|
||||
cwd: "../escape",
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "cwd-contract" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace cwd must stay within the runtime root");
|
||||
const read = await recovered.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/cat", "runtime-owned.txt"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "read-contract" },
|
||||
});
|
||||
read.stdin.end();
|
||||
await expect(collect(read.stdout)).resolves.toBe("docker-state");
|
||||
await expect(read.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
const committedOnly = await recovered.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/sh", "-c", "test ! -e .env"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "materialization-contract" },
|
||||
});
|
||||
committedOnly.stdin.end();
|
||||
await expect(committedOnly.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
expect(execFileSync("git", ["status", "--short"], { cwd: repo, encoding: "utf8" })).toBe(
|
||||
"?? .env\n",
|
||||
);
|
||||
|
||||
const killed = await recovered.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/sh", "-c", "sleep 30 & echo ready; wait"],
|
||||
env: { PATH: "/usr/bin:/bin" },
|
||||
purpose: { kind: "workspace-script", script: "signal-contract" },
|
||||
});
|
||||
killed.stdin.end();
|
||||
await expect(waitForOutput(killed.stdout, "ready")).resolves.toContain("ready");
|
||||
const killedAt = Date.now();
|
||||
killed.kill("SIGTERM");
|
||||
await expect(killed.exited).resolves.toEqual({ code: null, signal: "SIGTERM" });
|
||||
expect(Date.now() - killedAt).toBeLessThan(5_000);
|
||||
await vi.waitFor(() => expect(dockerProcesses(workspaceId)).not.toContain("sleep 30"));
|
||||
|
||||
const stubborn = await recovered.run({
|
||||
workspaceId,
|
||||
argv: [
|
||||
"/usr/local/bin/node",
|
||||
"-e",
|
||||
"process.on('SIGTERM',()=>{});console.log('stubborn-ready');setInterval(()=>{},1000)",
|
||||
],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "escalation-contract" },
|
||||
});
|
||||
stubborn.stdin.end();
|
||||
await expect(waitForOutput(stubborn.stdout, "stubborn-ready")).resolves.toContain(
|
||||
"stubborn-ready",
|
||||
);
|
||||
const pauseStartedAt = Date.now();
|
||||
await recovered.pause(workspaceId);
|
||||
expect(Date.now() - pauseStartedAt).toBeLessThan(5_000);
|
||||
await expect(stubborn.exited).resolves.toEqual({ code: null, signal: "SIGTERM" });
|
||||
expect(
|
||||
execFileSync(
|
||||
"docker",
|
||||
["inspect", "--format", "{{.State.Running}}", dockerResourceName(workspaceId)],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
).trim(),
|
||||
).toBe("false");
|
||||
|
||||
await recovered.destroy(workspaceId);
|
||||
expect(dockerResourceCount("ps", workspaceId)).toBe(0);
|
||||
expect(dockerResourceCount("volume", workspaceId)).toBe(0);
|
||||
expect(await readFileFromHost(repo, "runtime-owned.txt")).toBeNull();
|
||||
await expect(
|
||||
recovered.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "missing-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is missing: ${workspaceId}`);
|
||||
} finally {
|
||||
await service.destroy(workspaceId).catch(() => undefined);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-rollback-"));
|
||||
cleanupRoots.push(root);
|
||||
const repo = await createRepository(root);
|
||||
const workspaceId = `docker-rollback-${Date.now()}`;
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async () => null,
|
||||
persistRuntimeId: async () => {
|
||||
throw new Error("persistence failed");
|
||||
},
|
||||
externalRuntimes: {
|
||||
docker: {
|
||||
type: "command",
|
||||
command: [process.execPath, "--import", "tsx", runtimeExecutable],
|
||||
options: { image },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId: `${workspaceId}-escape`,
|
||||
runtimeId: "docker",
|
||||
project: { id: "docker-project", source: { kind: "host-directory", path: repo } },
|
||||
placement: { kind: "existing", relativeCwd: "../escape" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace cwd escapes its root");
|
||||
expect(dockerResourceCount("ps", `${workspaceId}-escape`)).toBe(0);
|
||||
expect(dockerResourceCount("volume", `${workspaceId}-escape`)).toBe(0);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId,
|
||||
runtimeId: "docker",
|
||||
project: { id: "docker-project", source: { kind: "host-directory", path: repo } },
|
||||
placement: { kind: "existing" },
|
||||
}),
|
||||
).rejects.toThrow("persistence failed");
|
||||
expect(dockerResourceCount("ps", workspaceId)).toBe(0);
|
||||
expect(dockerResourceCount("volume", workspaceId)).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
test("Docker rejects a committed symlink cwd that resolves outside the selected root", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-symlink-"));
|
||||
cleanupRoots.push(root);
|
||||
const repo = await createRepository(root);
|
||||
await mkdir(path.join(repo, "selected"));
|
||||
await symlink("..", path.join(repo, "selected", "escape"));
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "symlink fixture"], {
|
||||
cwd: repo,
|
||||
});
|
||||
const workspaceId = `docker-symlink-${Date.now()}`;
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
|
||||
persistRuntimeId: async (id, runtimeId) => {
|
||||
runtimeIds.set(id, runtimeId);
|
||||
},
|
||||
externalRuntimes: {
|
||||
docker: {
|
||||
type: "command",
|
||||
command: [process.execPath, "--import", "tsx", runtimeExecutable],
|
||||
options: { image },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.create({
|
||||
workspaceId,
|
||||
runtimeId: "docker",
|
||||
project: { id: "symlink-project", source: { kind: "host-directory", path: repo } },
|
||||
placement: { kind: "existing", relativeCwd: "selected" },
|
||||
});
|
||||
const escaped = await service.run({
|
||||
workspaceId,
|
||||
cwd: "escape",
|
||||
argv: ["/bin/sh", "-c", "touch escaped-marker"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "realpath-confinement" },
|
||||
});
|
||||
escaped.stdin.end();
|
||||
const [escapedError, escapedExit] = await Promise.all([
|
||||
collect(escaped.stderr),
|
||||
escaped.exited,
|
||||
]);
|
||||
expect(escapedError).toContain("Workspace cwd escapes its root");
|
||||
expect(escapedExit.code).not.toBe(0);
|
||||
const verify = await service.run({
|
||||
workspaceId,
|
||||
argv: ["/bin/sh", "-c", "test ! -e ../escaped-marker"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "realpath-confinement-proof" },
|
||||
});
|
||||
verify.stdin.end();
|
||||
await expect(verify.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
} finally {
|
||||
await service.destroy(workspaceId).catch(() => undefined);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("the Docker runtime never adopts or destroys an unowned deterministic resource", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-ownership-"));
|
||||
cleanupRoots.push(root);
|
||||
const workspaceId = `docker-ownership-${Date.now()}`;
|
||||
const volume = dockerResourceName(workspaceId);
|
||||
execFileSync("docker", ["volume", "create", volume]);
|
||||
const runtimeIds = new Map([[workspaceId, "docker"]]);
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
|
||||
persistRuntimeId: async (id, runtimeId) => {
|
||||
runtimeIds.set(id, runtimeId);
|
||||
},
|
||||
externalRuntimes: {
|
||||
docker: {
|
||||
type: "command",
|
||||
command: [process.execPath, "--import", "tsx", runtimeExecutable],
|
||||
options: { image },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId,
|
||||
runtimeId: "docker",
|
||||
project: { id: "ownership-project", source: { kind: "host-directory", path: root } },
|
||||
placement: { kind: "existing" },
|
||||
}),
|
||||
).rejects.toThrow(`Docker volume ownership mismatch: ${volume}`);
|
||||
await expect(service.destroy(workspaceId)).rejects.toThrow(
|
||||
`Docker volume ownership mismatch: ${volume}`,
|
||||
);
|
||||
expect(
|
||||
execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", volume], {
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe(volume);
|
||||
} finally {
|
||||
execFileSync("docker", ["volume", "rm", volume]);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
async function createRepository(root: string): Promise<string> {
|
||||
const repo = path.join(root, "repo");
|
||||
await mkdir(repo);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo });
|
||||
await writeFile(path.join(repo, "committed.txt"), "committed\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
|
||||
cwd: repo,
|
||||
});
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
output += chunk.toString();
|
||||
if (!output.includes(marker)) return;
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
stream.off("end", onEnd);
|
||||
resolve(output);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
stream.off("data", onData);
|
||||
stream.off("end", onEnd);
|
||||
reject(error);
|
||||
};
|
||||
const onEnd = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
reject(new Error(`Stream ended before output marker: ${marker}`));
|
||||
};
|
||||
stream.on("data", onData);
|
||||
stream.once("error", onError);
|
||||
stream.once("end", onEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function dockerProcesses(workspaceId: string): string {
|
||||
const container = dockerResourceName(workspaceId);
|
||||
return execFileSync("docker", ["top", container, "-eo", "pid,args"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function dockerResourceName(workspaceId: string): string {
|
||||
return `paseo-ws-${createHash("sha256").update(workspaceId).digest("hex").slice(0, 20)}`;
|
||||
}
|
||||
|
||||
function dockerResourceCount(kind: "ps" | "volume", workspaceId: string): number {
|
||||
const args =
|
||||
kind === "ps"
|
||||
? ["ps", "-aq", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`]
|
||||
: ["volume", "ls", "-q", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`];
|
||||
return execFileSync("docker", args, { encoding: "utf8" }).trim() ? 1 : 0;
|
||||
}
|
||||
|
||||
async function readFileFromHost(root: string, name: string): Promise<string | null> {
|
||||
try {
|
||||
return await (await import("node:fs/promises")).readFile(path.join(root, name), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createWorkspaceRuntimeService,
|
||||
type CreateWorkspaceInput,
|
||||
type WorkspaceRuntimeOptions,
|
||||
type WorkspaceRuntimeService,
|
||||
} from "./index.js";
|
||||
|
||||
const cleanupRoots: string[] = [];
|
||||
const posixDescribe = describe.runIf(process.platform !== "win32");
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => {
|
||||
test("creates, pipes an exact environment, preserves status, and owns only its resources", async () => {
|
||||
const fixture = await createFixture(runtimeId);
|
||||
const created = await fixture.service.create(fixture.createInput);
|
||||
expect(created).toEqual({ workspaceId: fixture.workspaceId, runtimeId });
|
||||
expect("cwd" in created).toBe(false);
|
||||
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
expect(await readFile(path.join(runtimeRoot, "committed.txt"), "utf8")).toBe("committed\n");
|
||||
if (runtimeId === "worktree") {
|
||||
expect(await readFile(path.join(runtimeRoot, "setup-owned.txt"), "utf8")).toBe("setup\n");
|
||||
}
|
||||
|
||||
const child = await fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"cat > runtime-owned.txt; printf runtime-stdout; printf runtime-stderr >&2; exit 23",
|
||||
],
|
||||
env: { RUNTIME_EXACT_ENV: runtimeId },
|
||||
purpose: { kind: "workspace-script", script: "public-contract" },
|
||||
});
|
||||
child.stdin.end(`${runtimeId}-state`);
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
collect(child.stdout),
|
||||
collect(child.stderr),
|
||||
child.exited,
|
||||
]);
|
||||
expect({ stdout, stderr, exit }).toEqual({
|
||||
stdout: "runtime-stdout",
|
||||
stderr: "runtime-stderr",
|
||||
exit: { code: 23, signal: null },
|
||||
});
|
||||
|
||||
const env = await fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/usr/bin/env"],
|
||||
env: { RUNTIME_EXACT_ENV: runtimeId },
|
||||
purpose: { kind: "workspace-script", script: "environment-contract" },
|
||||
});
|
||||
env.stdin.end();
|
||||
await expect(collect(env.stdout)).resolves.toBe(`RUNTIME_EXACT_ENV=${runtimeId}\n`);
|
||||
await expect(env.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
|
||||
const signaled = await fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/bin/sleep", "30"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "signal-contract" },
|
||||
});
|
||||
signaled.stdin.end();
|
||||
signaled.kill("SIGTERM");
|
||||
await expect(signaled.exited).resolves.toEqual({ code: null, signal: "SIGTERM" });
|
||||
|
||||
await fixture.service.pause(fixture.workspaceId);
|
||||
const recovered = createWorkspaceRuntimeService(fixture.options);
|
||||
await expect(
|
||||
recovered.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "paused-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`);
|
||||
await recovered.resume(fixture.workspaceId);
|
||||
expect(await readFile(path.join(runtimeRoot, "runtime-owned.txt"), "utf8")).toBe(
|
||||
`${runtimeId}-state`,
|
||||
);
|
||||
|
||||
await recovered.destroy(fixture.workspaceId);
|
||||
expect(existsSync(fixture.repo)).toBe(true);
|
||||
expect(existsSync(runtimeRoot)).toBe(runtimeId === "local");
|
||||
await expect(
|
||||
recovered.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "missing-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is missing: ${fixture.workspaceId}`);
|
||||
});
|
||||
|
||||
test("repeated create does not rerun setup or change paused state", async () => {
|
||||
const fixture = await createFixture(runtimeId);
|
||||
const setupMarker = "idempotent-setup.txt";
|
||||
const input = {
|
||||
...fixture.createInput,
|
||||
setup: [
|
||||
{
|
||||
argv: ["/bin/sh", "-c", `printf 'setup\\n' >> ${setupMarker}`] as const,
|
||||
env: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
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");
|
||||
await expect(
|
||||
fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "idempotence-contract" },
|
||||
}),
|
||||
).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`);
|
||||
await fixture.service.resume(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
posixDescribe("fail-closed and producer cleanup", () => {
|
||||
test("missing and unregistered selections never fall back to the host", async () => {
|
||||
const root = await temporaryRoot("fail-closed");
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const service = createService(root, runtimeIds);
|
||||
await expect(
|
||||
service.run({
|
||||
workspaceId: "missing",
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "missing-selection" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace runtime is not selected: missing");
|
||||
runtimeIds.set("missing", "not-registered");
|
||||
await expect(
|
||||
service.run({
|
||||
workspaceId: "missing",
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "unregistered-selection" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace runtime is not registered: not-registered");
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId: "unknown",
|
||||
runtimeId: "not-registered",
|
||||
project: { id: "project", source: { kind: "host-directory", path: root } },
|
||||
placement: { kind: "existing" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace runtime is not registered: not-registered");
|
||||
});
|
||||
|
||||
test("persistence failure destroys the newly-created worktree", async () => {
|
||||
const root = await temporaryRoot("persist-cleanup");
|
||||
const repo = await createRepository(root);
|
||||
const worktreesRoot = path.join(root, "worktrees");
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "home"),
|
||||
worktreesRoot,
|
||||
resolveRuntimeId: async () => null,
|
||||
persistRuntimeId: async () => {
|
||||
throw new Error("persistence failed");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId: "../../hostile-id",
|
||||
runtimeId: "worktree",
|
||||
project: { id: "project", source: { kind: "host-directory", path: repo } },
|
||||
placement: {
|
||||
kind: "branch",
|
||||
branchName: "persist-cleanup",
|
||||
baseRef: "main",
|
||||
worktreeSlug: "persist-cleanup",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("persistence failed");
|
||||
expect(listLinkedWorktrees(repo)).toHaveLength(1);
|
||||
expect(existsSync(path.join(root, "hostile-id.json"))).toBe(false);
|
||||
});
|
||||
|
||||
test("persistence failure removes newly-created local driver state", async () => {
|
||||
const root = await temporaryRoot("local-persist-cleanup");
|
||||
const repo = await createRepository(root);
|
||||
const service = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "home"),
|
||||
resolveRuntimeId: async () => null,
|
||||
persistRuntimeId: async () => {
|
||||
throw new Error("persistence failed");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
service.create({
|
||||
workspaceId: "../../hostile-local-id",
|
||||
runtimeId: "local",
|
||||
project: { id: "project", source: { kind: "host-directory", path: repo } },
|
||||
placement: { kind: "existing" },
|
||||
}),
|
||||
).rejects.toThrow("persistence failed");
|
||||
await expect(
|
||||
service.run({
|
||||
workspaceId: "../../hostile-local-id",
|
||||
argv: ["/bin/true"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "cleanup-contract" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace runtime is not selected");
|
||||
expect(existsSync(path.join(root, "hostile-local-id.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
posixDescribe.each(["local", "worktree"] as const)(
|
||||
"%s runtime confinement and process teardown",
|
||||
(runtimeId) => {
|
||||
test("rejects a symlink cwd that resolves outside the runtime root", async () => {
|
||||
const fixture = await createFixture(runtimeId);
|
||||
await fixture.service.create(fixture.createInput);
|
||||
const outside = path.join(fixture.root, "outside");
|
||||
await mkdir(outside);
|
||||
await symlink(outside, path.join(fixture.runtimeRoot(), "escape"));
|
||||
await expect(
|
||||
fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
cwd: "escape",
|
||||
argv: ["/bin/pwd"],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "cwd-contract" },
|
||||
}),
|
||||
).rejects.toThrow("Workspace cwd escapes its runtime root");
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
test("pause escalates past ignored SIGTERM and leaves no descendant", async () => {
|
||||
const fixture = await createFixture(runtimeId);
|
||||
await fixture.service.create(fixture.createInput);
|
||||
const child = await fixture.service.run({
|
||||
workspaceId: fixture.workspaceId,
|
||||
argv: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.on('SIGTERM',()=>{});const c=require('child_process').spawn(process.execPath,['-e',\"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)\"],{stdio:'ignore'});require('fs').writeFileSync('descendant.pid',String(c.pid));setInterval(()=>{},1000)",
|
||||
],
|
||||
env: {},
|
||||
purpose: { kind: "workspace-script", script: "teardown-contract" },
|
||||
});
|
||||
child.stdin.end();
|
||||
const pidFile = path.join(fixture.runtimeRoot(), "descendant.pid");
|
||||
await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true));
|
||||
const descendantPid = Number((await readFile(pidFile, "utf8")).trim());
|
||||
const startedAt = Date.now();
|
||||
await fixture.service.pause(fixture.workspaceId);
|
||||
expect(Date.now() - startedAt).toBeLessThan(4_000);
|
||||
await expect(child.exited).resolves.toEqual({ code: null, signal: "SIGKILL" });
|
||||
await vi.waitFor(() => expect(isProcessAlive(descendantPid)).toBe(false));
|
||||
await fixture.service.resume(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
async function createFixture(runtimeId: "local" | "worktree") {
|
||||
const root = await temporaryRoot(runtimeId);
|
||||
const repo = await createRepository(root);
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const worktreesRoot = path.join(root, "worktrees");
|
||||
const options: WorkspaceRuntimeOptions = {
|
||||
paseoHome: path.join(root, "home"),
|
||||
worktreesRoot,
|
||||
resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null,
|
||||
persistRuntimeId: async (workspaceId, selectedRuntimeId) => {
|
||||
runtimeIds.set(workspaceId, selectedRuntimeId);
|
||||
},
|
||||
};
|
||||
const workspaceId = `${runtimeId}-workspace`;
|
||||
const createInput: CreateWorkspaceInput = {
|
||||
workspaceId,
|
||||
runtimeId,
|
||||
project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: repo } },
|
||||
placement:
|
||||
runtimeId === "local"
|
||||
? { kind: "existing" }
|
||||
: {
|
||||
kind: "branch",
|
||||
branchName: "runtime-branch",
|
||||
baseRef: "main",
|
||||
worktreeSlug: "runtime-worktree",
|
||||
},
|
||||
setup:
|
||||
runtimeId === "worktree"
|
||||
? [{ argv: ["/bin/sh", "-c", "printf 'setup\\n' > setup-owned.txt"], env: {} }]
|
||||
: undefined,
|
||||
};
|
||||
return {
|
||||
root,
|
||||
repo,
|
||||
options,
|
||||
workspaceId,
|
||||
createInput,
|
||||
service: createWorkspaceRuntimeService(options),
|
||||
runtimeRoot: () =>
|
||||
runtimeId === "local"
|
||||
? repo
|
||||
: listLinkedWorktrees(repo).find((cwd) => path.basename(cwd) === "runtime-worktree")!,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(root: string, runtimeIds: Map<string, string>): WorkspaceRuntimeService {
|
||||
return createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "home"),
|
||||
resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null,
|
||||
persistRuntimeId: async (workspaceId, runtimeId) => {
|
||||
runtimeIds.set(workspaceId, runtimeId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function temporaryRoot(name: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `paseo-runtime-${name}-`));
|
||||
cleanupRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function createRepository(root: string): Promise<string> {
|
||||
const repo = path.join(root, "repo");
|
||||
await mkdir(repo);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo });
|
||||
await writeFile(path.join(repo, "committed.txt"), "committed\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { cwd: repo });
|
||||
return repo;
|
||||
}
|
||||
|
||||
function listLinkedWorktrees(repo: string): string[] {
|
||||
return execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repo, encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => line.slice("worktree ".length));
|
||||
}
|
||||
|
||||
async function collect(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream)
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,32 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"workspaceRuntimes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "command"
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user