Add worktree-aware workspace runtime support

This commit is contained in:
Dotta
2026-03-10 10:58:38 -05:00
parent 7934952a77
commit 3120c72372
35 changed files with 8750 additions and 61 deletions

View File

@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import {
buildOpenClawGatewayConfig,
parseOpenClawGatewayStdoutLine,
} from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext(
@@ -36,7 +39,9 @@ function buildContext(
};
}
async function createMockGatewayServer() {
async function createMockGatewayServer(options?: {
waitPayload?: Record<string, unknown>;
}) {
const server = createServer();
const wss = new WebSocketServer({ server });
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
type: "res",
id: frame.id,
ok: true,
payload: {
payload: options?.waitPayload ?? {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
paperclipWorkspace: {
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
branchName: "pap-123-test",
},
paperclipWorkspaces: [
{
id: "workspace-1",
cwd: "/tmp/project",
},
],
paperclipRuntimeServiceIntents: [
{
name: "preview",
lifecycle: "ephemeral",
},
],
},
},
),
);
@@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => {
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(payload?.paperclip).toEqual(
expect.objectContaining({
runId: "run-123",
companyId: "company-123",
agentId: "agent-123",
taskId: "task-123",
issueId: "issue-123",
workspace: expect.objectContaining({
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
}),
workspaces: [
expect.objectContaining({
id: "workspace-1",
cwd: "/tmp/project",
}),
],
workspaceRuntime: expect.objectContaining({
services: [
expect.objectContaining({
name: "preview",
lifecycle: "ephemeral",
}),
],
}),
}),
);
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
});
it("returns adapter-managed runtime services from gateway result meta", async () => {
const gateway = await createMockGatewayServer({
waitPayload: {
runId: "run-123",
status: "ok",
startedAt: 1,
endedAt: 2,
meta: {
runtimeServices: [
{
name: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
},
],
},
},
});
try {
const result = await execute(
buildContext({
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
waitTimeoutMs: 2000,
}),
);
expect(result.exitCode).toBe(0);
expect(result.runtimeServices).toEqual([
expect.objectContaining({
serviceName: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
status: "running",
}),
]);
} finally {
await gateway.close();
}
});
it("auto-approves pairing once and retries the run", async () => {
const gateway = await createMockGatewayServerWithPairing();
const logs: string[] = [];
@@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => {
});
});
describe("openclaw gateway ui build config", () => {
it("parses payload template and runtime services json", () => {
const config = buildOpenClawGatewayConfig({
adapterType: "openclaw_gateway",
cwd: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "wss://gateway.example/ws",
payloadTemplateJson: JSON.stringify({
agentId: "remote-agent-123",
metadata: { team: "platform" },
}),
runtimeServicesJson: JSON.stringify({
services: [
{
name: "preview",
lifecycle: "shared",
},
],
}),
bootstrapPrompt: "",
maxTurnsPerRun: 0,
heartbeatEnabled: true,
intervalSec: 300,
});
expect(config).toEqual(
expect.objectContaining({
url: "wss://gateway.example/ws",
payloadTemplate: {
agentId: "remote-agent-123",
metadata: { team: "platform" },
},
workspaceRuntime: {
services: [
{
name: "preview",
lifecycle: "shared",
},
],
},
}),
);
});
});
describe("openclaw gateway testEnvironment", () => {
it("reports missing url as failure", async () => {
const result = await testEnvironment({

View File

@@ -0,0 +1,300 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
const execFileAsync = promisify(execFile);
const leasedRunIds = new Set<string>();
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
return repoRoot;
}
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
return {
baseCwd: cwd,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
strategy: "project_primary",
cwd,
branchName: null,
worktreePath: null,
warnings: [],
created: false,
};
}
afterEach(async () => {
await Promise.all(
Array.from(leasedRunIds).map(async (runId) => {
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
}),
);
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
const first = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(first.strategy).toBe("git_worktree");
expect(first.created).toBe(true);
expect(first.branchName).toBe("PAP-447-add-worktree-support");
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
const second = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(second.created).toBe(false);
expect(second.cwd).toBe(first.cwd);
expect(second.branchName).toBe(first.branchName);
});
});
describe("ensureRuntimeServicesForRun", () => {
it("reuses shared runtime services across runs and starts a new service after release", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
const workspace = buildWorkspace(workspaceRoot);
const serviceCommand =
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command: serviceCommand,
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "project_workspace",
stopPolicy: {
type: "on_run_finish",
},
},
],
},
};
const run1 = "run-1";
const run2 = "run-2";
leasedRunIds.add(run1);
leasedRunIds.add(run2);
const first = await ensureRuntimeServicesForRun({
runId: run1,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.reused).toBe(false);
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
const response = await fetch(first[0]!.url!);
expect(await response.text()).toBe("ok");
const second = await ensureRuntimeServicesForRun({
runId: run2,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.reused).toBe(true);
expect(second[0]?.id).toBe(first[0]?.id);
await releaseRuntimeServicesForRun(run1);
leasedRunIds.delete(run1);
await releaseRuntimeServicesForRun(run2);
leasedRunIds.delete(run2);
const run3 = "run-3";
leasedRunIds.add(run3);
const third = await ensureRuntimeServicesForRun({
runId: run3,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(third).toHaveLength(1);
expect(third[0]?.reused).toBe(false);
expect(third[0]?.id).not.toBe(first[0]?.id);
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
const workspace = buildWorkspace("/tmp/project");
const now = new Date("2026-03-09T12:00:00.000Z");
const first = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
const second = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
issueId: "issue-1",
serviceName: "preview",
provider: "adapter_managed",
status: "running",
healthStatus: "healthy",
startedByRunId: "run-1",
});
expect(first[0]?.id).toBe(second[0]?.id);
});
});