mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Merge pull request #3354 from cryppadotta/pap-1331-runtime-workflows
fix: harden heartbeat and adapter runtime workflows
This commit is contained in:
commit
dae888cc5d
50 changed files with 14923 additions and 623 deletions
|
|
@ -2,6 +2,24 @@ import { randomUUID } from "node:crypto";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { runChildProcess } from "./server-utils.js";
|
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", () => {
|
describe("runChildProcess", () => {
|
||||||
it("waits for onSpawn before sending stdin to the child", async () => {
|
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||||
const spawnDelayMs = 150;
|
const spawnDelayMs = 150;
|
||||||
|
|
@ -35,4 +53,36 @@ describe("runChildProcess", () => {
|
||||||
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
|
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
|
||||||
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(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 {
|
interface RunningProcess {
|
||||||
child: ChildProcess;
|
child: ChildProcess;
|
||||||
graceSec: number;
|
graceSec: number;
|
||||||
|
processGroupId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpawnTarget {
|
interface SpawnTarget {
|
||||||
|
|
@ -34,6 +35,28 @@ type ChildProcessWithEvents = ChildProcess & {
|
||||||
): 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 runningProcesses = new Map<string, RunningProcess>();
|
||||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||||
|
|
@ -1034,7 +1057,7 @@ export async function runChildProcess(
|
||||||
graceSec: number;
|
graceSec: number;
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
onLogError?: (err: unknown, runId: string, message: string) => 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;
|
stdin?: string;
|
||||||
},
|
},
|
||||||
): Promise<RunProcessResult> {
|
): Promise<RunProcessResult> {
|
||||||
|
|
@ -1064,19 +1087,21 @@ export async function runChildProcess(
|
||||||
const child = spawn(target.command, target.args, {
|
const child = spawn(target.command, target.args, {
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
|
detached: process.platform !== "win32",
|
||||||
shell: false,
|
shell: false,
|
||||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||||
}) as ChildProcessWithEvents;
|
}) as ChildProcessWithEvents;
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
const processGroupId = resolveProcessGroupId(child);
|
||||||
|
|
||||||
const spawnPersistPromise =
|
const spawnPersistPromise =
|
||||||
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
|
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");
|
onLogError(err, runId, "failed to record child process metadata");
|
||||||
})
|
})
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
runningProcesses.set(runId, { child, graceSec: opts.graceSec, processGroupId });
|
||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
|
|
@ -1087,11 +1112,9 @@ export async function runChildProcess(
|
||||||
opts.timeoutSec > 0
|
opts.timeoutSec > 0
|
||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
child.kill("SIGTERM");
|
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!child.killed) {
|
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}, Math.max(1, opts.graceSec) * 1000);
|
}, Math.max(1, opts.graceSec) * 1000);
|
||||||
}, opts.timeoutSec * 1000)
|
}, opts.timeoutSec * 1000)
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export interface AdapterExecutionContext {
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => 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;
|
authToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
|
|
@ -33,35 +32,10 @@ import {
|
||||||
} from "./parse.js";
|
} from "./parse.js";
|
||||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||||
import { isBedrockModelId } from "./models.js";
|
import { isBedrockModelId } from "./models.js";
|
||||||
|
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
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 {
|
interface ClaudeExecutionInput {
|
||||||
runId: string;
|
runId: string;
|
||||||
agent: AdapterExecutionContext["agent"];
|
agent: AdapterExecutionContext["agent"];
|
||||||
|
|
@ -361,30 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const billingType = resolveClaudeBillingType(effectiveEnv);
|
const billingType = resolveClaudeBillingType(effectiveEnv);
|
||||||
const skillsDir = await buildSkillsDir(config);
|
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
// When instructionsFilePath is configured, build a stable content-addressed
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
// file that includes both the file content and the path directive, so we only
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
|
||||||
const canResumeSession =
|
let combinedInstructionsContents: string | null = null;
|
||||||
runtimeSessionId.length > 0 &&
|
if (instructionsFilePath) {
|
||||||
(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;
|
|
||||||
try {
|
try {
|
||||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||||
const pathDirective =
|
const pathDirective =
|
||||||
|
|
@ -392,20 +349,50 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`Resolve any relative file references from ${instructionsFileDir}. ` +
|
`Resolve any relative file references from ${instructionsFileDir}. ` +
|
||||||
`This base directory is authoritative for sibling instruction files such as ` +
|
`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.`;
|
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
|
||||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
combinedInstructionsContents = instructionsContent + pathDirective;
|
||||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
|
||||||
effectiveInstructionsFilePath = combinedPath;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
`[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 bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||||
const templateData = {
|
const templateData = {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
|
@ -460,7 +447,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||||
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
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);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
@ -482,14 +469,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
|
const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath;
|
||||||
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
|
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
|
||||||
const commandNotes =
|
const commandNotes: string[] = [];
|
||||||
attemptInstructionsFilePath && !resumeSessionId
|
if (!resumeSessionId) {
|
||||||
? [
|
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
|
||||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
}
|
||||||
]
|
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||||
: [];
|
commandNotes.push(
|
||||||
|
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onMeta) {
|
if (onMeta) {
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
|
|
@ -586,6 +576,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd,
|
||||||
|
promptBundleKey: promptBundle.bundleKey,
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
|
@ -618,25 +609,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const initial = await runAttempt(sessionId ?? null);
|
||||||
const initial = await runAttempt(sessionId ?? null);
|
if (
|
||||||
if (
|
sessionId &&
|
||||||
sessionId &&
|
!initial.proc.timedOut &&
|
||||||
!initial.proc.timedOut &&
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
initial.parsed &&
|
||||||
initial.parsed &&
|
isClaudeUnknownSessionError(initial.parsed)
|
||||||
isClaudeUnknownSessionError(initial.parsed)
|
) {
|
||||||
) {
|
await onLog(
|
||||||
await onLog(
|
"stdout",
|
||||||
"stdout",
|
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
);
|
||||||
);
|
const retry = await runAttempt(null);
|
||||||
const retry = await runAttempt(null);
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
|
||||||
} finally {
|
|
||||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||||
readNonEmptyString(record.cwd) ??
|
readNonEmptyString(record.cwd) ??
|
||||||
readNonEmptyString(record.workdir) ??
|
readNonEmptyString(record.workdir) ??
|
||||||
readNonEmptyString(record.folder);
|
readNonEmptyString(record.folder);
|
||||||
|
const promptBundleKey =
|
||||||
|
readNonEmptyString(record.promptBundleKey) ??
|
||||||
|
readNonEmptyString(record.prompt_bundle_key);
|
||||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(repoUrl ? { repoUrl } : {}),
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
...(repoRef ? { repoRef } : {}),
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
|
@ -55,12 +59,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||||
readNonEmptyString(params.cwd) ??
|
readNonEmptyString(params.cwd) ??
|
||||||
readNonEmptyString(params.workdir) ??
|
readNonEmptyString(params.workdir) ??
|
||||||
readNonEmptyString(params.folder);
|
readNonEmptyString(params.folder);
|
||||||
|
const promptBundleKey =
|
||||||
|
readNonEmptyString(params.promptBundleKey) ??
|
||||||
|
readNonEmptyString(params.prompt_bundle_key);
|
||||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(repoUrl ? { repoUrl } : {}),
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
...(repoRef ? { repoRef } : {}),
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
|
|
||||||
172
packages/adapters/claude-local/src/server/prompt-cache.ts
Normal file
172
packages/adapters/claude-local/src/server/prompt-cache.ts
Normal file
|
|
@ -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,
|
sourcePath: entry.source,
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
detail: desiredSet.has(entry.key)
|
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,
|
: null,
|
||||||
required: Boolean(entry.required),
|
required: Boolean(entry.required),
|
||||||
requiredReason: entry.requiredReason ?? null,
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,39 @@ describe("parseCodexJsonl", () => {
|
||||||
errorMessage: "resume failed",
|
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", () => {
|
describe("isCodexUnknownSessionError", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter
|
||||||
|
|
||||||
export function parseCodexJsonl(stdout: string) {
|
export function parseCodexJsonl(stdout: string) {
|
||||||
let sessionId: string | null = null;
|
let sessionId: string | null = null;
|
||||||
const messages: string[] = [];
|
let finalMessage: string | null = null;
|
||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
|
|
@ -33,7 +33,7 @@ export function parseCodexJsonl(stdout: string) {
|
||||||
const item = parseObject(event.item);
|
const item = parseObject(event.item);
|
||||||
if (asString(item.type, "") === "agent_message") {
|
if (asString(item.type, "") === "agent_message") {
|
||||||
const text = asString(item.text, "");
|
const text = asString(item.text, "");
|
||||||
if (text) messages.push(text);
|
if (text) finalMessage = text;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ export function parseCodexJsonl(stdout: string) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
summary: messages.join("\n\n").trim(),
|
summary: finalMessage?.trim() ?? "",
|
||||||
usage,
|
usage,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
packages/db/src/migrations/0055_kind_weapon_omega.sql
Normal file
1
packages/db/src/migrations/0055_kind_weapon_omega.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_group_id" integer;--> statement-breakpoint
|
||||||
13206
packages/db/src/migrations/meta/0055_snapshot.json
Normal file
13206
packages/db/src/migrations/meta/0055_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -386,6 +386,13 @@
|
||||||
"when": 1775750400000,
|
"when": 1775750400000,
|
||||||
"tag": "0054_draft_routines",
|
"tag": "0054_draft_routines",
|
||||||
"breakpoints": true
|
"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"),
|
errorCode: text("error_code"),
|
||||||
externalRunId: text("external_run_id"),
|
externalRunId: text("external_run_id"),
|
||||||
processPid: integer("process_pid"),
|
processPid: integer("process_pid"),
|
||||||
|
processGroupId: integer("process_group_id"),
|
||||||
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
|
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
|
||||||
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
|
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import type {
|
||||||
TelemetryState,
|
TelemetryState,
|
||||||
} from "./types.js";
|
} 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 BATCH_SIZE = 50;
|
||||||
const SEND_TIMEOUT_MS = 5_000;
|
const SEND_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
|
@ -44,29 +47,35 @@ export class TelemetryClient {
|
||||||
|
|
||||||
const events = this.queue.splice(0);
|
const events = this.queue.splice(0);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
|
const endpoints = this.resolveEndpoints();
|
||||||
const app = this.config.app ?? "paperclip";
|
const app = this.config.app ?? "paperclip";
|
||||||
const schemaVersion = this.config.schemaVersion ?? "1";
|
const schemaVersion = this.config.schemaVersion ?? "1";
|
||||||
|
const body = JSON.stringify({
|
||||||
|
app,
|
||||||
|
schemaVersion,
|
||||||
|
installId: state.installId,
|
||||||
|
version: this.version,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
for (const endpoint of endpoints) {
|
||||||
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
const controller = new AbortController();
|
||||||
try {
|
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
||||||
await fetch(endpoint, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(endpoint, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({
|
headers: { "Content-Type": "application/json" },
|
||||||
app,
|
body,
|
||||||
schemaVersion,
|
signal: controller.signal,
|
||||||
installId: state.installId,
|
});
|
||||||
version: this.version,
|
if (response.ok) {
|
||||||
events,
|
return;
|
||||||
}),
|
}
|
||||||
signal: controller.signal,
|
} catch {
|
||||||
});
|
// Try the next built-in endpoint before dropping the batch.
|
||||||
} catch {
|
} finally {
|
||||||
// Fire-and-forget: silent failure, no retries
|
clearTimeout(timer);
|
||||||
} finally {
|
}
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,4 +111,9 @@ export class TelemetryClient {
|
||||||
}
|
}
|
||||||
return this.state;
|
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;
|
errorCode: string | null;
|
||||||
externalRunId: string | null;
|
externalRunId: string | null;
|
||||||
processPid: number | null;
|
processPid: number | null;
|
||||||
|
processGroupId?: number | null;
|
||||||
processStartedAt: Date | null;
|
processStartedAt: Date | null;
|
||||||
retryOfRunId: string | null;
|
retryOfRunId: string | null;
|
||||||
processLossRetryCount: number;
|
processLossRetryCount: number;
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,19 @@ describe("adapter session codecs", () => {
|
||||||
const parsed = claudeSessionCodec.deserialize({
|
const parsed = claudeSessionCodec.deserialize({
|
||||||
session_id: "claude-session-1",
|
session_id: "claude-session-1",
|
||||||
folder: "/tmp/workspace",
|
folder: "/tmp/workspace",
|
||||||
|
prompt_bundle_key: "bundle-1",
|
||||||
});
|
});
|
||||||
expect(parsed).toEqual({
|
expect(parsed).toEqual({
|
||||||
sessionId: "claude-session-1",
|
sessionId: "claude-session-1",
|
||||||
cwd: "/tmp/workspace",
|
cwd: "/tmp/workspace",
|
||||||
|
promptBundleKey: "bundle-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialized = claudeSessionCodec.serialize(parsed);
|
const serialized = claudeSessionCodec.serialize(parsed);
|
||||||
expect(serialized).toEqual({
|
expect(serialized).toEqual({
|
||||||
sessionId: "claude-session-1",
|
sessionId: "claude-session-1",
|
||||||
cwd: "/tmp/workspace",
|
cwd: "/tmp/workspace",
|
||||||
|
promptBundleKey: "bundle-1",
|
||||||
});
|
});
|
||||||
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-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(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({
|
expect(res.body.adapterConfig).toMatchObject({
|
||||||
command: "codex --profile engineer",
|
command: "codex --profile engineer",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
117
server/src/__tests__/agent-live-run-routes.test.ts
Normal file
117
server/src/__tests__/agent-live-run-routes.test.ts
Normal file
|
|
@ -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 express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 agentId = "11111111-1111-4111-8111-111111111111";
|
||||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
|
@ -88,32 +90,30 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerServiceMocks() {
|
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
trackAgentCreated: mockTrackAgentCreated,
|
||||||
trackAgentCreated: mockTrackAgentCreated,
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
getTelemetryClient: mockGetTelemetryClient,
|
getTelemetryClient: mockGetTelemetryClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
agentInstructionsService: () => mockAgentInstructionsService,
|
agentInstructionsService: () => mockAgentInstructionsService,
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
approvalService: () => mockApprovalService,
|
approvalService: () => mockApprovalService,
|
||||||
companySkillService: () => mockCompanySkillService,
|
companySkillService: () => mockCompanySkillService,
|
||||||
budgetService: () => mockBudgetService,
|
budgetService: () => mockBudgetService,
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
issueApprovalService: () => mockIssueApprovalService,
|
issueApprovalService: () => mockIssueApprovalService,
|
||||||
issueService: () => mockIssueService,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
function createDbStub() {
|
function createDbStub() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -131,11 +131,7 @@ function createDbStub() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>) {
|
function createApp(actor: Record<string, unknown>) {
|
||||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
|
||||||
import("../routes/agents.js"),
|
|
||||||
import("../middleware/index.js"),
|
|
||||||
]);
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
|
|
@ -149,8 +145,6 @@ async function createApp(actor: Record<string, unknown>) {
|
||||||
|
|
||||||
describe("agent permission routes", () => {
|
describe("agent permission routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
|
||||||
registerServiceMocks();
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
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 () => {
|
it("grants tasks:assign by default when board creates a new agent", async () => {
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "local_implicit",
|
source: "local_implicit",
|
||||||
|
|
@ -233,7 +227,7 @@ describe("agent permission routes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "local_implicit",
|
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(
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||||
companyId,
|
companyId,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -270,7 +264,7 @@ describe("agent permission routes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "local_implicit",
|
source: "local_implicit",
|
||||||
|
|
@ -321,7 +315,7 @@ describe("agent permission routes", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "local_implicit",
|
source: "local_implicit",
|
||||||
|
|
@ -342,7 +336,7 @@ describe("agent permission routes", () => {
|
||||||
permissions: { canCreateAgents: true },
|
permissions: { canCreateAgents: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "local_implicit",
|
source: "local_implicit",
|
||||||
|
|
@ -377,7 +371,7 @@ describe("agent permission routes", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId,
|
agentId,
|
||||||
companyId,
|
companyId,
|
||||||
|
|
@ -408,7 +402,7 @@ describe("agent permission routes", () => {
|
||||||
status: "running",
|
status: "running",
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "board-user",
|
userId: "board-user",
|
||||||
source: "session",
|
source: "session",
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ const mockAdapter = vi.hoisted(() => ({
|
||||||
syncSkills: vi.fn(),
|
syncSkills: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function registerRouteMocks() {
|
function registerModuleMocks() {
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||||
trackAgentCreated: mockTrackAgentCreated,
|
trackAgentCreated: mockTrackAgentCreated,
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
|
|
@ -149,7 +149,7 @@ function makeAgent(adapterType: string) {
|
||||||
describe("agent skill routes", () => {
|
describe("agent skill routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerRouteMocks();
|
registerModuleMocks();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockAgentService.resolveByReference.mockResolvedValue({
|
mockAgentService.resolveByReference.mockResolvedValue({
|
||||||
|
|
@ -238,9 +238,6 @@ describe("agent skill routes", () => {
|
||||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
|
||||||
materializeMissing: false,
|
|
||||||
});
|
|
||||||
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
|
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
|
|
@ -266,9 +263,6 @@ describe("agent skill routes", () => {
|
||||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
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 () => {
|
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");
|
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
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 () => {
|
it("skips runtime materialization when syncing Claude skills", async () => {
|
||||||
|
|
@ -299,9 +290,6 @@ describe("agent skill routes", () => {
|
||||||
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
|
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
|
||||||
materializeMissing: false,
|
|
||||||
});
|
|
||||||
expect(mockAdapter.syncSkills).toHaveBeenCalled();
|
expect(mockAdapter.syncSkills).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -313,7 +301,6 @@ describe("agent skill routes", () => {
|
||||||
.send({ desiredSkills: ["paperclip"] });
|
.send({ desiredSkills: ["paperclip"] });
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
|
||||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -339,7 +326,6 @@ describe("agent skill routes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
|
||||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
expect.objectContaining({
|
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(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "11111111-1111-4111-8111-111111111111",
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|
@ -403,7 +389,7 @@ describe("agent skill routes", () => {
|
||||||
adapterConfig: {},
|
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(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "11111111-1111-4111-8111-111111111111",
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|
@ -430,7 +416,7 @@ describe("agent skill routes", () => {
|
||||||
adapterConfig: {},
|
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(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "11111111-1111-4111-8111-111111111111",
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|
@ -458,7 +444,6 @@ describe("agent skill routes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
|
||||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { approvalRoutes } from "../routes/approvals.js";
|
|
||||||
import { errorHandler } from "../middleware/index.js";
|
|
||||||
|
|
||||||
const mockApprovalService = vi.hoisted(() => ({
|
const mockApprovalService = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
|
@ -39,7 +37,11 @@ vi.mock("../services/index.js", () => ({
|
||||||
secretService: () => mockSecretService,
|
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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
|
|
@ -58,7 +60,11 @@ function createApp(actorOverrides: Record<string, unknown> = {}) {
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAgentApp() {
|
async function createAgentApp() {
|
||||||
|
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
import("../routes/approvals.js"),
|
||||||
|
import("../middleware/index.js"),
|
||||||
|
]);
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
|
|
@ -78,7 +84,8 @@ function createAgentApp() {
|
||||||
|
|
||||||
describe("approval routes idempotent retries", () => {
|
describe("approval routes idempotent retries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.resetModules();
|
||||||
|
vi.resetAllMocks();
|
||||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
|
|
@ -105,7 +112,7 @@ describe("approval routes idempotent retries", () => {
|
||||||
applied: false,
|
applied: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(createApp())
|
const res = await request(await createApp())
|
||||||
.post("/api/approvals/approval-1/approve")
|
.post("/api/approvals/approval-1/approve")
|
||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
|
|
@ -134,7 +141,7 @@ describe("approval routes idempotent retries", () => {
|
||||||
applied: false,
|
applied: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(createApp())
|
const res = await request(await createApp())
|
||||||
.post("/api/approvals/approval-1/reject")
|
.post("/api/approvals/approval-1/reject")
|
||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
|
|
@ -151,7 +158,7 @@ describe("approval routes idempotent retries", () => {
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(createApp())
|
const res = await request(await createApp())
|
||||||
.post("/api/approvals/approval-2/approve")
|
.post("/api/approvals/approval-2/approve")
|
||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
|
|
@ -168,7 +175,7 @@ describe("approval routes idempotent retries", () => {
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(createApp())
|
const res = await request(await createApp())
|
||||||
.post("/api/approvals/approval-3/request-revision")
|
.post("/api/approvals/approval-3/request-revision")
|
||||||
.send({ decisionNote: "Need changes" });
|
.send({ decisionNote: "Need changes" });
|
||||||
|
|
||||||
|
|
@ -192,7 +199,7 @@ describe("approval routes idempotent retries", () => {
|
||||||
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
|
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")
|
.post("/api/companies/company-1/approvals")
|
||||||
.send({
|
.send({
|
||||||
type: "request_board_approval",
|
type: "request_board_approval",
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,23 @@ import { execute } from "@paperclipai/adapter-claude-local/server";
|
||||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||||
const script = `#!/usr/bin/env node
|
const script = `#!/usr/bin/env node
|
||||||
const fs = require("node:fs");
|
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 capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||||
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
|
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
|
||||||
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
|
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
|
||||||
const payload = {
|
const payload = {
|
||||||
argv: process.argv.slice(2),
|
argv,
|
||||||
prompt: fs.readFileSync(0, "utf8"),
|
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,
|
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||||
appendedSystemPromptFilePath,
|
appendedSystemPromptFilePath,
|
||||||
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
|
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);
|
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> {
|
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
||||||
const script = `#!/usr/bin/env node
|
const script = `#!/usr/bin/env node
|
||||||
const fs = require("node:fs");
|
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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
|
||||||
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
||||||
|
|
@ -406,4 +387,259 @@ describe("claude execute", () => {
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
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 express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { accessRoutes } from "../routes/access.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
const mockAccessService = vi.hoisted(() => ({
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
isInstanceAdmin: vi.fn(),
|
isInstanceAdmin: vi.fn(),
|
||||||
|
|
@ -25,16 +27,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||||
|
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerServiceMocks() {
|
vi.mock("../services/index.js", () => ({
|
||||||
vi.doMock("../services/index.js", () => ({
|
accessService: () => mockAccessService,
|
||||||
accessService: () => mockAccessService,
|
agentService: () => mockAgentService,
|
||||||
agentService: () => mockAgentService,
|
boardAuthService: () => mockBoardAuthService,
|
||||||
boardAuthService: () => mockBoardAuthService,
|
logActivity: mockLogActivity,
|
||||||
logActivity: mockLogActivity,
|
notifyHireApproved: vi.fn(),
|
||||||
notifyHireApproved: vi.fn(),
|
deduplicateAgentName: vi.fn((name: string) => name),
|
||||||
deduplicateAgentName: vi.fn((name: string) => name),
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp(actor: any) {
|
function createApp(actor: any) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -43,28 +43,22 @@ function createApp(actor: any) {
|
||||||
req.actor = actor;
|
req.actor = actor;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
return import("../routes/access.js").then(({ accessRoutes }) =>
|
app.use(
|
||||||
import("../middleware/index.js").then(({ errorHandler }) => {
|
"/api",
|
||||||
app.use(
|
accessRoutes({} as any, {
|
||||||
"/api",
|
deploymentMode: "authenticated",
|
||||||
accessRoutes({} as any, {
|
deploymentExposure: "private",
|
||||||
deploymentMode: "authenticated",
|
bindHost: "127.0.0.1",
|
||||||
deploymentExposure: "private",
|
allowedHostnames: [],
|
||||||
bindHost: "127.0.0.1",
|
}),
|
||||||
allowedHostnames: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
app.use(errorHandler);
|
|
||||||
return app;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("cli auth routes", () => {
|
describe("cli auth routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetAllMocks();
|
||||||
registerServiceMocks();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||||
|
|
@ -77,7 +71,7 @@ describe("cli auth routes", () => {
|
||||||
pendingBoardToken: "pcp_board_token",
|
pendingBoardToken: "pcp_board_token",
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = await createApp({ type: "none", source: "none" });
|
const app = createApp({ type: "none", source: "none" });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/cli-auth/challenges")
|
.post("/api/cli-auth/challenges")
|
||||||
.send({
|
.send({
|
||||||
|
|
@ -113,7 +107,7 @@ describe("cli auth routes", () => {
|
||||||
approvedByUser: null,
|
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");
|
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
@ -139,7 +133,7 @@ describe("cli auth routes", () => {
|
||||||
});
|
});
|
||||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
source: "session",
|
source: "session",
|
||||||
|
|
@ -179,7 +173,7 @@ describe("cli auth routes", () => {
|
||||||
});
|
});
|
||||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "admin-1",
|
userId: "admin-1",
|
||||||
source: "session",
|
source: "session",
|
||||||
|
|
@ -206,7 +200,7 @@ describe("cli auth routes", () => {
|
||||||
});
|
});
|
||||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "admin-2",
|
userId: "admin-2",
|
||||||
keyId: "board-key-3",
|
keyId: "board-key-3",
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,15 @@ const mockFeedbackService = vi.hoisted(() => ({
|
||||||
saveIssueVote: vi.fn(),
|
saveIssueVote: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function registerServiceMocks() {
|
vi.mock("../services/index.js", () => ({
|
||||||
vi.doMock("../services/index.js", () => ({
|
accessService: () => mockAccessService,
|
||||||
accessService: () => mockAccessService,
|
agentService: () => mockAgentService,
|
||||||
agentService: () => mockAgentService,
|
budgetService: () => mockBudgetService,
|
||||||
budgetService: () => mockBudgetService,
|
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
companyService: () => mockCompanyService,
|
||||||
companyService: () => mockCompanyService,
|
feedbackService: () => mockFeedbackService,
|
||||||
feedbackService: () => mockFeedbackService,
|
logActivity: mockLogActivity,
|
||||||
logActivity: mockLogActivity,
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCompany() {
|
function createCompany() {
|
||||||
const now = new Date("2026-03-19T02:00:00.000Z");
|
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", () => {
|
describe("PATCH /api/companies/:companyId/branding", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerServiceMocks();
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,23 +20,21 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerRouteMocks() {
|
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
trackSkillImported: mockTrackSkillImported,
|
||||||
trackSkillImported: mockTrackSkillImported,
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
getTelemetryClient: mockGetTelemetryClient,
|
getTelemetryClient: mockGetTelemetryClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
companySkillService: () => mockCompanySkillService,
|
companySkillService: () => mockCompanySkillService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>) {
|
async function createApp(actor: Record<string, unknown>) {
|
||||||
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
|
@ -57,8 +55,7 @@ async function createApp(actor: Record<string, unknown>) {
|
||||||
describe("company skill mutation permissions", () => {
|
describe("company skill mutation permissions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerRouteMocks();
|
vi.resetAllMocks();
|
||||||
vi.clearAllMocks();
|
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||||
imported: [],
|
imported: [],
|
||||||
|
|
|
||||||
|
|
@ -71,21 +71,19 @@ const mockBudgetService = vi.hoisted(() => ({
|
||||||
resolveIncident: vi.fn(),
|
resolveIncident: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function registerRouteMocks() {
|
vi.mock("../services/index.js", () => ({
|
||||||
vi.doMock("../services/index.js", () => ({
|
budgetService: () => mockBudgetService,
|
||||||
budgetService: () => mockBudgetService,
|
costService: () => mockCostService,
|
||||||
costService: () => mockCostService,
|
financeService: () => mockFinanceService,
|
||||||
financeService: () => mockFinanceService,
|
companyService: () => mockCompanyService,
|
||||||
companyService: () => mockCompanyService,
|
agentService: () => mockAgentService,
|
||||||
agentService: () => mockAgentService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
heartbeatService: () => mockHeartbeatService,
|
logActivity: mockLogActivity,
|
||||||
logActivity: mockLogActivity,
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../services/quota-windows.js", () => ({
|
vi.mock("../services/quota-windows.js", () => ({
|
||||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
async function createApp() {
|
async function createApp() {
|
||||||
const [{ costRoutes }, { errorHandler }] = await Promise.all([
|
const [{ costRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
|
@ -119,10 +117,14 @@ async function createAppWithActor(actor: any) {
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCostParsers() {
|
||||||
|
const { parseCostDateRange, parseCostLimit } = await import("../routes/costs.js");
|
||||||
|
return { parseCostDateRange, parseCostLimit };
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerRouteMocks();
|
vi.resetAllMocks();
|
||||||
vi.clearAllMocks();
|
|
||||||
mockCompanyService.update.mockResolvedValue({
|
mockCompanyService.update.mockResolvedValue({
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
name: "Paperclip",
|
name: "Paperclip",
|
||||||
|
|
@ -140,30 +142,25 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cost routes", () => {
|
describe("cost routes", () => {
|
||||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
it("accepts valid ISO date strings", async () => {
|
||||||
const app = await createApp();
|
const { parseCostDateRange } = await loadCostParsers();
|
||||||
const res = await request(app)
|
expect(parseCostDateRange({
|
||||||
.get("/api/companies/company-1/costs/summary")
|
from: "2026-01-01T00:00:00.000Z",
|
||||||
.query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" });
|
to: "2026-01-31T23:59:59.999Z",
|
||||||
expect(res.status).toBe(200);
|
})).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 () => {
|
it("returns 400 for an invalid 'from' date string", async () => {
|
||||||
const app = await createApp();
|
const { parseCostDateRange } = await loadCostParsers();
|
||||||
const res = await request(app)
|
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
|
||||||
.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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for an invalid 'to' date string", async () => {
|
it("returns 400 for an invalid 'to' date string", async () => {
|
||||||
const app = await createApp();
|
const { parseCostDateRange } = await loadCostParsers();
|
||||||
const res = await request(app)
|
expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i);
|
||||||
.get("/api/companies/company-1/costs/summary")
|
|
||||||
.query({ to: "banana" });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns finance summary rows for valid requests", async () => {
|
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 () => {
|
it("returns 400 for invalid finance event list limits", async () => {
|
||||||
const app = await createApp();
|
const { parseCostLimit } = await loadCostParsers();
|
||||||
const res = await request(app)
|
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
|
||||||
.get("/api/companies/company-1/costs/finance-events")
|
|
||||||
.query({ limit: "0" });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts valid finance event list limits", async () => {
|
it("accepts valid finance event list limits", async () => {
|
||||||
const app = await createApp();
|
const { parseCostLimit } = await loadCostParsers();
|
||||||
const res = await request(app)
|
expect(parseCostLimit({ limit: "25" })).toBe(25);
|
||||||
.get("/api/companies/company-1/costs/finance-events")
|
|
||||||
.query({ limit: "25" });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects company budget updates for board users outside the company", async () => {
|
it("rejects company budget updates for board users outside the company", async () => {
|
||||||
|
|
|
||||||
91
server/src/__tests__/heartbeat-list.test.ts
Normal file
91
server/src/__tests__/heartbeat-list.test.ts
Normal file
|
|
@ -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", () => {
|
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
const childProcesses = new Set<ChildProcess>();
|
const childProcesses = new Set<ChildProcess>();
|
||||||
|
const cleanupPids = new Set<number>();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
||||||
|
|
@ -66,6 +126,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
childProcesses.clear();
|
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(issues);
|
||||||
await db.delete(heartbeatRunEvents);
|
await db.delete(heartbeatRunEvents);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
|
|
@ -79,6 +147,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
childProcesses.clear();
|
childProcesses.clear();
|
||||||
|
for (const pid of cleanupPids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Ignore already-dead cleanup targets.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupPids.clear();
|
||||||
runningProcesses.clear();
|
runningProcesses.clear();
|
||||||
await tempDb?.cleanup();
|
await tempDb?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
@ -88,6 +164,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
agentStatus?: "paused" | "idle" | "running";
|
agentStatus?: "paused" | "idle" | "running";
|
||||||
runStatus?: "running" | "queued" | "failed";
|
runStatus?: "running" | "queued" | "failed";
|
||||||
processPid?: number | null;
|
processPid?: number | null;
|
||||||
|
processGroupId?: number | null;
|
||||||
processLossRetryCount?: number;
|
processLossRetryCount?: number;
|
||||||
includeIssue?: boolean;
|
includeIssue?: boolean;
|
||||||
runErrorCode?: string | null;
|
runErrorCode?: string | null;
|
||||||
|
|
@ -143,6 +220,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
wakeupRequestId,
|
wakeupRequestId,
|
||||||
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
|
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
|
||||||
processPid: input?.processPid ?? null,
|
processPid: input?.processPid ?? null,
|
||||||
|
processGroupId: input?.processGroupId ?? null,
|
||||||
processLossRetryCount: input?.processLossRetryCount ?? 0,
|
processLossRetryCount: input?.processLossRetryCount ?? 0,
|
||||||
errorCode: input?.runErrorCode ?? null,
|
errorCode: input?.runErrorCode ?? null,
|
||||||
error: input?.runError ?? null,
|
error: input?.runError ?? null,
|
||||||
|
|
@ -228,6 +306,45 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
expect(issue?.checkoutRunId).toBe(runId);
|
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 () => {
|
it("does not queue a second retry after the first process-loss retry was already used", async () => {
|
||||||
const { agentId, runId, issueId } = await seedRunFixture({
|
const { agentId, runId, issueId } = await seedRunFixture({
|
||||||
processPid: 999_999_999,
|
processPid: 999_999_999,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
summarizeHeartbeatRunResultJson,
|
summarizeHeartbeatRunResultJson,
|
||||||
buildHeartbeatRunIssueComment,
|
buildHeartbeatRunIssueComment,
|
||||||
|
mergeHeartbeatRunResultJson,
|
||||||
} from "../services/heartbeat-run-summary.js";
|
} from "../services/heartbeat-run-summary.js";
|
||||||
|
|
||||||
describe("summarizeHeartbeatRunResultJson", () => {
|
describe("summarizeHeartbeatRunResultJson", () => {
|
||||||
|
|
@ -55,3 +56,35 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||||
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
|
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());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerRouteMocks() {
|
vi.mock("../services/index.js", () => ({
|
||||||
vi.doMock("../services/index.js", () => ({
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
instanceSettingsService: () => mockInstanceSettingsService,
|
logActivity: mockLogActivity,
|
||||||
logActivity: mockLogActivity,
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createApp(actor: any) {
|
async function createApp(actor: any) {
|
||||||
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
|
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
|
@ -37,8 +35,7 @@ async function createApp(actor: any) {
|
||||||
describe("instance settings routes", () => {
|
describe("instance settings routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerRouteMocks();
|
vi.resetAllMocks();
|
||||||
vi.clearAllMocks();
|
|
||||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||||
censorUsernameInLogs: false,
|
censorUsernameInLogs: false,
|
||||||
keyboardShortcuts: false,
|
keyboardShortcuts: false,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { errorHandler } from "../middleware/index.js";
|
|
||||||
import { issueRoutes } from "../routes/issues.js";
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||||
|
|
||||||
const mockIssueService = vi.hoisted(() => ({
|
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 () => {
|
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||||
|
|
|
||||||
|
|
@ -38,48 +38,49 @@ const mockTx = vi.hoisted(() => ({
|
||||||
const mockDb = vi.hoisted(() => ({
|
const mockDb = vi.hoisted(() => ({
|
||||||
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
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.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
trackAgentTaskCompleted: vi.fn(),
|
||||||
trackAgentTaskCompleted: vi.fn(),
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => mockFeedbackService,
|
||||||
listIssueVotesForUser: vi.fn(async () => []),
|
goalService: () => ({}),
|
||||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
heartbeatService: () => mockHeartbeatService,
|
||||||
}),
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
goalService: () => ({}),
|
issueApprovalService: () => ({}),
|
||||||
heartbeatService: () => mockHeartbeatService,
|
issueService: () => mockIssueService,
|
||||||
instanceSettingsService: () => ({
|
logActivity: mockLogActivity,
|
||||||
get: vi.fn(async () => ({
|
projectService: () => ({}),
|
||||||
id: "instance-settings-1",
|
routineService: () => mockRoutineService,
|
||||||
general: {
|
workProductService: () => ({}),
|
||||||
censorUsernameInLogs: false,
|
}));
|
||||||
feedbackDataSharingPreference: "prompt",
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
|
||||||
}),
|
|
||||||
issueApprovalService: () => ({}),
|
|
||||||
issueService: () => mockIssueService,
|
|
||||||
logActivity: mockLogActivity,
|
|
||||||
projectService: () => ({}),
|
|
||||||
routineService: () => ({
|
|
||||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
|
||||||
}),
|
|
||||||
workProductService: () => ({}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -134,7 +135,7 @@ function makeIssue(status: "todo" | "done") {
|
||||||
describe("issue comment reopen routes", () => {
|
describe("issue comment reopen routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerServiceMocks();
|
vi.resetAllMocks();
|
||||||
mockIssueService.getById.mockReset();
|
mockIssueService.getById.mockReset();
|
||||||
mockIssueService.assertCheckoutOwner.mockReset();
|
mockIssueService.assertCheckoutOwner.mockReset();
|
||||||
mockIssueService.update.mockReset();
|
mockIssueService.update.mockReset();
|
||||||
|
|
@ -151,6 +152,11 @@ describe("issue comment reopen routes", () => {
|
||||||
mockHeartbeatService.cancelRun.mockReset();
|
mockHeartbeatService.cancelRun.mockReset();
|
||||||
mockAgentService.getById.mockReset();
|
mockAgentService.getById.mockReset();
|
||||||
mockLogActivity.mockReset();
|
mockLogActivity.mockReset();
|
||||||
|
mockFeedbackService.listIssueVotesForUser.mockReset();
|
||||||
|
mockFeedbackService.saveIssueVote.mockReset();
|
||||||
|
mockInstanceSettingsService.get.mockReset();
|
||||||
|
mockInstanceSettingsService.listCompanyIds.mockReset();
|
||||||
|
mockRoutineService.syncRunStatusForIssue.mockReset();
|
||||||
mockTxInsertValues.mockReset();
|
mockTxInsertValues.mockReset();
|
||||||
mockTxInsert.mockReset();
|
mockTxInsert.mockReset();
|
||||||
mockDb.transaction.mockReset();
|
mockDb.transaction.mockReset();
|
||||||
|
|
@ -163,6 +169,21 @@ describe("issue comment reopen routes", () => {
|
||||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
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({
|
mockIssueService.addComment.mockResolvedValue({
|
||||||
id: "comment-1",
|
id: "comment-1",
|
||||||
issueId: "11111111-1111-4111-8111-111111111111",
|
issueId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|
|
||||||
|
|
@ -21,56 +21,60 @@ const mockIssueService = vi.hoisted(() => ({
|
||||||
const mockFeedbackExportService = vi.hoisted(() => ({
|
const mockFeedbackExportService = vi.hoisted(() => ({
|
||||||
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
|
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.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
trackAgentTaskCompleted: vi.fn(),
|
||||||
trackAgentTaskCompleted: vi.fn(),
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
accessService: () => ({
|
accessService: () => mockAccessService,
|
||||||
canUser: vi.fn(),
|
agentService: () => mockAgentService,
|
||||||
hasPermission: vi.fn(),
|
documentService: () => ({}),
|
||||||
}),
|
executionWorkspaceService: () => ({}),
|
||||||
agentService: () => ({
|
feedbackService: () => mockFeedbackService,
|
||||||
getById: vi.fn(),
|
goalService: () => ({}),
|
||||||
}),
|
heartbeatService: () => mockHeartbeatService,
|
||||||
documentService: () => ({}),
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
executionWorkspaceService: () => ({}),
|
issueApprovalService: () => ({}),
|
||||||
feedbackService: () => mockFeedbackService,
|
issueService: () => mockIssueService,
|
||||||
goalService: () => ({}),
|
logActivity: mockLogActivity,
|
||||||
heartbeatService: () => ({
|
projectService: () => ({}),
|
||||||
wakeup: vi.fn(async () => undefined),
|
routineService: () => mockRoutineService,
|
||||||
reportRunActivity: vi.fn(async () => undefined),
|
workProductService: () => ({}),
|
||||||
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: () => ({}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>) {
|
async function createApp(actor: Record<string, unknown>) {
|
||||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
|
@ -91,13 +95,27 @@ async function createApp(actor: Record<string, unknown>) {
|
||||||
describe("issue feedback trace routes", () => {
|
describe("issue feedback trace routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
registerServiceMocks();
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
|
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
|
||||||
attempted: 1,
|
attempted: 1,
|
||||||
sent: 1,
|
sent: 1,
|
||||||
failed: 0,
|
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 () => {
|
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
|
||||||
const mockIssueService = vi.hoisted(() => ({
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
|
|
@ -16,41 +18,39 @@ const mockAgentService = vi.hoisted(() => ({
|
||||||
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerRouteMocks() {
|
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
trackErrorHandlerCrash: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
getTelemetryClient: mockGetTelemetryClient,
|
getTelemetryClient: mockGetTelemetryClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
accessService: () => ({
|
accessService: () => ({
|
||||||
canUser: vi.fn(),
|
canUser: vi.fn(),
|
||||||
hasPermission: vi.fn(),
|
hasPermission: vi.fn(),
|
||||||
}),
|
}),
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({}),
|
feedbackService: () => ({}),
|
||||||
goalService: () => ({}),
|
goalService: () => ({}),
|
||||||
heartbeatService: () => ({
|
heartbeatService: () => ({
|
||||||
wakeup: vi.fn(async () => undefined),
|
wakeup: vi.fn(async () => undefined),
|
||||||
reportRunActivity: vi.fn(async () => undefined),
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
}),
|
}),
|
||||||
instanceSettingsService: () => ({}),
|
instanceSettingsService: () => ({}),
|
||||||
issueApprovalService: () => ({}),
|
issueApprovalService: () => ({}),
|
||||||
issueService: () => mockIssueService,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
routineService: () => ({
|
routineService: () => ({
|
||||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||||
}),
|
}),
|
||||||
workProductService: () => ({}),
|
workProductService: () => ({}),
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
function makeIssue(status: "todo" | "done") {
|
function makeIssue(status: "todo" | "done") {
|
||||||
return {
|
return {
|
||||||
|
|
@ -65,11 +65,7 @@ function makeIssue(status: "todo" | "done") {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>) {
|
function createApp(actor: Record<string, unknown>) {
|
||||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
|
||||||
import("../routes/issues.js"),
|
|
||||||
import("../middleware/index.js"),
|
|
||||||
]);
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
|
|
@ -83,8 +79,6 @@ async function createApp(actor: Record<string, unknown>) {
|
||||||
|
|
||||||
describe("issue telemetry routes", () => {
|
describe("issue telemetry routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
|
||||||
registerRouteMocks();
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||||
|
|
@ -104,7 +98,7 @@ describe("issue telemetry routes", () => {
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
|
|
@ -123,7 +117,7 @@ describe("issue telemetry routes", () => {
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
|
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
|
||||||
const app = await createApp({
|
const app = createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "local-board",
|
userId: "local-board",
|
||||||
companyIds: ["company-1"],
|
companyIds: ["company-1"],
|
||||||
|
|
|
||||||
56
server/src/__tests__/llms-routes.test.ts
Normal file
56
server/src/__tests__/llms-routes.test.ts
Normal file
|
|
@ -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 express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { accessRoutes } from "../routes/access.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
const mockAccessService = vi.hoisted(() => ({
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
hasPermission: vi.fn(),
|
hasPermission: vi.fn(),
|
||||||
|
|
@ -33,16 +35,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||||
|
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function registerServiceMocks() {
|
vi.mock("../services/index.js", () => ({
|
||||||
vi.doMock("../services/index.js", () => ({
|
accessService: () => mockAccessService,
|
||||||
accessService: () => mockAccessService,
|
agentService: () => mockAgentService,
|
||||||
agentService: () => mockAgentService,
|
boardAuthService: () => mockBoardAuthService,
|
||||||
boardAuthService: () => mockBoardAuthService,
|
deduplicateAgentName: vi.fn(),
|
||||||
deduplicateAgentName: vi.fn(),
|
logActivity: mockLogActivity,
|
||||||
logActivity: mockLogActivity,
|
notifyHireApproved: vi.fn(),
|
||||||
notifyHireApproved: vi.fn(),
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDbStub() {
|
function createDbStub() {
|
||||||
const createdInvite = {
|
const createdInvite = {
|
||||||
|
|
@ -99,11 +99,7 @@ function createDbStub() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
|
||||||
import("../routes/access.js"),
|
|
||||||
import("../middleware/index.js"),
|
|
||||||
]);
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
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", () => {
|
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetAllMocks();
|
||||||
registerServiceMocks();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockAccessService.canUser.mockResolvedValue(false);
|
mockAccessService.canUser.mockResolvedValue(false);
|
||||||
mockAgentService.getById.mockReset();
|
mockAgentService.getById.mockReset();
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
|
|
@ -140,7 +134,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
role: "engineer",
|
role: "engineer",
|
||||||
});
|
});
|
||||||
const app = await createApp(
|
const app = createApp(
|
||||||
{
|
{
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
|
|
@ -165,7 +159,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
role: "ceo",
|
role: "ceo",
|
||||||
});
|
});
|
||||||
const app = await createApp(
|
const app = createApp(
|
||||||
{
|
{
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
|
|
@ -193,7 +187,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
|
|
||||||
it("includes companyName in invite summary responses", async () => {
|
it("includes companyName in invite summary responses", async () => {
|
||||||
const db = createDbStub();
|
const db = createDbStub();
|
||||||
const app = await createApp(
|
const app = createApp(
|
||||||
{
|
{
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
|
|
@ -215,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
it("allows board callers with invite permission", async () => {
|
it("allows board callers with invite permission", async () => {
|
||||||
const db = createDbStub();
|
const db = createDbStub();
|
||||||
mockAccessService.canUser.mockResolvedValue(true);
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
const app = await createApp(
|
const app = createApp(
|
||||||
{
|
{
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
|
|
@ -238,12 +232,12 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
allowedJoinTypes: "agent",
|
allowedJoinTypes: "agent",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("rejects board callers without invite permission", async () => {
|
it("rejects board callers without invite permission", async () => {
|
||||||
const db = createDbStub();
|
const db = createDbStub();
|
||||||
mockAccessService.canUser.mockResolvedValue(false);
|
mockAccessService.canUser.mockResolvedValue(false);
|
||||||
const app = await createApp(
|
const app = createApp(
|
||||||
{
|
{
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "user-1",
|
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 express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
|
||||||
|
|
||||||
const unknownHostname = "blocked-host.invalid";
|
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();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
privateHostnameGuard({
|
privateHostnameGuard({
|
||||||
|
|
@ -24,33 +24,37 @@ function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHo
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("privateHostnameGuard", () => {
|
describe("privateHostnameGuard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows requests when disabled", async () => {
|
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");
|
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows loopback hostnames", async () => {
|
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");
|
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows explicitly configured hostnames", async () => {
|
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");
|
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks unknown hostnames with remediation command", async () => {
|
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`);
|
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
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 () => {
|
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`);
|
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
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 mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../telemetry.js", () => ({
|
function registerModuleMocks() {
|
||||||
getTelemetryClient: mockGetTelemetryClient,
|
vi.doMock("../telemetry.js", () => ({
|
||||||
}));
|
getTelemetryClient: mockGetTelemetryClient,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../services/index.js", () => ({
|
vi.doMock("../services/index.js", () => ({
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/workspace-runtime.js", () => ({
|
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function createApp() {
|
async function createApp() {
|
||||||
const { projectRoutes } = await import("../routes/projects.js");
|
const { projectRoutes } = await import("../routes/projects.js");
|
||||||
|
|
@ -97,6 +99,8 @@ function buildProject(overrides: Record<string, unknown> = {}) {
|
||||||
|
|
||||||
describe("project env routes", () => {
|
describe("project env routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
registerModuleMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
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(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
expect(mockProjectService.update).toHaveBeenCalledWith(
|
|
||||||
"project-1",
|
|
||||||
expect.objectContaining({ env: normalizedEnv }),
|
|
||||||
);
|
|
||||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => {
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
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", () => {
|
it("startPeriodicFlush is idempotent", () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
client.startPeriodicFlush(1000);
|
client.startPeriodicFlush(1000);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ type BuildInvocationEnvForLogsOptions = {
|
||||||
resolvedCommandEnvKey?: string;
|
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;
|
serverUtils.runningProcesses;
|
||||||
export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES;
|
export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES;
|
||||||
export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES;
|
export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES;
|
||||||
|
|
|
||||||
|
|
@ -2441,15 +2441,14 @@ export function agentRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
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") {
|
if (run && run.status !== "queued" && run.status !== "running") {
|
||||||
run = null;
|
run = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
|
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
|
||||||
const candidateRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
|
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
|
||||||
const candidateContext = asRecord(candidateRun?.contextSnapshot);
|
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
|
||||||
const candidateIssueId = asNonEmptyString(candidateContext?.issueId);
|
|
||||||
if (candidateRun && candidateIssueId === issue.id) {
|
if (candidateRun && candidateIssueId === issue.id) {
|
||||||
run = candidateRun;
|
run = candidateRun;
|
||||||
}
|
}
|
||||||
|
|
@ -2466,7 +2465,7 @@ export function agentRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
|
...run,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
adapterType: agent.adapterType,
|
adapterType: agent.adapterType,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,26 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||||
import { badRequest } from "../errors.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) {
|
export function costRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const heartbeat = heartbeatService(db);
|
const heartbeat = heartbeatService(db);
|
||||||
|
|
@ -92,30 +112,10 @@ export function costRoutes(db: Db) {
|
||||||
res.status(201).json(event);
|
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) => {
|
router.get("/companies/:companyId/costs/summary", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const summary = await costs.summary(companyId, range);
|
const summary = await costs.summary(companyId, range);
|
||||||
res.json(summary);
|
res.json(summary);
|
||||||
});
|
});
|
||||||
|
|
@ -123,7 +123,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await costs.byAgent(companyId, range);
|
const rows = await costs.byAgent(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -131,7 +131,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await costs.byAgentModel(companyId, range);
|
const rows = await costs.byAgentModel(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -139,7 +139,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await costs.byProvider(companyId, range);
|
const rows = await costs.byProvider(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -147,7 +147,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await costs.byBiller(companyId, range);
|
const rows = await costs.byBiller(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -155,7 +155,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
|
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const summary = await finance.summary(companyId, range);
|
const summary = await finance.summary(companyId, range);
|
||||||
res.json(summary);
|
res.json(summary);
|
||||||
});
|
});
|
||||||
|
|
@ -163,7 +163,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
|
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await finance.byBiller(companyId, range);
|
const rows = await finance.byBiller(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -171,7 +171,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
|
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await finance.byKind(companyId, range);
|
const rows = await finance.byKind(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -179,8 +179,8 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
|
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const limit = parseLimit(req.query);
|
const limit = parseCostLimit(req.query);
|
||||||
const rows = await finance.list(companyId, range, limit);
|
const rows = await finance.list(companyId, range, limit);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
@ -242,7 +242,7 @@ export function costRoutes(db: Db) {
|
||||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const range = parseDateRange(req.query);
|
const range = parseCostDateRange(req.query);
|
||||||
const rows = await costs.byProject(companyId, range);
|
const rows = await costs.byProject(companyId, range);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function llmRoutes(db: Db) {
|
||||||
"Notes:",
|
"Notes:",
|
||||||
"- Sensitive values are redacted in configuration read APIs.",
|
"- Sensitive values are redacted in configuration read APIs.",
|
||||||
"- New hires may be created in pending_approval state depending on company settings.",
|
"- 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"));
|
res.type("text/plain").send(lines.join("\n"));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,34 @@ function readCommentText(value: unknown) {
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
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(
|
export function summarizeHeartbeatRunResultJson(
|
||||||
resultJson: Record<string, unknown> | null | undefined,
|
resultJson: Record<string, unknown> | null | undefined,
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ import { companySkillService } from "./company-skills.js";
|
||||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.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 {
|
import {
|
||||||
buildWorkspaceReadyComment,
|
buildWorkspaceReadyComment,
|
||||||
cleanupExecutionWorkspaceArtifacts,
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
|
|
@ -47,6 +51,7 @@ import {
|
||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||||
import { workspaceOperationService } from "./workspace-operations.js";
|
import { workspaceOperationService } from "./workspace-operations.js";
|
||||||
|
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
|
||||||
import {
|
import {
|
||||||
buildExecutionWorkspaceAdapterConfig,
|
buildExecutionWorkspaceAdapterConfig,
|
||||||
gateProjectExecutionWorkspacePolicy,
|
gateProjectExecutionWorkspacePolicy,
|
||||||
|
|
@ -261,6 +266,9 @@ async function ensureManagedProjectWorkspace(input: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heartbeatRunProcessGroupIdColumn =
|
||||||
|
heartbeatRuns.processGroupId ?? sql<number | null>`NULL`.as("processGroupId");
|
||||||
|
|
||||||
const heartbeatRunListColumns = {
|
const heartbeatRunListColumns = {
|
||||||
id: heartbeatRuns.id,
|
id: heartbeatRuns.id,
|
||||||
companyId: heartbeatRuns.companyId,
|
companyId: heartbeatRuns.companyId,
|
||||||
|
|
@ -288,6 +296,7 @@ const heartbeatRunListColumns = {
|
||||||
errorCode: heartbeatRuns.errorCode,
|
errorCode: heartbeatRuns.errorCode,
|
||||||
externalRunId: heartbeatRuns.externalRunId,
|
externalRunId: heartbeatRuns.externalRunId,
|
||||||
processPid: heartbeatRuns.processPid,
|
processPid: heartbeatRuns.processPid,
|
||||||
|
processGroupId: heartbeatRunProcessGroupIdColumn,
|
||||||
processStartedAt: heartbeatRuns.processStartedAt,
|
processStartedAt: heartbeatRuns.processStartedAt,
|
||||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||||
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
||||||
|
|
@ -296,6 +305,18 @@ const heartbeatRunListColumns = {
|
||||||
updatedAt: heartbeatRuns.updatedAt,
|
updatedAt: heartbeatRuns.updatedAt,
|
||||||
} as const;
|
} 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) {
|
function appendExcerpt(prev: string, chunk: string) {
|
||||||
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
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) {
|
function truncateDisplayId(value: string | null | undefined, max = 128) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return value.length > max ? value.slice(0, max) : value;
|
return value.length > max ? value.slice(0, max) : value;
|
||||||
|
|
@ -1824,13 +1885,14 @@ export function heartbeatService(db: Db) {
|
||||||
|
|
||||||
async function persistRunProcessMetadata(
|
async function persistRunProcessMetadata(
|
||||||
runId: string,
|
runId: string,
|
||||||
meta: { pid: number; startedAt: string },
|
meta: { pid: number; processGroupId: number | null; startedAt: string },
|
||||||
) {
|
) {
|
||||||
const startedAt = new Date(meta.startedAt);
|
const startedAt = new Date(meta.startedAt);
|
||||||
return db
|
return db
|
||||||
.update(heartbeatRuns)
|
.update(heartbeatRuns)
|
||||||
.set({
|
.set({
|
||||||
processPid: meta.pid,
|
processPid: meta.pid,
|
||||||
|
processGroupId: meta.processGroupId,
|
||||||
processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt,
|
processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|
@ -2356,7 +2418,9 @@ export function heartbeatService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
|
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) {
|
if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) {
|
||||||
const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`;
|
const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`;
|
||||||
const detachedRun = await setRunStatus(run.id, "running", {
|
const detachedRun = await setRunStatus(run.id, "running", {
|
||||||
|
|
@ -2378,10 +2442,17 @@ export function heartbeatService(db: Db) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1;
|
let descendantOnlyCleanup = false;
|
||||||
const baseMessage = run.processPid
|
if (processGroupAlive) {
|
||||||
? `Process lost -- child pid ${run.processPid} is no longer running`
|
descendantOnlyCleanup = true;
|
||||||
: "Process lost -- server may have restarted";
|
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", {
|
let finalizedRun = await setRunStatus(run.id, "failed", {
|
||||||
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
||||||
|
|
@ -2414,6 +2485,8 @@ export function heartbeatService(db: Db) {
|
||||||
: baseMessage,
|
: baseMessage,
|
||||||
payload: {
|
payload: {
|
||||||
...(run.processPid ? { processPid: run.processPid } : {}),
|
...(run.processPid ? { processPid: run.processPid } : {}),
|
||||||
|
...(run.processGroupId ? { processGroupId: run.processGroupId } : {}),
|
||||||
|
...(descendantOnlyCleanup ? { descendantOnlyCleanup: true } : {}),
|
||||||
...(retriedRun ? { retryRunId: retriedRun.id } : {}),
|
...(retriedRun ? { retryRunId: retriedRun.id } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -3179,7 +3252,14 @@ export function heartbeatService(db: Db) {
|
||||||
onLog,
|
onLog,
|
||||||
onMeta: onAdapterMeta,
|
onMeta: onAdapterMeta,
|
||||||
onSpawn: async (meta) => {
|
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,
|
authToken: authToken ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -3299,6 +3379,11 @@ export function heartbeatService(db: Db) {
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const persistedResultJson = mergeHeartbeatRunResultJson(
|
||||||
|
adapterResult.resultJson ?? null,
|
||||||
|
adapterResult.summary ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
await setRunStatus(run.id, status, {
|
await setRunStatus(run.id, status, {
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
error:
|
error:
|
||||||
|
|
@ -3319,7 +3404,7 @@ export function heartbeatService(db: Db) {
|
||||||
exitCode: adapterResult.exitCode,
|
exitCode: adapterResult.exitCode,
|
||||||
signal: adapterResult.signal,
|
signal: adapterResult.signal,
|
||||||
usageJson,
|
usageJson,
|
||||||
resultJson: adapterResult.resultJson ?? null,
|
resultJson: persistedResultJson,
|
||||||
sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId,
|
sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId,
|
||||||
stdoutExcerpt,
|
stdoutExcerpt,
|
||||||
stderrExcerpt,
|
stderrExcerpt,
|
||||||
|
|
@ -3347,7 +3432,7 @@ export function heartbeatService(db: Db) {
|
||||||
});
|
});
|
||||||
if (issueId && outcome === "succeeded") {
|
if (issueId && outcome === "succeeded") {
|
||||||
try {
|
try {
|
||||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||||
if (issueComment) {
|
if (issueComment) {
|
||||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
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);
|
const running = runningProcesses.get(run.id);
|
||||||
if (running) {
|
if (running) {
|
||||||
running.child.kill("SIGTERM");
|
await terminateHeartbeatRunProcess({
|
||||||
const graceMs = Math.max(1, running.graceSec) * 1000;
|
pid: running.child.pid ?? run.processPid,
|
||||||
setTimeout(() => {
|
processGroupId: running.processGroupId ?? run.processGroupId,
|
||||||
if (!running.child.killed) {
|
graceMs: Math.max(1, running.graceSec) * 1000,
|
||||||
running.child.kill("SIGKILL");
|
});
|
||||||
}
|
} else if (run.processPid || run.processGroupId) {
|
||||||
}, graceMs);
|
await terminateHeartbeatRunProcess({
|
||||||
|
pid: run.processPid,
|
||||||
|
processGroupId: run.processGroupId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelled = await setRunStatus(run.id, "cancelled", {
|
const cancelled = await setRunStatus(run.id, "cancelled", {
|
||||||
|
|
@ -4298,8 +4386,17 @@ export function heartbeatService(db: Db) {
|
||||||
|
|
||||||
const running = runningProcesses.get(run.id);
|
const running = runningProcesses.get(run.id);
|
||||||
if (running) {
|
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);
|
runningProcesses.delete(run.id);
|
||||||
|
} else if (run.processPid || run.processGroupId) {
|
||||||
|
await terminateHeartbeatRunProcess({
|
||||||
|
pid: run.processPid,
|
||||||
|
processGroupId: run.processGroupId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await releaseIssueExecutionAndPromote(run);
|
await releaseIssueExecutionAndPromote(run);
|
||||||
}
|
}
|
||||||
|
|
@ -4515,6 +4612,15 @@ export function heartbeatService(db: Db) {
|
||||||
|
|
||||||
cancelBudgetScopeWork,
|
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) => {
|
getActiveRunForAgent: async (agentId: string) => {
|
||||||
const [run] = await db
|
const [run] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -4529,5 +4635,20 @@ export function heartbeatService(db: Db) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return run ?? null;
|
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) {
|
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||||
if (process.platform === "win32") return true;
|
if (process.platform === "win32") return true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -296,13 +307,19 @@ export async function terminateLocalService(
|
||||||
|
|
||||||
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
|
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (!isPidAlive(record.pid)) {
|
const targetAlive = targetProcessGroup
|
||||||
|
? isProcessGroupAlive(record.processGroupId)
|
||||||
|
: isPidAlive(record.pid);
|
||||||
|
if (!targetAlive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await delay(100);
|
await delay(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPidAlive(record.pid)) return;
|
const stillAlive = targetProcessGroup
|
||||||
|
? isProcessGroupAlive(record.processGroupId)
|
||||||
|
: isPidAlive(record.pid);
|
||||||
|
if (!stillAlive) return;
|
||||||
try {
|
try {
|
||||||
if (targetProcessGroup) {
|
if (targetProcessGroup) {
|
||||||
process.kill(-record.processGroupId!, "SIGKILL");
|
process.kill(-record.processGroupId!, "SIGKILL");
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import type {
|
import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared";
|
||||||
HeartbeatRun,
|
|
||||||
HeartbeatRunEvent,
|
|
||||||
InstanceSchedulerHeartbeatAgent,
|
|
||||||
WorkspaceOperation,
|
|
||||||
} from "@paperclipai/shared";
|
|
||||||
import { api } from "./client";
|
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;
|
agentId: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
|
issueId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRunForIssue {
|
export interface LiveRunForIssue {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
externalRunId: null,
|
externalRunId: null,
|
||||||
processPid: null,
|
processPid: null,
|
||||||
|
processGroupId: null,
|
||||||
processStartedAt: null,
|
processStartedAt: null,
|
||||||
retryOfRunId: null,
|
retryOfRunId: null,
|
||||||
processLossRetryCount: 0,
|
processLossRetryCount: 0,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssu
|
||||||
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
|
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
|
||||||
return {
|
return {
|
||||||
id: "run-1",
|
id: "run-1",
|
||||||
companyId: "company-1",
|
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
agentName: "CodexCoder",
|
agentName: "CodexCoder",
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
|
|
@ -31,30 +30,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt: new Date("2026-04-08T21:00:00.000Z"),
|
startedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||||
finishedAt: null,
|
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"),
|
createdAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||||
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ describe("FailedRunInboxRow", () => {
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
externalRunId: null,
|
externalRunId: null,
|
||||||
processPid: null,
|
processPid: null,
|
||||||
|
processGroupId: null,
|
||||||
processStartedAt: null,
|
processStartedAt: null,
|
||||||
retryOfRunId: null,
|
retryOfRunId: null,
|
||||||
processLossRetryCount: 0,
|
processLossRetryCount: 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue