Merge pull request #3354 from cryppadotta/pap-1331-runtime-workflows
fix: harden heartbeat and adapter runtime workflows
This commit is contained in:
@@ -2,6 +2,24 @@ import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
|
||||
function isPidAlive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isPidAlive(pid)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||
const spawnDelayMs = 150;
|
||||
@@ -35,4 +53,36 @@ describe("runChildProcess", () => {
|
||||
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
|
||||
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("kills descendant processes on timeout via the process group", async () => {
|
||||
let descendantPid: number | null = null;
|
||||
|
||||
const result = await runChildProcess(
|
||||
randomUUID(),
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
[
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
|
||||
"process.stdout.write(String(child.pid));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join(" "),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
timeoutSec: 1,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
onSpawn: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
descendantPid = Number.parseInt(result.stdout.trim(), 10);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
|
||||
|
||||
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface RunProcessResult {
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
processGroupId: number | null;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
@@ -34,6 +35,28 @@ type ChildProcessWithEvents = ChildProcess & {
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
function resolveProcessGroupId(child: ChildProcess) {
|
||||
if (process.platform === "win32") return null;
|
||||
return typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
|
||||
}
|
||||
|
||||
function signalRunningProcess(
|
||||
running: Pick<RunningProcess, "child" | "processGroupId">,
|
||||
signal: NodeJS.Signals,
|
||||
) {
|
||||
if (process.platform !== "win32" && running.processGroupId && running.processGroupId > 0) {
|
||||
try {
|
||||
process.kill(-running.processGroupId, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child signal if group signaling fails.
|
||||
}
|
||||
}
|
||||
if (!running.child.killed) {
|
||||
running.child.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
@@ -1034,7 +1057,7 @@ export async function runChildProcess(
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
@@ -1064,19 +1087,21 @@ export async function runChildProcess(
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
detached: process.platform !== "win32",
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
const startedAt = new Date().toISOString();
|
||||
const processGroupId = resolveProcessGroupId(child);
|
||||
|
||||
const spawnPersistPromise =
|
||||
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
|
||||
? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||
? opts.onSpawn({ pid: child.pid, processGroupId, startedAt }).catch((err) => {
|
||||
onLogError(err, runId, "failed to record child process metadata");
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec, processGroupId });
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
@@ -1087,11 +1112,9 @@ export async function runChildProcess(
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
@@ -120,7 +120,7 @@ export interface AdapterExecutionContext {
|
||||
context: Record<string, unknown>;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
@@ -33,35 +32,10 @@ import {
|
||||
} from "./parse.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(
|
||||
resolveClaudeDesiredSkillNames(
|
||||
config,
|
||||
availableEntries,
|
||||
),
|
||||
);
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
await fs.symlink(
|
||||
entry.source,
|
||||
path.join(target, entry.runtimeName),
|
||||
);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
@@ -361,30 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
),
|
||||
);
|
||||
const billingType = resolveClaudeBillingType(effectiveEnv);
|
||||
const skillsDir = await buildSkillsDir(config);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
let effectiveInstructionsFilePath: string | undefined;
|
||||
let preparedInstructionsFile = false;
|
||||
|
||||
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
|
||||
if (resumeSessionId || !instructionsFilePath) return undefined;
|
||||
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
|
||||
|
||||
preparedInstructionsFile = true;
|
||||
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
|
||||
// When instructionsFilePath is configured, build a stable content-addressed
|
||||
// file that includes both the file content and the path directive, so we only
|
||||
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
|
||||
let combinedInstructionsContents: string | null = null;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||
const pathDirective =
|
||||
@@ -392,20 +349,50 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`Resolve any relative file references from ${instructionsFileDir}. ` +
|
||||
`This base directory is authoritative for sibling instruction files such as ` +
|
||||
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
|
||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||
effectiveInstructionsFilePath = combinedPath;
|
||||
combinedInstructionsContents = instructionsContent + pathDirective;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
effectiveInstructionsFilePath = undefined;
|
||||
}
|
||||
}
|
||||
const promptBundle = await prepareClaudePromptBundle({
|
||||
companyId: agent.companyId,
|
||||
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
|
||||
instructionsContents: combinedInstructionsContents,
|
||||
onLog,
|
||||
});
|
||||
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
||||
|
||||
return effectiveInstructionsFilePath;
|
||||
};
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
||||
const hasMatchingPromptBundle =
|
||||
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
hasMatchingPromptBundle &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (
|
||||
runtimeSessionId &&
|
||||
runtimeSessionCwd.length > 0 &&
|
||||
path.resolve(runtimeSessionCwd) !== path.resolve(cwd)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
|
||||
);
|
||||
}
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
@@ -460,7 +447,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
||||
}
|
||||
args.push("--add-dir", skillsDir);
|
||||
args.push("--add-dir", promptBundle.addDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
@@ -482,14 +469,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
|
||||
const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath;
|
||||
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
|
||||
const commandNotes =
|
||||
attemptInstructionsFilePath && !resumeSessionId
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
const commandNotes: string[] = [];
|
||||
if (!resumeSessionId) {
|
||||
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
|
||||
}
|
||||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||
commandNotes.push(
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
);
|
||||
}
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
@@ -586,6 +576,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
promptBundleKey: promptBundle.bundleKey,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
@@ -618,25 +609,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
} finally {
|
||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
}
|
||||
|
||||
@@ -36,12 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const promptBundleKey =
|
||||
readNonEmptyString(record.promptBundleKey) ??
|
||||
readNonEmptyString(record.prompt_bundle_key);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
@@ -55,12 +59,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const promptBundleKey =
|
||||
readNonEmptyString(params.promptBundleKey) ??
|
||||
readNonEmptyString(params.prompt_bundle_key);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash, type Hash } from "node:crypto";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
|
||||
type SkillEntry = PaperclipSkillEntry;
|
||||
|
||||
export interface ClaudePromptBundle {
|
||||
bundleKey: string;
|
||||
rootDir: string;
|
||||
addDir: string;
|
||||
instructionsFilePath: string | null;
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveManagedClaudePromptCacheRoot(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId: string,
|
||||
): string {
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
return path.resolve(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
instanceId,
|
||||
"companies",
|
||||
companyId,
|
||||
"claude-prompt-cache",
|
||||
);
|
||||
}
|
||||
|
||||
async function hashPathContents(
|
||||
candidate: string,
|
||||
hash: Hash,
|
||||
relativePath: string,
|
||||
seenDirectories: Set<string>,
|
||||
): Promise<void> {
|
||||
const stat = await fs.lstat(candidate);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}\n`);
|
||||
const resolved = await fs.realpath(candidate).catch(() => null);
|
||||
if (!resolved) {
|
||||
hash.update("missing\n");
|
||||
return;
|
||||
}
|
||||
await hashPathContents(resolved, hash, relativePath, seenDirectories);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const realDir = await fs.realpath(candidate).catch(() => candidate);
|
||||
hash.update(`dir:${relativePath}\n`);
|
||||
if (seenDirectories.has(realDir)) {
|
||||
hash.update("loop\n");
|
||||
return;
|
||||
}
|
||||
seenDirectories.add(realDir);
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
hash.update(`file:${relativePath}\n`);
|
||||
hash.update(await fs.readFile(candidate));
|
||||
hash.update("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
hash.update(`other:${relativePath}:${stat.mode}\n`);
|
||||
}
|
||||
|
||||
async function buildClaudePromptBundleKey(input: {
|
||||
skills: SkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
}): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("paperclip-claude-prompt-bundle:v1\n");
|
||||
if (input.instructionsContents) {
|
||||
hash.update("instructions\n");
|
||||
hash.update(input.instructionsContents);
|
||||
hash.update("\n");
|
||||
} else {
|
||||
hash.update("instructions:none\n");
|
||||
}
|
||||
|
||||
const sortedSkills = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName));
|
||||
for (const entry of sortedSkills) {
|
||||
hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
|
||||
await hashPathContents(entry.source, hash, entry.runtimeName, new Set<string>());
|
||||
}
|
||||
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function ensureReadableFile(targetPath: string, contents: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(targetPath, fsConstants.R_OK);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through and materialize the file.
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, contents, "utf8");
|
||||
await fs.rename(tempPath, targetPath);
|
||||
} catch (err) {
|
||||
const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false);
|
||||
if (!targetReadable) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareClaudePromptBundle(input: {
|
||||
companyId: string;
|
||||
skills: SkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<ClaudePromptBundle> {
|
||||
const { companyId, skills, instructionsContents, onLog } = input;
|
||||
const bundleKey = await buildClaudePromptBundleKey({
|
||||
skills,
|
||||
instructionsContents,
|
||||
});
|
||||
const rootDir = path.join(resolveManagedClaudePromptCacheRoot(process.env, companyId), bundleKey);
|
||||
const skillsHome = path.join(rootDir, ".claude", "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
|
||||
for (const entry of skills) {
|
||||
const target = path.join(skillsHome, entry.runtimeName);
|
||||
try {
|
||||
await ensurePaperclipSkillSymlink(entry.source, target);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const instructionsFilePath = instructionsContents
|
||||
? path.join(rootDir, "agent-instructions.md")
|
||||
: null;
|
||||
if (instructionsFilePath && instructionsContents) {
|
||||
await ensureReadableFile(instructionsFilePath, instructionsContents);
|
||||
}
|
||||
|
||||
return {
|
||||
bundleKey,
|
||||
rootDir,
|
||||
addDir: rootDir,
|
||||
instructionsFilePath,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Will be mounted into the ephemeral Claude skill directory on the next run."
|
||||
? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
|
||||
@@ -27,6 +27,39 @@ describe("parseCodexJsonl", () => {
|
||||
errorMessage: "resume failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the last agent message as the summary when commentary updates precede the final answer", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { type: "reasoning", text: "Checking the heartbeat procedure" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { type: "agent_message", text: "I’m checking out the issue and reading the docs now." },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { type: "agent_message", text: "Fixed the issue and verified the targeted tests pass." },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "turn.completed",
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
expect(parseCodexJsonl(stdout)).toEqual({
|
||||
sessionId: "thread_123",
|
||||
summary: "Fixed the issue and verified the targeted tests pass.",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 2,
|
||||
outputTokens: 4,
|
||||
},
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCodexUnknownSessionError", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter
|
||||
|
||||
export function parseCodexJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let finalMessage: string | null = null;
|
||||
let errorMessage: string | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
@@ -33,7 +33,7 @@ export function parseCodexJsonl(stdout: string) {
|
||||
const item = parseObject(event.item);
|
||||
if (asString(item.type, "") === "agent_message") {
|
||||
const text = asString(item.text, "");
|
||||
if (text) messages.push(text);
|
||||
if (text) finalMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function parseCodexJsonl(stdout: string) {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
summary: finalMessage?.trim() ?? "",
|
||||
usage,
|
||||
errorMessage,
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_group_id" integer;--> statement-breakpoint
|
||||
File diff suppressed because it is too large
Load Diff
@@ -386,6 +386,13 @@
|
||||
"when": 1775750400000,
|
||||
"tag": "0054_draft_routines",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 55,
|
||||
"version": "7",
|
||||
"when": 1775825256196,
|
||||
"tag": "0055_kind_weapon_omega",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const heartbeatRuns = pgTable(
|
||||
errorCode: text("error_code"),
|
||||
externalRunId: text("external_run_id"),
|
||||
processPid: integer("process_pid"),
|
||||
processGroupId: integer("process_group_id"),
|
||||
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
|
||||
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
|
||||
onDelete: "set null",
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
TelemetryState,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest";
|
||||
const DEFAULT_ENDPOINTS = [
|
||||
"https://telemetry.paperclip.ing/ingest",
|
||||
"https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest",
|
||||
] as const;
|
||||
const BATCH_SIZE = 50;
|
||||
const SEND_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -44,29 +47,35 @@ export class TelemetryClient {
|
||||
|
||||
const events = this.queue.splice(0);
|
||||
const state = this.getState();
|
||||
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
|
||||
const endpoints = this.resolveEndpoints();
|
||||
const app = this.config.app ?? "paperclip";
|
||||
const schemaVersion = this.config.schemaVersion ?? "1";
|
||||
const body = JSON.stringify({
|
||||
app,
|
||||
schemaVersion,
|
||||
installId: state.installId,
|
||||
version: this.version,
|
||||
events,
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
app,
|
||||
schemaVersion,
|
||||
installId: state.installId,
|
||||
version: this.version,
|
||||
events,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: silent failure, no retries
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
for (const endpoint of endpoints) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Try the next built-in endpoint before dropping the batch.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +111,9 @@ export class TelemetryClient {
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private resolveEndpoints(): readonly string[] {
|
||||
const configured = this.config.endpoint?.trim();
|
||||
return configured ? [configured] : DEFAULT_ENDPOINTS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface HeartbeatRun {
|
||||
errorCode: string | null;
|
||||
externalRunId: string | null;
|
||||
processPid: number | null;
|
||||
processGroupId?: number | null;
|
||||
processStartedAt: Date | null;
|
||||
retryOfRunId: string | null;
|
||||
processLossRetryCount: number;
|
||||
|
||||
@@ -19,16 +19,19 @@ describe("adapter session codecs", () => {
|
||||
const parsed = claudeSessionCodec.deserialize({
|
||||
session_id: "claude-session-1",
|
||||
folder: "/tmp/workspace",
|
||||
prompt_bundle_key: "bundle-1",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "claude-session-1",
|
||||
cwd: "/tmp/workspace",
|
||||
promptBundleKey: "bundle-1",
|
||||
});
|
||||
|
||||
const serialized = claudeSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "claude-session-1",
|
||||
cwd: "/tmp/workspace",
|
||||
promptBundleKey: "bundle-1",
|
||||
});
|
||||
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
|
||||
});
|
||||
|
||||
@@ -298,15 +298,6 @@ describe("agent instructions bundle routes", () => {
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
command: "codex --profile engineer",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(res.body.adapterConfig).toMatchObject({
|
||||
command: "codex --profile engineer",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getRunIssueSummary: vi.fn(),
|
||||
getActiveRunIssueSummaryForAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => ({}),
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(),
|
||||
secretService: () => ({}),
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
findActiveServerAdapter: vi.fn(),
|
||||
requireServerAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent live run routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
executionRunId: "run-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
status: "in_progress",
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue(null);
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Builder",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-04-10T09:29:59.000Z"),
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
});
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("returns a compact active run payload for issue polling", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(res.body).toEqual({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
startedAt: "2026-04-10T09:30:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-10T09:29:59.000Z",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
expect(res.body).not.toHaveProperty("resultJson");
|
||||
expect(res.body).not.toHaveProperty("contextSnapshot");
|
||||
expect(res.body).not.toHaveProperty("logRef");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -88,32 +90,30 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
return {
|
||||
@@ -131,11 +131,7 @@ function createDbStub() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/agents.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -149,8 +145,6 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("agent permission routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
@@ -197,7 +191,7 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
it("grants tasks:assign by default when board creates a new agent", async () => {
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -233,7 +227,7 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -255,7 +249,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect([200, 201]).toContain(res.status);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
@@ -270,7 +264,7 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -321,7 +315,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -342,7 +336,7 @@ describe("agent permission routes", () => {
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
@@ -377,7 +371,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
@@ -408,7 +402,7 @@ describe("agent permission routes", () => {
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
|
||||
@@ -57,7 +57,7 @@ const mockAdapter = vi.hoisted(() => ({
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
@@ -149,7 +149,7 @@ function makeAgent(adapterType: string) {
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
@@ -238,9 +238,6 @@ describe("agent skill routes", () => {
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: false,
|
||||
});
|
||||
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adapterType: "claude_local",
|
||||
@@ -266,9 +263,6 @@ describe("agent skill routes", () => {
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps runtime materialization for persistent skill adapters", async () => {
|
||||
@@ -286,9 +280,6 @@ describe("agent skill routes", () => {
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips runtime materialization when syncing Claude skills", async () => {
|
||||
@@ -299,9 +290,6 @@ describe("agent skill routes", () => {
|
||||
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: false,
|
||||
});
|
||||
expect(mockAdapter.syncSkills).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -313,7 +301,6 @@ describe("agent skill routes", () => {
|
||||
.send({ desiredSkills: ["paperclip"] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
@@ -339,7 +326,6 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
@@ -367,7 +353,7 @@ describe("agent skill routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -403,7 +389,7 @@ describe("agent skill routes", () => {
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -430,7 +416,7 @@ describe("agent skill routes", () => {
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -458,7 +444,6 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalRoutes } from "../routes/approvals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -39,7 +37,11 @@ vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
async function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/approvals.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -58,7 +60,11 @@ function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||
return app;
|
||||
}
|
||||
|
||||
function createAgentApp() {
|
||||
async function createAgentApp() {
|
||||
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/approvals.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -78,7 +84,8 @@ function createAgentApp() {
|
||||
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
@@ -105,7 +112,7 @@ describe("approval routes idempotent retries", () => {
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post("/api/approvals/approval-1/approve")
|
||||
.send({});
|
||||
|
||||
@@ -134,7 +141,7 @@ describe("approval routes idempotent retries", () => {
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post("/api/approvals/approval-1/reject")
|
||||
.send({});
|
||||
|
||||
@@ -151,7 +158,7 @@ describe("approval routes idempotent retries", () => {
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post("/api/approvals/approval-2/approve")
|
||||
.send({});
|
||||
|
||||
@@ -168,7 +175,7 @@ describe("approval routes idempotent retries", () => {
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
const res = await request(await createApp())
|
||||
.post("/api/approvals/approval-3/request-revision")
|
||||
.send({ decisionNote: "Need changes" });
|
||||
|
||||
@@ -192,7 +199,7 @@ describe("approval routes idempotent retries", () => {
|
||||
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await request(createAgentApp())
|
||||
const res = await request(await createAgentApp())
|
||||
.post("/api/companies/company-1/approvals")
|
||||
.send({
|
||||
type: "request_board_approval",
|
||||
|
||||
@@ -7,13 +7,23 @@ 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 path = require("node:path");
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const addDirIndex = argv.indexOf("--add-dir");
|
||||
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
||||
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
||||
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
|
||||
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
argv,
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
addDir,
|
||||
instructionsFilePath,
|
||||
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
||||
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
appendedSystemPromptFilePath,
|
||||
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
|
||||
@@ -29,6 +39,18 @@ console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", res
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
prompt: string;
|
||||
addDir: string | null;
|
||||
instructionsFilePath: string | null;
|
||||
instructionsContents: string | null;
|
||||
skillEntries: string[];
|
||||
claudeConfigDir: string | null;
|
||||
appendedSystemPromptFilePath?: string | null;
|
||||
appendedSystemPromptFileContents?: string | null;
|
||||
};
|
||||
|
||||
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -232,47 +254,6 @@ describe("claude execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test for unnecessary file I/O on resumed sessions (Greptile P2).
|
||||
*
|
||||
* The combined agent-instructions.md temp file must NOT be written when
|
||||
* resuming, since the instructions are already baked into the session cache.
|
||||
*/
|
||||
it("does not write agent-instructions temp file on a resumed session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-"));
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
||||
const instructionsFile = path.join(root, "instructions.md");
|
||||
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-io-resume",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
promptTemplate: "Do work.",
|
||||
instructionsFilePath: instructionsFile,
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
// The skills dir lives under HOME/.paperclip/skills — verify no combined
|
||||
// agent-instructions.md was written anywhere under root on a resume.
|
||||
const allFiles = await fs.readdir(root, { recursive: true });
|
||||
const tempInstructionsWritten = (allFiles as string[]).some((f) =>
|
||||
f.includes("agent-instructions.md"),
|
||||
);
|
||||
expect(tempInstructionsWritten).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
|
||||
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
||||
@@ -406,4 +387,259 @@ describe("claude execute", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "claude");
|
||||
const capturePath1 = path.join(root, "capture-1.json");
|
||||
const capturePath2 = path.join(root, "capture-2.json");
|
||||
const instructionsPath = path.join(root, "AGENTS.md");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
|
||||
try {
|
||||
const first = await execute({
|
||||
runId: "run-1",
|
||||
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: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(first.errorMessage).toBeNull();
|
||||
expect(first.sessionParams).toMatchObject({
|
||||
sessionId: "claude-session-1",
|
||||
cwd: workspace,
|
||||
});
|
||||
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
|
||||
|
||||
const second = await execute({
|
||||
runId: "run-2",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: first.sessionParams ?? null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 1,
|
||||
includedCount: 1,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.errorMessage).toBeNull();
|
||||
|
||||
const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
||||
const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
|
||||
const expectedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"claude-prompt-cache",
|
||||
);
|
||||
|
||||
expect(capture1.addDir).toBeTruthy();
|
||||
expect(capture1.addDir).toBe(capture2.addDir);
|
||||
expect(capture1.instructionsFilePath).toBeTruthy();
|
||||
expect(capture2.instructionsFilePath ?? null).toBeNull();
|
||||
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsContents).toContain("You are managed instructions.");
|
||||
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
||||
expect(capture1.skillEntries).toContain("paperclip");
|
||||
expect(capture2.argv).toContain("--resume");
|
||||
expect(capture2.argv).toContain("claude-session-1");
|
||||
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "claude");
|
||||
const capturePath1 = path.join(root, "capture-before.json");
|
||||
const capturePath2 = path.join(root, "capture-after.json");
|
||||
const instructionsPath = path.join(root, "AGENTS.md");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
const logs: string[] = [];
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
|
||||
try {
|
||||
const first = await execute({
|
||||
runId: "run-before",
|
||||
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: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
|
||||
|
||||
const second = await execute({
|
||||
runId: "run-after",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: first.sessionParams ?? null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.errorMessage).toBeNull();
|
||||
|
||||
const before = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
||||
const after = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
|
||||
|
||||
expect(before.instructionsFilePath).not.toBe(after.instructionsFilePath);
|
||||
expect(after.argv).not.toContain("--resume");
|
||||
expect(after.prompt).toContain("Follow the paperclip heartbeat.");
|
||||
expect(logs.join("")).toContain("will not be resumed with");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
@@ -25,16 +27,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
@@ -43,28 +43,22 @@ function createApp(actor: any) {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
return import("../routes/access.js").then(({ accessRoutes }) =>
|
||||
import("../middleware/index.js").then(({ errorHandler }) => {
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes({} as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
})
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes({} as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
@@ -77,7 +71,7 @@ describe("cli auth routes", () => {
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const app = createApp({ type: "none", source: "none" });
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.send({
|
||||
@@ -113,7 +107,7 @@ describe("cli auth routes", () => {
|
||||
approvedByUser: null,
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const app = createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -139,7 +133,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
@@ -179,7 +173,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
@@ -206,7 +200,7 @@ describe("cli auth routes", () => {
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-2",
|
||||
keyId: "board-key-3",
|
||||
|
||||
@@ -39,17 +39,15 @@ const mockFeedbackService = vi.hoisted(() => ({
|
||||
saveIssueVote: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
feedbackService: () => mockFeedbackService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
feedbackService: () => mockFeedbackService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createCompany() {
|
||||
const now = new Date("2026-03-19T02:00:00.000Z");
|
||||
@@ -90,7 +88,6 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("PATCH /api/companies/:companyId/branding", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,23 +20,21 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackSkillImported: mockTrackSkillImported,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackSkillImported: mockTrackSkillImported,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -57,8 +55,7 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("company skill mutation permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [],
|
||||
|
||||
@@ -71,21 +71,19 @@ const mockBudgetService = vi.hoisted(() => ({
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
vi.mock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
const [{ costRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -119,10 +117,14 @@ async function createAppWithActor(actor: any) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function loadCostParsers() {
|
||||
const { parseCostDateRange, parseCostLimit } = await import("../routes/costs.js");
|
||||
return { parseCostDateRange, parseCostLimit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
@@ -140,30 +142,25 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("cost routes", () => {
|
||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
it("accepts valid ISO date strings", async () => {
|
||||
const { parseCostDateRange } = await loadCostParsers();
|
||||
expect(parseCostDateRange({
|
||||
from: "2026-01-01T00:00:00.000Z",
|
||||
to: "2026-01-31T23:59:59.999Z",
|
||||
})).toEqual({
|
||||
from: new Date("2026-01-01T00:00:00.000Z"),
|
||||
to: new Date("2026-01-31T23:59:59.999Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'from' date string", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "not-a-date" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'from' date/i);
|
||||
const { parseCostDateRange } = await loadCostParsers();
|
||||
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'to' date string", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ to: "banana" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
||||
const { parseCostDateRange } = await loadCostParsers();
|
||||
expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i);
|
||||
});
|
||||
|
||||
it("returns finance summary rows for valid requests", async () => {
|
||||
@@ -176,21 +173,13 @@ describe("cost routes", () => {
|
||||
});
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "0" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
||||
const { parseCostLimit } = await loadCostParsers();
|
||||
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "25" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
||||
const { parseCostLimit } = await loadCostParsers();
|
||||
expect(parseCostLimit({ limit: "25" })).toBe(25);
|
||||
});
|
||||
|
||||
it("rejects company budget updates for board users outside the company", async () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres heartbeat list tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("heartbeat list", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-list-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("returns runs even when the linked db schema lacks processGroupId", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = 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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: randomUUID() },
|
||||
});
|
||||
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(heartbeatRuns, "processGroupId");
|
||||
Object.defineProperty(heartbeatRuns, "processGroupId", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const runs = await heartbeatService(db).list(companyId, agentId, 5);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.id).toBe(runId);
|
||||
expect(runs[0]?.processGroupId ?? null).toBeNull();
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor);
|
||||
} else {
|
||||
delete (heartbeatRuns as Record<string, unknown>).processGroupId;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -49,10 +49,70 @@ function spawnAliveProcess() {
|
||||
});
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number | null | undefined) {
|
||||
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isPidAlive(pid)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
async function spawnOrphanedProcessGroup() {
|
||||
const leader = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
[
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
|
||||
"process.stdout.write(String(child.pid));",
|
||||
"setTimeout(() => process.exit(0), 25);",
|
||||
].join(" "),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
leader.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
leader.once("error", reject);
|
||||
leader.once("exit", () => resolve());
|
||||
});
|
||||
|
||||
const descendantPid = Number.parseInt(stdout.trim(), 10);
|
||||
if (!Number.isInteger(descendantPid) || descendantPid <= 0) {
|
||||
throw new Error(`Failed to capture orphaned descendant pid from detached process group: ${stdout}`);
|
||||
}
|
||||
|
||||
return {
|
||||
processPid: leader.pid ?? null,
|
||||
processGroupId: leader.pid ?? null,
|
||||
descendantPid,
|
||||
};
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const childProcesses = new Set<ChildProcess>();
|
||||
const cleanupPids = new Set<number>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
||||
@@ -66,6 +126,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
for (const pid of cleanupPids) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore already-dead cleanup targets.
|
||||
}
|
||||
}
|
||||
cleanupPids.clear();
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
@@ -79,6 +147,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
for (const pid of cleanupPids) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore already-dead cleanup targets.
|
||||
}
|
||||
}
|
||||
cleanupPids.clear();
|
||||
runningProcesses.clear();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
@@ -88,6 +164,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
agentStatus?: "paused" | "idle" | "running";
|
||||
runStatus?: "running" | "queued" | "failed";
|
||||
processPid?: number | null;
|
||||
processGroupId?: number | null;
|
||||
processLossRetryCount?: number;
|
||||
includeIssue?: boolean;
|
||||
runErrorCode?: string | null;
|
||||
@@ -143,6 +220,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
wakeupRequestId,
|
||||
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
|
||||
processPid: input?.processPid ?? null,
|
||||
processGroupId: input?.processGroupId ?? null,
|
||||
processLossRetryCount: input?.processLossRetryCount ?? 0,
|
||||
errorCode: input?.runErrorCode ?? null,
|
||||
error: input?.runError ?? null,
|
||||
@@ -228,6 +306,45 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(issue?.checkoutRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("reaps orphaned descendant process groups when the parent pid is already gone", async () => {
|
||||
const orphan = await spawnOrphanedProcessGroup();
|
||||
cleanupPids.add(orphan.descendantPid);
|
||||
expect(isPidAlive(orphan.descendantPid)).toBe(true);
|
||||
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: orphan.processPid,
|
||||
processGroupId: orphan.processGroupId,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(result.runIds).toEqual([runId]);
|
||||
|
||||
expect(await waitForPidExit(orphan.descendantPid, 2_000)).toBe(true);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const failedRun = runs.find((row) => row.id === runId);
|
||||
expect(failedRun?.status).toBe("failed");
|
||||
expect(failedRun?.errorCode).toBe("process_lost");
|
||||
expect(failedRun?.error).toContain("descendant process group");
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.status).toBe("queued");
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
|
||||
});
|
||||
|
||||
it("does not queue a second retry after the first process-loss retry was already used", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: 999_999_999,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
summarizeHeartbeatRunResultJson,
|
||||
buildHeartbeatRunIssueComment,
|
||||
mergeHeartbeatRunResultJson,
|
||||
} from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
@@ -55,3 +56,35 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeHeartbeatRunResultJson", () => {
|
||||
it("adds adapter summaries into stored result json for comment posting", () => {
|
||||
const merged = mergeHeartbeatRunResultJson(
|
||||
{ stdout: "raw stdout", stderr: "" },
|
||||
"## Summary\n\n1. first thing\n2. second thing",
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
stdout: "raw stdout",
|
||||
stderr: "",
|
||||
summary: "## Summary\n\n1. first thing\n2. second thing",
|
||||
});
|
||||
expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing");
|
||||
});
|
||||
|
||||
it("creates a result payload when only a summary exists", () => {
|
||||
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" });
|
||||
});
|
||||
|
||||
it("does not overwrite an explicit summary already returned by the adapter", () => {
|
||||
expect(
|
||||
mergeHeartbeatRunResultJson(
|
||||
{ summary: "adapter result", stdout: "raw stdout" },
|
||||
"fallback summary",
|
||||
),
|
||||
).toEqual({
|
||||
summary: "adapter result",
|
||||
stdout: "raw stdout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
async function createApp(actor: any) {
|
||||
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -37,8 +35,7 @@ async function createApp(actor: any) {
|
||||
describe("instance settings routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -170,7 +170,7 @@ describe("issue activity event routes", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||
|
||||
@@ -38,48 +38,49 @@ const mockTx = vi.hoisted(() => ({
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||
}));
|
||||
const mockFeedbackService = vi.hoisted(() => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}));
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}));
|
||||
const mockRoutineService = vi.hoisted(() => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => mockRoutineService,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
@@ -134,7 +135,7 @@ function makeIssue(status: "todo" | "done") {
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockIssueService.update.mockReset();
|
||||
@@ -151,6 +152,11 @@ describe("issue comment reopen routes", () => {
|
||||
mockHeartbeatService.cancelRun.mockReset();
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockFeedbackService.listIssueVotesForUser.mockReset();
|
||||
mockFeedbackService.saveIssueVote.mockReset();
|
||||
mockInstanceSettingsService.get.mockReset();
|
||||
mockInstanceSettingsService.listCompanyIds.mockReset();
|
||||
mockRoutineService.syncRunStatusForIssue.mockReset();
|
||||
mockTxInsertValues.mockReset();
|
||||
mockTxInsert.mockReset();
|
||||
mockDb.transaction.mockReset();
|
||||
@@ -163,6 +169,21 @@ describe("issue comment reopen routes", () => {
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
||||
mockFeedbackService.saveIssueVote.mockResolvedValue({
|
||||
vote: null,
|
||||
consentEnabledNow: false,
|
||||
sharingEnabled: false,
|
||||
});
|
||||
mockInstanceSettingsService.get.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
|
||||
@@ -21,56 +21,60 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
const mockFeedbackExportService = vi.hoisted(() => ({
|
||||
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
|
||||
}));
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
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 mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}));
|
||||
const mockRoutineService = vi.hoisted(() => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
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),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => mockRoutineService,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -91,13 +95,27 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
describe("issue feedback trace routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
|
||||
attempted: 1,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
});
|
||||
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
|
||||
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
|
||||
mockHeartbeatService.getRun.mockResolvedValue(null);
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
mockInstanceSettingsService.get.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -16,41 +18,39 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function makeIssue(status: "todo" | "done") {
|
||||
return {
|
||||
@@ -65,11 +65,7 @@ function makeIssue(status: "todo" | "done") {
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -83,8 +79,6 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
|
||||
describe("issue telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
@@ -104,7 +98,7 @@ describe("issue telemetry routes", () => {
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
@@ -123,7 +117,7 @@ describe("issue telemetry routes", () => {
|
||||
}, 10_000);
|
||||
|
||||
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
|
||||
const app = await createApp({
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { llmRoutes } from "../routes/llms.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockListServerAdapters = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
listServerAdapters: mockListServerAdapters,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", llmRoutes({} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("llm routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListServerAdapters.mockReturnValue([
|
||||
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("documents timer heartbeats as opt-in for new hires", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/llms/agent-configuration.txt");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain("Timer heartbeats are opt-in for new hires.");
|
||||
expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
@@ -33,16 +35,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
@@ -99,11 +99,7 @@ function createDbStub() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/access.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -125,9 +121,7 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerServiceMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
@@ -140,7 +134,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -165,7 +159,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -193,7 +187,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
|
||||
it("includes companyName in invite summary responses", async () => {
|
||||
const db = createDbStub();
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -215,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -238,12 +232,12 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
allowedJoinTypes: "agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||
|
||||
const unknownHostname = "blocked-host.invalid";
|
||||
|
||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
|
||||
const app = express();
|
||||
app.use(
|
||||
privateHostnameGuard({
|
||||
@@ -24,33 +24,37 @@ function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHo
|
||||
}
|
||||
|
||||
describe("privateHostnameGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("allows requests when disabled", async () => {
|
||||
const app = createApp({ enabled: false });
|
||||
const app = await createApp({ enabled: false });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows loopback hostnames", async () => {
|
||||
const app = createApp({ enabled: true });
|
||||
const app = await createApp({ enabled: true });
|
||||
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows explicitly configured hostnames", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames with remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
|
||||
@@ -21,21 +21,23 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
@@ -97,6 +99,8 @@ function buildProject(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe("project env routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
@@ -160,10 +164,6 @@ describe("project env routes", () => {
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockProjectService.update).toHaveBeenCalledWith(
|
||||
"project-1",
|
||||
expect.objectContaining({ env: normalizedEnv }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the api gateway ingest url when the default hostname fails", async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new TypeError("getaddrinfo ENOTFOUND telemetry.paperclip.ing"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const client = makeClient({ endpoint: undefined });
|
||||
client.track("install.started");
|
||||
|
||||
await client.flush();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://telemetry.paperclip.ing/ingest");
|
||||
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toBe("https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest");
|
||||
});
|
||||
|
||||
it("startPeriodicFlush is idempotent", () => {
|
||||
const client = makeClient();
|
||||
client.startPeriodicFlush(1000);
|
||||
|
||||
@@ -13,7 +13,7 @@ type BuildInvocationEnvForLogsOptions = {
|
||||
resolvedCommandEnvKey?: string;
|
||||
};
|
||||
|
||||
export const runningProcesses: Map<string, { child: ChildProcess; graceSec: number }> =
|
||||
export const runningProcesses: Map<string, { child: ChildProcess; graceSec: number; processGroupId: number | null }> =
|
||||
serverUtils.runningProcesses;
|
||||
export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES;
|
||||
export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES;
|
||||
|
||||
@@ -2441,15 +2441,14 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
|
||||
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
|
||||
if (run && run.status !== "queued" && run.status !== "running") {
|
||||
run = null;
|
||||
}
|
||||
|
||||
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
|
||||
const candidateRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
|
||||
const candidateContext = asRecord(candidateRun?.contextSnapshot);
|
||||
const candidateIssueId = asNonEmptyString(candidateContext?.issueId);
|
||||
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
|
||||
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
|
||||
if (candidateRun && candidateIssueId === issue.id) {
|
||||
run = candidateRun;
|
||||
}
|
||||
@@ -2466,7 +2465,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
|
||||
res.json({
|
||||
...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
|
||||
...run,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
|
||||
+31
-31
@@ -21,6 +21,26 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
export function parseCostDateRange(query: Record<string, unknown>) {
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
const from = fromRaw ? new Date(fromRaw) : undefined;
|
||||
const to = toRaw ? new Date(toRaw) : undefined;
|
||||
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
|
||||
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
export function parseCostLimit(query: Record<string, unknown>) {
|
||||
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
|
||||
if (raw == null || raw === "") return 100;
|
||||
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
throw badRequest("invalid 'limit' value");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function costRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const heartbeat = heartbeatService(db);
|
||||
@@ -92,30 +112,10 @@ export function costRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
function parseDateRange(query: Record<string, unknown>) {
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
const from = fromRaw ? new Date(fromRaw) : undefined;
|
||||
const to = toRaw ? new Date(toRaw) : undefined;
|
||||
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
|
||||
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
function parseLimit(query: Record<string, unknown>) {
|
||||
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
|
||||
if (raw == null || raw === "") return 100;
|
||||
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
throw badRequest("invalid 'limit' value");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/costs/summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const summary = await costs.summary(companyId, range);
|
||||
res.json(summary);
|
||||
});
|
||||
@@ -123,7 +123,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await costs.byAgent(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -131,7 +131,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await costs.byAgentModel(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -139,7 +139,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await costs.byProvider(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -147,7 +147,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await costs.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const summary = await finance.summary(companyId, range);
|
||||
res.json(summary);
|
||||
});
|
||||
@@ -163,7 +163,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await finance.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -171,7 +171,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await finance.byKind(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -179,8 +179,8 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const limit = parseLimit(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const limit = parseCostLimit(req.query);
|
||||
const rows = await finance.list(companyId, range, limit);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export function costRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const range = parseCostDateRange(req.query);
|
||||
const rows = await costs.byProject(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export function llmRoutes(db: Db) {
|
||||
"Notes:",
|
||||
"- Sensitive values are redacted in configuration read APIs.",
|
||||
"- New hires may be created in pending_approval state depending on company settings.",
|
||||
"- Timer heartbeats are opt-in for new hires. Leave runtimeConfig.heartbeat.enabled false unless the role truly needs scheduled work or the user explicitly asked for it.",
|
||||
"",
|
||||
];
|
||||
res.type("text/plain").send(lines.join("\n"));
|
||||
|
||||
@@ -13,6 +13,34 @@ function readCommentText(value: unknown) {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function mergeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
summary: string | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
const normalizedSummary = readCommentText(summary);
|
||||
const baseResult =
|
||||
resultJson && typeof resultJson === "object" && !Array.isArray(resultJson)
|
||||
? resultJson
|
||||
: null;
|
||||
|
||||
if (!baseResult) {
|
||||
return normalizedSummary ? { summary: normalizedSummary } : null;
|
||||
}
|
||||
|
||||
if (!normalizedSummary) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
if (readCommentText(baseResult.summary)) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
summary: normalizedSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
|
||||
@@ -32,7 +32,11 @@ import { companySkillService } from "./company-skills.js";
|
||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildHeartbeatRunIssueComment,
|
||||
mergeHeartbeatRunResultJson,
|
||||
summarizeHeartbeatRunResultJson,
|
||||
} from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
@@ -261,6 +266,9 @@ async function ensureManagedProjectWorkspace(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatRunProcessGroupIdColumn =
|
||||
heartbeatRuns.processGroupId ?? sql<number | null>`NULL`.as("processGroupId");
|
||||
|
||||
const heartbeatRunListColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
@@ -288,6 +296,7 @@ const heartbeatRunListColumns = {
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
externalRunId: heartbeatRuns.externalRunId,
|
||||
processPid: heartbeatRuns.processPid,
|
||||
processGroupId: heartbeatRunProcessGroupIdColumn,
|
||||
processStartedAt: heartbeatRuns.processStartedAt,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
||||
@@ -296,6 +305,18 @@ const heartbeatRunListColumns = {
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
} as const;
|
||||
|
||||
const heartbeatRunIssueSummaryColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
} as const;
|
||||
|
||||
function appendExcerpt(prev: string, chunk: string) {
|
||||
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
||||
}
|
||||
@@ -1026,6 +1047,46 @@ function isProcessAlive(pid: number | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateHeartbeatRunProcess(input: {
|
||||
pid: number | null | undefined;
|
||||
processGroupId: number | null | undefined;
|
||||
graceMs?: number;
|
||||
}) {
|
||||
const pid = input.pid ?? null;
|
||||
const processGroupId = input.processGroupId ?? null;
|
||||
if (typeof pid !== "number" && typeof processGroupId !== "number") return;
|
||||
|
||||
await terminateLocalService(
|
||||
{
|
||||
pid:
|
||||
typeof pid === "number" && Number.isInteger(pid) && pid > 0
|
||||
? pid
|
||||
: (processGroupId ?? 0),
|
||||
processGroupId:
|
||||
typeof processGroupId === "number" && Number.isInteger(processGroupId) && processGroupId > 0
|
||||
? processGroupId
|
||||
: null,
|
||||
},
|
||||
input.graceMs ? { forceAfterMs: input.graceMs } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function buildProcessLossMessage(run: {
|
||||
processPid: number | null;
|
||||
processGroupId: number | null;
|
||||
}, options?: { descendantOnly?: boolean }) {
|
||||
if (options?.descendantOnly && run.processGroupId) {
|
||||
return `Process lost -- parent pid ${run.processPid ?? "unknown"} exited, but descendant process group ${run.processGroupId} was still alive and was terminated`;
|
||||
}
|
||||
if (run.processPid) {
|
||||
return `Process lost -- child pid ${run.processPid} is no longer running`;
|
||||
}
|
||||
if (run.processGroupId) {
|
||||
return `Process lost -- process group ${run.processGroupId} is no longer running`;
|
||||
}
|
||||
return "Process lost -- server may have restarted";
|
||||
}
|
||||
|
||||
function truncateDisplayId(value: string | null | undefined, max = 128) {
|
||||
if (!value) return null;
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
@@ -1824,13 +1885,14 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
async function persistRunProcessMetadata(
|
||||
runId: string,
|
||||
meta: { pid: number; startedAt: string },
|
||||
meta: { pid: number; processGroupId: number | null; startedAt: string },
|
||||
) {
|
||||
const startedAt = new Date(meta.startedAt);
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: meta.pid,
|
||||
processGroupId: meta.processGroupId,
|
||||
processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -2356,7 +2418,9 @@ export function heartbeatService(db: Db) {
|
||||
}
|
||||
|
||||
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
|
||||
if (tracksLocalChild && run.processPid && isProcessAlive(run.processPid)) {
|
||||
const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
|
||||
const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
|
||||
if (processPidAlive) {
|
||||
if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) {
|
||||
const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`;
|
||||
const detachedRun = await setRunStatus(run.id, "running", {
|
||||
@@ -2378,10 +2442,17 @@ export function heartbeatService(db: Db) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1;
|
||||
const baseMessage = run.processPid
|
||||
? `Process lost -- child pid ${run.processPid} is no longer running`
|
||||
: "Process lost -- server may have restarted";
|
||||
let descendantOnlyCleanup = false;
|
||||
if (processGroupAlive) {
|
||||
descendantOnlyCleanup = true;
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: run.processPid,
|
||||
processGroupId: run.processGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRetry = tracksLocalChild && (!!run.processPid || !!run.processGroupId) && (run.processLossRetryCount ?? 0) < 1;
|
||||
const baseMessage = buildProcessLossMessage(run, descendantOnlyCleanup ? { descendantOnly: true } : undefined);
|
||||
|
||||
let finalizedRun = await setRunStatus(run.id, "failed", {
|
||||
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
||||
@@ -2414,6 +2485,8 @@ export function heartbeatService(db: Db) {
|
||||
: baseMessage,
|
||||
payload: {
|
||||
...(run.processPid ? { processPid: run.processPid } : {}),
|
||||
...(run.processGroupId ? { processGroupId: run.processGroupId } : {}),
|
||||
...(descendantOnlyCleanup ? { descendantOnlyCleanup: true } : {}),
|
||||
...(retriedRun ? { retryRunId: retriedRun.id } : {}),
|
||||
},
|
||||
});
|
||||
@@ -3179,7 +3252,14 @@ export function heartbeatService(db: Db) {
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, meta);
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
@@ -3299,6 +3379,11 @@ export function heartbeatService(db: Db) {
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const persistedResultJson = mergeHeartbeatRunResultJson(
|
||||
adapterResult.resultJson ?? null,
|
||||
adapterResult.summary ?? null,
|
||||
);
|
||||
|
||||
await setRunStatus(run.id, status, {
|
||||
finishedAt: new Date(),
|
||||
error:
|
||||
@@ -3319,7 +3404,7 @@ export function heartbeatService(db: Db) {
|
||||
exitCode: adapterResult.exitCode,
|
||||
signal: adapterResult.signal,
|
||||
usageJson,
|
||||
resultJson: adapterResult.resultJson ?? null,
|
||||
resultJson: persistedResultJson,
|
||||
sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId,
|
||||
stdoutExcerpt,
|
||||
stderrExcerpt,
|
||||
@@ -3347,7 +3432,7 @@ export function heartbeatService(db: Db) {
|
||||
});
|
||||
if (issueId && outcome === "succeeded") {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
}
|
||||
@@ -4242,13 +4327,16 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
if (running) {
|
||||
running.child.kill("SIGTERM");
|
||||
const graceMs = Math.max(1, running.graceSec) * 1000;
|
||||
setTimeout(() => {
|
||||
if (!running.child.killed) {
|
||||
running.child.kill("SIGKILL");
|
||||
}
|
||||
}, graceMs);
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: running.child.pid ?? run.processPid,
|
||||
processGroupId: running.processGroupId ?? run.processGroupId,
|
||||
graceMs: Math.max(1, running.graceSec) * 1000,
|
||||
});
|
||||
} else if (run.processPid || run.processGroupId) {
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: run.processPid,
|
||||
processGroupId: run.processGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
const cancelled = await setRunStatus(run.id, "cancelled", {
|
||||
@@ -4298,8 +4386,17 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
if (running) {
|
||||
running.child.kill("SIGTERM");
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: running.child.pid ?? run.processPid,
|
||||
processGroupId: running.processGroupId ?? run.processGroupId,
|
||||
graceMs: Math.max(1, running.graceSec) * 1000,
|
||||
});
|
||||
runningProcesses.delete(run.id);
|
||||
} else if (run.processPid || run.processGroupId) {
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: run.processPid,
|
||||
processGroupId: run.processGroupId,
|
||||
});
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(run);
|
||||
}
|
||||
@@ -4515,6 +4612,15 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
cancelBudgetScopeWork,
|
||||
|
||||
getRunIssueSummary: async (runId: string) => {
|
||||
const [run] = await db
|
||||
.select(heartbeatRunIssueSummaryColumns)
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.limit(1);
|
||||
return run ?? null;
|
||||
},
|
||||
|
||||
getActiveRunForAgent: async (agentId: string) => {
|
||||
const [run] = await db
|
||||
.select()
|
||||
@@ -4529,5 +4635,20 @@ export function heartbeatService(db: Db) {
|
||||
.limit(1);
|
||||
return run ?? null;
|
||||
},
|
||||
|
||||
getActiveRunIssueSummaryForAgent: async (agentId: string) => {
|
||||
const [run] = await db
|
||||
.select(heartbeatRunIssueSummaryColumns)
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
eq(heartbeatRuns.status, "running"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.startedAt))
|
||||
.limit(1);
|
||||
return run ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +221,17 @@ export function isPidAlive(pid: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isProcessGroupAlive(processGroupId: number | null | undefined) {
|
||||
if (process.platform === "win32") return false;
|
||||
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
|
||||
try {
|
||||
process.kill(-processGroupId, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||
if (process.platform === "win32") return true;
|
||||
try {
|
||||
@@ -296,13 +307,19 @@ export async function terminateLocalService(
|
||||
|
||||
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
|
||||
while (Date.now() < deadline) {
|
||||
if (!isPidAlive(record.pid)) {
|
||||
const targetAlive = targetProcessGroup
|
||||
? isProcessGroupAlive(record.processGroupId)
|
||||
: isPidAlive(record.pid);
|
||||
if (!targetAlive) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
if (!isPidAlive(record.pid)) return;
|
||||
const stillAlive = targetProcessGroup
|
||||
? isProcessGroupAlive(record.processGroupId)
|
||||
: isPidAlive(record.pid);
|
||||
if (!stillAlive) return;
|
||||
try {
|
||||
if (targetProcessGroup) {
|
||||
process.kill(-record.processGroupId!, "SIGKILL");
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type {
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
WorkspaceOperation,
|
||||
} from "@paperclipai/shared";
|
||||
import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface ActiveRunForIssue extends HeartbeatRun {
|
||||
export interface ActiveRunForIssue {
|
||||
id: string;
|
||||
status: string;
|
||||
invocationSource: string;
|
||||
triggerDetail: string | null;
|
||||
startedAt: string | Date | null;
|
||||
finishedAt: string | Date | null;
|
||||
createdAt: string | Date;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
issueId?: string | null;
|
||||
}
|
||||
|
||||
export interface LiveRunForIssue {
|
||||
|
||||
@@ -135,6 +135,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
|
||||
@@ -22,7 +22,6 @@ function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssu
|
||||
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
|
||||
return {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
@@ -31,30 +30,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
|
||||
status: "running",
|
||||
startedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: { inputTokens: 1 },
|
||||
resultJson: { summary: "partial" },
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
contextSnapshot: null,
|
||||
createdAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("FailedRunInboxRow", () => {
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
|
||||
Reference in New Issue
Block a user