Fix execution workspace runtime lifecycle
This commit is contained in:
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveViteHmrPort } from "../app.ts";
|
||||
|
||||
describe("resolveViteHmrPort", () => {
|
||||
it("uses serverPort + 10000 when the result stays in range", () => {
|
||||
expect(resolveViteHmrPort(3100)).toBe(13_100);
|
||||
expect(resolveViteHmrPort(55_535)).toBe(65_535);
|
||||
});
|
||||
|
||||
it("falls back below the server port when adding 10000 would overflow", () => {
|
||||
expect(resolveViteHmrPort(55_536)).toBe(45_536);
|
||||
expect(resolveViteHmrPort(63_000)).toBe(53_000);
|
||||
});
|
||||
|
||||
it("never returns a privileged or invalid port", () => {
|
||||
expect(resolveViteHmrPort(65_535)).toBe(55_535);
|
||||
expect(resolveViteHmrPort(9_000)).toBe(19_000);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
@@ -141,3 +142,39 @@ describe("shouldResetTaskSessionForWake", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
||||
it("moves the explicitly selected workspace to the front", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1", cwd: "/tmp/one" },
|
||||
{ id: "workspace-2", cwd: "/tmp/two" },
|
||||
{ id: "workspace-3", cwd: "/tmp/three" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
||||
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when no preferred workspace is selected", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when the selected workspace is missing", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
realizeExecutionWorkspace,
|
||||
@@ -55,6 +56,10 @@ afterEach(async () => {
|
||||
leasedRunIds.delete(runId);
|
||||
}),
|
||||
);
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.DATABASE_URL;
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
@@ -211,6 +216,68 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
|
||||
it("removes a created git worktree and branch during cleanup", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const workspace = 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-449",
|
||||
title: "Cleanup workspace",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanup.cleaned).toBe(true);
|
||||
expect(cleanup.warnings).toEqual([]);
|
||||
await expect(fs.stat(workspace.cwd)).rejects.toThrow();
|
||||
await expect(
|
||||
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
|
||||
).resolves.toMatchObject({
|
||||
stdout: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRuntimeServicesForRun", () => {
|
||||
@@ -312,6 +379,84 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
expect(third[0]?.reused).toBe(false);
|
||||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
|
||||
it("does not leak parent Paperclip instance env into runtime service commands", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
const envCapturePath = path.join(workspaceRoot, "captured-env.json");
|
||||
const serviceCommand = [
|
||||
"node -e",
|
||||
JSON.stringify(
|
||||
[
|
||||
"const fs = require('node:fs');",
|
||||
`fs.writeFileSync(${JSON.stringify(envCapturePath)}, JSON.stringify({`,
|
||||
"paperclipConfig: process.env.PAPERCLIP_CONFIG ?? null,",
|
||||
"paperclipHome: process.env.PAPERCLIP_HOME ?? null,",
|
||||
"paperclipInstanceId: process.env.PAPERCLIP_INSTANCE_ID ?? null,",
|
||||
"databaseUrl: process.env.DATABASE_URL ?? null,",
|
||||
"customEnv: process.env.RUNTIME_CUSTOM_ENV ?? null,",
|
||||
"port: process.env.PORT ?? null,",
|
||||
"}));",
|
||||
"require('node:http').createServer((req, res) => res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1');",
|
||||
].join(" "),
|
||||
),
|
||||
].join(" ");
|
||||
|
||||
process.env.PAPERCLIP_CONFIG = "/tmp/base-paperclip-config.json";
|
||||
process.env.PAPERCLIP_HOME = "/tmp/base-paperclip-home";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "base-instance";
|
||||
process.env.DATABASE_URL = "postgres://shared-db.example.com/paperclip";
|
||||
|
||||
const runId = "run-env";
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
runId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command: serviceCommand,
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "on_run_finish",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {
|
||||
RUNTIME_CUSTOM_ENV: "from-adapter",
|
||||
},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
const captured = JSON.parse(await fs.readFile(envCapturePath, "utf8")) as Record<string, string | null>;
|
||||
expect(captured.paperclipConfig).toBeNull();
|
||||
expect(captured.paperclipHome).toBeNull();
|
||||
expect(captured.paperclipInstanceId).toBeNull();
|
||||
expect(captured.databaseUrl).toBeNull();
|
||||
expect(captured.customEnv).toBe("from-adapter");
|
||||
expect(captured.port).toMatch(/^\d+$/);
|
||||
expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
@@ -374,6 +519,7 @@ describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
executionWorkspaceId: null,
|
||||
issueId: "issue-1",
|
||||
serviceName: "preview",
|
||||
provider: "adapter_managed",
|
||||
|
||||
Reference in New Issue
Block a user