mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
Merge public-gh/master into PAP-881-document-revisions-bulid-it
This commit is contained in:
commit
41f261eaf5
194 changed files with 29520 additions and 2185 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
|
|
@ -76,6 +77,14 @@ describe("adapter model listing", () => {
|
|||
expect(models).toEqual(cursorFallbackModels);
|
||||
});
|
||||
|
||||
it("returns opencode fallback models including gpt-5.4", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
|
||||
expect(models).toEqual(opencodeFallbackModels);
|
||||
});
|
||||
|
||||
it("loads cursor models dynamically and caches them", async () => {
|
||||
const runner = vi.fn(() => ({
|
||||
status: 0,
|
||||
|
|
@ -95,10 +104,4 @@ describe("adapter model listing", () => {
|
|||
expect(first.some((model) => model.id === "composer-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no opencode models when opencode command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
|
|
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
|
|||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
||||
});
|
||||
|
||||
it("exposes a dedicated agent route for the inbox mine view", async () => {
|
||||
mockIssueService.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "run-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/agents/me/inbox/mine")
|
||||
.query({ userId: "board-user" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
|
||||
touchedByUserId: "board-user",
|
||||
inboxArchivedByUserId: "board-user",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
});
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,28 @@ describe("boardMutationGuard", () => {
|
|||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations when x-forwarded-host matches origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://10.90.10.20:3443")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://evil.example.com")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server";
|
||||
import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
|
||||
describe("claude_local max-turn detection", () => {
|
||||
it("detects max-turn exhaustion by subtype", () => {
|
||||
|
|
@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude_local ui stdout parser", () => {
|
||||
it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => {
|
||||
const ts = "2026-03-29T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "claude-session-1",
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "claude-session-1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "claude-session-1",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I will inspect the repo." },
|
||||
{ type: "thinking", thinking: "Checking the adapter wiring" },
|
||||
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I will inspect the repo." },
|
||||
{ kind: "thinking", ts, text: "Checking the adapter wiring" },
|
||||
{ kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "tool_1",
|
||||
content: "AGENTS.md\nREADME.md",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("claude_local cli formatter", () => {
|
||||
it("prints the user-visible and background transcript events from stream-json output", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "claude-session-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I will inspect the repo." },
|
||||
{ type: "thinking", thinking: "Checking the adapter wiring" },
|
||||
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((value) => String(value)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)",
|
||||
"assistant: I will inspect the repo.",
|
||||
"thinking: Checking the adapter wiring",
|
||||
"tool_call: bash",
|
||||
'{\n "command": "ls -1"\n}',
|
||||
"tool_result",
|
||||
"AGENTS.md\nREADME.md",
|
||||
"result:",
|
||||
"Done",
|
||||
"tokens: in=10 out=5 cached=2 cost=$0.000420",
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
|
||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
describe("claude execute", () => {
|
||||
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const claudeConfigDir = path.join(root, "claude-config");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(claudeConfigDir, { recursive: true });
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "claude",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -195,6 +195,70 @@ describe("codex execute", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("logs HOME and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
|
|||
42
server/src/__tests__/dev-watch-ignore.test.ts
Normal file
42
server/src/__tests__/dev-watch-ignore.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js";
|
||||
|
||||
describe("resolveServerDevWatchIgnorePaths", () => {
|
||||
it("includes both the worktree UI paths and their real shared targets", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-"));
|
||||
const sharedUiRoot = path.join(tempRoot, "shared-ui");
|
||||
const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884");
|
||||
const serverRoot = path.join(worktreeRoot, "server");
|
||||
const worktreeUiRoot = path.join(worktreeRoot, "ui");
|
||||
|
||||
fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true });
|
||||
fs.mkdirSync(serverRoot, { recursive: true });
|
||||
fs.mkdirSync(worktreeUiRoot, { recursive: true });
|
||||
|
||||
fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules"));
|
||||
fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite"));
|
||||
fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist"));
|
||||
|
||||
const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot);
|
||||
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules"));
|
||||
expect(ignorePaths).toContain(`${path.join(worktreeUiRoot, "node_modules").replaceAll(path.sep, "/")}/**`);
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules")));
|
||||
expect(ignorePaths).toContain(`${fs.realpathSync(path.join(sharedUiRoot, "node_modules")).replaceAll(path.sep, "/")}/**`);
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules", ".vite-temp"));
|
||||
expect(ignorePaths).toContain(
|
||||
`${path.join(worktreeUiRoot, "node_modules", ".vite-temp").replaceAll(path.sep, "/")}/**`,
|
||||
);
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite"));
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite")));
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist"));
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist")));
|
||||
expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**");
|
||||
expect(ignorePaths).toContain("**/.vite-temp/**");
|
||||
});
|
||||
});
|
||||
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
executionWorkspaceService,
|
||||
mergeExecutionWorkspaceConfig,
|
||||
readExecutionWorkspaceConfig,
|
||||
} from "../services/execution-workspaces.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("execution workspace config helpers", () => {
|
||||
it("reads typed config from persisted metadata", () => {
|
||||
expect(readExecutionWorkspaceConfig({
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges config patches without dropping unrelated metadata", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
},
|
||||
},
|
||||
{
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the nested config block when requested", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
null,
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "README.md"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof executionWorkspaceService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = executionWorkspaceService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
metadata: {
|
||||
config: {
|
||||
teardownCommand: "bash ./scripts/teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Still working",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: true,
|
||||
isProjectPrimaryWorkspace: true,
|
||||
isDestructiveCloseAllowed: true,
|
||||
});
|
||||
expect(readiness?.blockingReasons).toEqual([]);
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.",
|
||||
"This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.",
|
||||
]));
|
||||
});
|
||||
|
||||
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`);
|
||||
tempDirs.add(worktreePath);
|
||||
|
||||
await runGit(repoRoot, ["branch", "paperclip-close-check"]);
|
||||
await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]);
|
||||
await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8");
|
||||
await runGit(worktreePath, ["add", "feature.txt"]);
|
||||
await runGit(worktreePath, ["commit", "-m", "Feature commit"]);
|
||||
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8");
|
||||
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
teardownCommand: "bash ./scripts/project-teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "git_repo",
|
||||
isPrimary: true,
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: "printf 'project cleanup\\n'",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Feature workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
config: {
|
||||
cleanupCommand: "printf 'workspace cleanup\\n'",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: false,
|
||||
isProjectPrimaryWorkspace: false,
|
||||
isDestructiveCloseAllowed: true,
|
||||
git: {
|
||||
workspacePath: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
createdByRuntime: true,
|
||||
hasDirtyTrackedFiles: false,
|
||||
hasUntrackedFiles: true,
|
||||
aheadCount: 1,
|
||||
behindCount: 0,
|
||||
isMergedIntoBase: false,
|
||||
},
|
||||
});
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"The workspace has 1 untracked file.",
|
||||
"This workspace is 1 commit ahead of main and is not merged.",
|
||||
]));
|
||||
expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([
|
||||
"archive_record",
|
||||
"cleanup_command",
|
||||
"teardown_command",
|
||||
"git_worktree_remove",
|
||||
"git_branch_delete",
|
||||
]));
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
@ -1,16 +1,56 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import * as devServerStatus from "../dev-server-status.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
describe("GET /health", () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
beforeEach(() => {
|
||||
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 200 with status ok", async () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ok", version: serverVersion });
|
||||
});
|
||||
|
||||
it("returns 200 when the database probe succeeds", async () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
|
||||
});
|
||||
|
||||
it("returns 503 when the database probe fails", async () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toEqual({
|
||||
status: "unhealthy",
|
||||
version: serverVersion,
|
||||
error: "database_unreachable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,89 +1,29 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, instance, dataDir };
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnAliveProcess() {
|
||||
|
|
@ -92,17 +32,14 @@ function spawnAliveProcess() {
|
|||
});
|
||||
}
|
||||
|
||||
describe("heartbeat orphaned process recovery", () => {
|
||||
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const childProcesses = new Set<ChildProcess>();
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
childProcesses.clear();
|
||||
runningProcesses.clear();
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedRunFixture(input?: {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import type { agents } from "@paperclipai/db";
|
|||
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
applyPersistedExecutionWorkspaceConfig,
|
||||
buildRealizedExecutionWorkspaceFromPersisted,
|
||||
buildExplicitResumeSessionOverride,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
|
@ -120,6 +123,147 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("applyPersistedExecutionWorkspaceConfig", () => {
|
||||
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: null,
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect("workspaceRuntime" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("applies explicit persisted execution workspace runtime config when present", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: {
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "workspace-web" }],
|
||||
},
|
||||
},
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "workspace-web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
|
||||
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
|
||||
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: buildResolvedWorkspace({
|
||||
cwd: "/tmp/project-primary",
|
||||
repoRef: "main",
|
||||
}),
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-1",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-880-thumbs-capture-for-evals-feature",
|
||||
status: "active",
|
||||
cwd: "/tmp/reused-worktree",
|
||||
repoUrl: "https://example.com/paperclip.git",
|
||||
baseRef: "main",
|
||||
branchName: "PAP-880-thumbs-capture-for-evals-feature",
|
||||
providerType: "git_worktree",
|
||||
providerRef: "/tmp/reused-worktree",
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.strategy).toBe("git_worktree");
|
||||
expect(result.cwd).toBe("/tmp/reused-worktree");
|
||||
expect(result.worktreePath).toBe("/tmp/reused-worktree");
|
||||
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
|
||||
expect(result.source).toBe("task_session");
|
||||
});
|
||||
|
||||
it("falls back to realization when the persisted workspace has no local path yet", () => {
|
||||
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: buildResolvedWorkspace({
|
||||
cwd: "/tmp/project-primary",
|
||||
repoRef: "main",
|
||||
}),
|
||||
workspace: {
|
||||
id: "execution-workspace-2",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-2",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-999-missing-provider-ref",
|
||||
status: "active",
|
||||
cwd: null,
|
||||
repoUrl: "https://example.com/paperclip.git",
|
||||
baseRef: "main",
|
||||
branchName: "feature/PAP-999-missing-provider-ref",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
||||
it("removes workspace runtime before heartbeat execution", () => {
|
||||
const input = {
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
});
|
||||
expect(input.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetTaskSessionForWake", () => {
|
||||
it("resets session context on assignment wake", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
|
|
|
|||
6
server/src/__tests__/helpers/embedded-postgres.ts
Normal file
6
server/src/__tests__/helpers/embedded-postgres.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestSupport,
|
||||
} from "@paperclipai/db";
|
||||
|
|
@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({
|
|||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
|
|
@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("interrupts an active run before a combined comment update", async () => {
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
executionRunId: "run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
}));
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "running",
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "heartbeat.cancelled",
|
||||
details: expect.objectContaining({
|
||||
source: "issue_comment_interrupt",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,114 +1,60 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
executionWorkspaces,
|
||||
instanceSettings,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("issueService.list participantAgentId", () => {
|
||||
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("returns issues an agent participated in across the supported signals", async () => {
|
||||
|
|
@ -281,4 +227,454 @@ describe("issueService.list participantAgentId", () => {
|
|||
|
||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||
});
|
||||
|
||||
it("filters issues by execution workspace id", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const targetWorkspaceId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const linkedIssueId = randomUUID();
|
||||
const otherLinkedIssueId = randomUUID();
|
||||
const unlinkedIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: targetWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Target workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Other workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: linkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: targetWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: otherLinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Other linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: otherWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: unlinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Unlinked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
const otherUserId = "user-2";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const visibleIssueId = randomUUID();
|
||||
const archivedIssueId = randomUUID();
|
||||
const resurfacedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: visibleIssueId,
|
||||
companyId,
|
||||
title: "Visible issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: archivedIssueId,
|
||||
companyId,
|
||||
title: "Archived issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: resurfacedIssueId,
|
||||
companyId,
|
||||
title: "Resurfaced issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await svc.archiveInbox(
|
||||
companyId,
|
||||
archivedIssueId,
|
||||
userId,
|
||||
new Date("2026-03-26T12:30:00.000Z"),
|
||||
);
|
||||
await svc.archiveInbox(
|
||||
companyId,
|
||||
resurfacedIssueId,
|
||||
userId,
|
||||
new Date("2026-03-26T13:00:00.000Z"),
|
||||
);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: resurfacedIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "This should bring the issue back into Mine.",
|
||||
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
||||
});
|
||||
|
||||
const archivedFiltered = await svc.list(companyId, {
|
||||
touchedByUserId: userId,
|
||||
inboxArchivedByUserId: userId,
|
||||
});
|
||||
|
||||
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
||||
resurfacedIssueId,
|
||||
visibleIssueId,
|
||||
]);
|
||||
|
||||
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
||||
|
||||
const afterUnarchive = await svc.list(companyId, {
|
||||
touchedByUserId: userId,
|
||||
inboxArchivedByUserId: userId,
|
||||
});
|
||||
|
||||
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
||||
visibleIssueId,
|
||||
archivedIssueId,
|
||||
resurfacedIssueId,
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
sharedWorkspaceKey: "workspace-key",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Issue worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
providerRef: `/tmp/${executionWorkspaceId}`,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
});
|
||||
|
||||
expect(child.parentId).toBe(parentIssueId);
|
||||
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const parentProjectWorkspaceId = randomUUID();
|
||||
const parentExecutionWorkspaceId = randomUUID();
|
||||
const explicitProjectWorkspaceId = randomUUID();
|
||||
const explicitExecutionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: parentProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Parent workspace",
|
||||
},
|
||||
{
|
||||
id: explicitProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Explicit workspace",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: parentExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Parent worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
},
|
||||
{
|
||||
id: explicitExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Explicit shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: parentExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
executionWorkspaceId: explicitExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "operator_branch",
|
||||
strategyType: "git_worktree",
|
||||
name: "Operator branch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Source issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "operator_branch",
|
||||
},
|
||||
});
|
||||
|
||||
const followUp = await svc.create(companyId, {
|
||||
projectId,
|
||||
title: "Follow-up issue",
|
||||
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
||||
});
|
||||
|
||||
expect(followUp.parentId).toBeNull();
|
||||
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(followUp.executionWorkspaceSettings).toEqual({
|
||||
mode: "operator_branch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companies, invites } from "@paperclipai/db";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
|
|
@ -51,19 +52,35 @@ function createDbStub() {
|
|||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
expiresAt: new Date("2099-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
createdAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
const select = vi.fn(() => ({
|
||||
from(table: unknown) {
|
||||
return {
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
if (table === invites) {
|
||||
return Promise.resolve([createdInvite]);
|
||||
}
|
||||
if (table === companies) {
|
||||
return Promise.resolve([{ name: "Acme AI" }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
return {
|
||||
insert,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
|||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("includes companyName in invite summary responses", async () => {
|
||||
const db = createDbStub();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyId).toBe("company-1");
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
|
@ -11,11 +7,9 @@ import {
|
|||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
|
|
@ -26,6 +20,10 @@ import {
|
|||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
|
||||
|
|
@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("routine routes end-to-end", () => {
|
||||
describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
|
|
@ -21,85 +15,29 @@ import {
|
|||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("routine service live-execution coalescing", () => {
|
||||
describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture(opts?: {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,51 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
resetRuntimeServicesForTests,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const leasedRunIds = new Set<string>();
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
|
|
@ -124,7 +152,30 @@ afterEach(async () => {
|
|||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
delete process.env.DATABASE_URL;
|
||||
await resetRuntimeServicesForTests();
|
||||
});
|
||||
|
||||
describe("sanitizeRuntimeServiceBaseEnv", () => {
|
||||
it("removes inherited Paperclip and pnpm auth flags before spawning runtime services", () => {
|
||||
const sanitized = sanitizeRuntimeServiceBaseEnv({
|
||||
PATH: process.env.PATH,
|
||||
DATABASE_URL: "postgres://example.test/paperclip",
|
||||
PAPERCLIP_HOME: "/tmp/paperclip-home",
|
||||
PAPERCLIP_INSTANCE_ID: "runtime-instance",
|
||||
npm_config_tailscale_auth: "true",
|
||||
npm_config_authenticated_private: "true",
|
||||
HOST: "0.0.0.0",
|
||||
});
|
||||
|
||||
expect(sanitized.PAPERCLIP_HOME).toBeUndefined();
|
||||
expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined();
|
||||
expect(sanitized.DATABASE_URL).toBeUndefined();
|
||||
expect(sanitized.npm_config_tailscale_auth).toBeUndefined();
|
||||
expect(sanitized.npm_config_authenticated_private).toBeUndefined();
|
||||
expect(sanitized.HOST).toBe("0.0.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
|
|
@ -196,6 +247,77 @@ describe("realizeExecutionWorkspace", () => {
|
|||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const realized = 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-unsafe",
|
||||
identifier: "PAP-991",
|
||||
title: "there should be a setting for the allowance of thumbs up / thumbs down data; `rm -rf`",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(realized.branchName).toBe(
|
||||
"PAP-991-there-should-be-a-setting-for-the-allowance-of-thumbs-up-thumbs-down-data-rm-rf",
|
||||
);
|
||||
expect(realized.branchName?.includes("/")).toBe(false);
|
||||
expect(path.basename(realized.cwd)).toBe(realized.branchName);
|
||||
});
|
||||
|
||||
it("preserves intentional slashes and dots from the branch template", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const realized = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "release/{{issue.identifier}}.{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-template-safe",
|
||||
identifier: "PAP-992",
|
||||
title: "Hotfix / April.1",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(realized.branchName).toBe("release/PAP-992.hotfix-april-1");
|
||||
expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1");
|
||||
});
|
||||
|
||||
it("runs a configured provision command inside the derived worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
|
|
@ -282,6 +404,156 @@ describe("realizeExecutionWorkspace", () => {
|
|||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
|
||||
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const previousCwd = process.cwd();
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-"));
|
||||
const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-"));
|
||||
const instanceId = "worktree-base";
|
||||
const sharedConfigDir = path.join(paperclipHome, "instances", instanceId);
|
||||
const sharedConfigPath = path.join(sharedConfigDir, "config.json");
|
||||
const sharedEnvPath = path.join(sharedConfigDir, ".env");
|
||||
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome;
|
||||
|
||||
await fs.mkdir(sharedConfigDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
sharedConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-26T00:00:00.000Z",
|
||||
source: "doctor",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(sharedConfigDir, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(sharedConfigDir, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(sharedConfigDir, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(sharedConfigDir, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(sharedConfigDir, "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8");
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)),
|
||||
path.join(repoRoot, "scripts", "provision-worktree.sh"),
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
|
||||
|
||||
try {
|
||||
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}}",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-885",
|
||||
title: "Show worktree banner",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
const configPath = path.join(workspace.cwd, ".paperclip", "config.json");
|
||||
const envPath = path.join(workspace.cwd, ".paperclip", ".env");
|
||||
const envContents = await fs.readFile(envPath, "utf8");
|
||||
const configContents = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
const configStats = await fs.lstat(configPath);
|
||||
const expectedInstanceId = "pap-885-show-worktree-banner";
|
||||
const expectedInstanceRoot = path.join(
|
||||
isolatedWorktreeHome,
|
||||
"instances",
|
||||
expectedInstanceId,
|
||||
);
|
||||
|
||||
expect(configStats.isSymbolicLink()).toBe(false);
|
||||
expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db"));
|
||||
expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db"));
|
||||
expect(configContents.server.port).not.toBe(3100);
|
||||
expect(configContents.secrets.localEncrypted.keyFilePath).toBe(
|
||||
path.join(expectedInstanceRoot, "secrets", "master.key"),
|
||||
);
|
||||
expect(envContents).not.toContain("DATABASE_URL=");
|
||||
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
|
||||
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
|
||||
expect(envContents).toContain(
|
||||
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
|
||||
);
|
||||
|
||||
process.chdir(workspace.cwd);
|
||||
expect(resolvePaperclipConfigPath()).toBe(configPath);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
|
@ -681,6 +953,101 @@ describe("ensureRuntimeServicesForRun", () => {
|
|||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
|
||||
it("does not reuse project-scoped shared services across different workspace launch contexts", async () => {
|
||||
const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-"));
|
||||
const worktreeWorkspaceRoot = path.join(primaryWorkspaceRoot, ".paperclip", "worktrees", "PAP-874-chat-speed-issues");
|
||||
await fs.mkdir(worktreeWorkspaceRoot, { recursive: true });
|
||||
|
||||
const primaryWorkspace = buildWorkspace(primaryWorkspaceRoot);
|
||||
const executionWorkspace: RealizedExecutionWorkspace = {
|
||||
...buildWorkspace(worktreeWorkspaceRoot),
|
||||
source: "task_session",
|
||||
strategy: "git_worktree",
|
||||
cwd: worktreeWorkspaceRoot,
|
||||
branchName: "PAP-874-chat-speed-issues",
|
||||
worktreePath: worktreeWorkspaceRoot,
|
||||
};
|
||||
const serviceCommand =
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "paperclip-dev",
|
||||
command: serviceCommand,
|
||||
cwd: ".",
|
||||
env: {
|
||||
PAPERCLIP_HOME: "{{workspace.cwd}}/.paperclip/runtime-services",
|
||||
},
|
||||
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 primaryRunId = "run-project-workspace";
|
||||
const executionRunId = "run-execution-workspace";
|
||||
leasedRunIds.add(primaryRunId);
|
||||
leasedRunIds.add(executionRunId);
|
||||
|
||||
const primaryServices = await ensureRuntimeServicesForRun({
|
||||
runId: primaryRunId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace: primaryWorkspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
const executionServices = await ensureRuntimeServicesForRun({
|
||||
runId: executionRunId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace: executionWorkspace,
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(primaryServices).toHaveLength(1);
|
||||
expect(executionServices).toHaveLength(1);
|
||||
expect(primaryServices[0]?.reused).toBe(false);
|
||||
expect(executionServices[0]?.reused).toBe(false);
|
||||
expect(executionServices[0]?.id).not.toBe(primaryServices[0]?.id);
|
||||
expect(executionServices[0]?.executionWorkspaceId).toBe("execution-workspace-1");
|
||||
expect(executionServices[0]?.cwd).toBe(worktreeWorkspaceRoot);
|
||||
expect(executionServices[0]?.url).not.toBe(primaryServices[0]?.url);
|
||||
|
||||
const primaryResponse = await fetch(primaryServices[0]!.url!);
|
||||
expect(await primaryResponse.text()).toBe(path.join(primaryWorkspaceRoot, ".paperclip", "runtime-services"));
|
||||
|
||||
const executionResponse = await fetch(executionServices[0]!.url!);
|
||||
expect(await executionResponse.text()).toBe(path.join(worktreeWorkspaceRoot, ".paperclip", "runtime-services"));
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -875,6 +1242,258 @@ describe("ensureRuntimeServicesForRun", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
it("adopts a live auto-port shared service after runtime state is reset", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `runtime-reconcile-${randomUUID()}`;
|
||||
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "agent",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
const service = services[0];
|
||||
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await resetRuntimeServicesForTests();
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 });
|
||||
|
||||
const persisted = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.id, service!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted?.status).toBe("running");
|
||||
expect(persisted?.providerRef).toMatch(/^\d+$/);
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
|
||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists controlled execution workspace stops as stopped", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime stop test",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Execution workspace stop test",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services[0]?.url).toBeTruthy();
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
await expect(fetch(services[0]!.url!)).rejects.toThrow();
|
||||
|
||||
const persisted = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.id, services[0]!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(persisted?.status).toBe("stopped");
|
||||
expect(persisted?.healthStatus).toBe("unknown");
|
||||
expect(persisted?.stoppedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
|
||||
const workspace = buildWorkspace("/tmp/project");
|
||||
|
|
|
|||
426
server/src/__tests__/worktree-config.test.ts
Normal file
426
server/src/__tests__/worktree-config.test.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyRuntimePortSelectionToConfig,
|
||||
maybePersistWorktreeRuntimePorts,
|
||||
maybeRepairLegacyWorktreeConfigAndEnvFiles,
|
||||
} from "../worktree-config.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function buildLegacyConfig(sharedRoot: string) {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-26T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres" as const,
|
||||
embeddedPostgresDataDir: path.join(sharedRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(sharedRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file" as const,
|
||||
logDir: path.join(sharedRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted" as const,
|
||||
exposure: "private" as const,
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit" as const,
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk" as const,
|
||||
localDisk: {
|
||||
baseDir: path.join(sharedRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted" as const,
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(sharedRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree config repair", () => {
|
||||
it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
||||
"PAPERCLIP_AGENT_JWT_SECRET=shared-secret",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_CONTEXT;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
|
||||
expect(result).toEqual({
|
||||
repairedConfig: true,
|
||||
repairedEnv: true,
|
||||
});
|
||||
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
const repairedEnv = await fs.readFile(envPath, "utf8");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
||||
|
||||
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
||||
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
||||
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
||||
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
||||
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
||||
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
|
||||
expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"');
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
||||
});
|
||||
|
||||
it("avoids sibling worktree ports when repairing legacy configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(siblingInstanceRoot, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(siblingInstanceRoot, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(siblingInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(result.repairedConfig).toBe(true);
|
||||
expect(repairedConfig.server.port).toBe(3102);
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("rebalances duplicate ports for already isolated worktree configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
|
||||
const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component");
|
||||
const paperclipDir = path.join(currentWorktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
||||
const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(currentInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(currentInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(currentInstanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(currentInstanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
siblingConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(siblingInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(currentWorktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(result.repairedConfig).toBe(true);
|
||||
expect(repairedConfig.server.port).toBe(3102);
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("persists runtime-selected worktree ports back into config", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(instanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54331,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(instanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(instanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(instanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
|
||||
process.env.PAPERCLIP_HOME = isolatedHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
|
||||
maybePersistWorktreeRuntimePorts({
|
||||
serverPort: 3103,
|
||||
databasePort: 54335,
|
||||
});
|
||||
|
||||
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(writtenConfig.server.port).toBe(3103);
|
||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
||||
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
|
||||
});
|
||||
|
||||
it("can update the in-memory config without rewriting env-driven ports", () => {
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
|
||||
serverPort: 3104,
|
||||
databasePort: 54340,
|
||||
allowServerPortWrite: false,
|
||||
allowDatabasePortWrite: true,
|
||||
});
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(config.server.port).toBe(3100);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue