Compare commits

...

32 Commits

Author SHA1 Message Date
Dotta
27b7d05f32 Merge public-gh/master into pap-1469-exec-reliability 2026-04-14 13:12:58 -05:00
Dotta
2f5eb7e74b test(server): refresh issue wake and workspace config mocks 2026-04-14 12:19:20 -05:00
Dotta
7e0fa2b6f3 fix(ui): restore issue detail sidebar import 2026-04-14 12:13:15 -05:00
Dotta
05e1cd22a8 fix(server): restore heartbeat activity logging import 2026-04-14 12:10:15 -05:00
Dotta
21784574db fix(server): restore implicit comment reopen helpers 2026-04-14 12:01:23 -05:00
Dotta
c3fe756dbb test(server): isolate route module state between cases 2026-04-14 11:54:52 -05:00
Dotta
d0af94b4d6 fix(server): preserve issue labels and wake resumed assignees 2026-04-14 11:54:44 -05:00
Dotta
0c3120d372 fix(heartbeat): compact run logs and trim polling noise 2026-04-14 11:54:44 -05:00
Dotta
42544053c4 Avoid rerendering toast action consumers 2026-04-14 11:54:44 -05:00
Dotta
8020f551ca fix: defer mentioned-agent wakes behind issue execution 2026-04-14 11:54:11 -05:00
Dotta
5a698b0446 feat(telemetry): emit adapter_type and model on agent.task_completed
The telemetry backend already retains these dimensions (d36ddc1) but
the app was not sending them. Now trackAgentTaskCompleted forwards
adapterType from the agent record and model from adapterConfig when
available, so completions-per-model and completions-per-adapter
charts will populate as data flows in.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:53:57 -05:00
Dotta
1ab9cd8dfe fix: avoid vite html transform retention 2026-04-14 11:53:57 -05:00
Dotta
c32b56398e Harden issue assignee resolution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:53:57 -05:00
Dotta
d6308bd3ef Clarify Paperclip status guidance
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:52:57 -05:00
Dotta
07c2ea3421 Trim issue run polling payloads
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:52:57 -05:00
Dotta
06223c78b7 Add v2026.413.0 release changelog
Covers all changes since v2026.403.0 including issue chat thread,
external adapter plugins, execution policies, blocker dependencies,
standalone MCP server, and 21 community contributors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:52:57 -05:00
Dotta
fac2f226c5 Recover stranded assigned issue execution 2026-04-14 11:52:57 -05:00
Dotta
d330076bb2 fix: bypass repeated vite html transforms in dev 2026-04-14 11:52:57 -05:00
Dotta
2e7db9e202 Fix issue active run scoping 2026-04-14 11:52:57 -05:00
Dotta
e16beed78e fix: skip auth session lookup on non-api requests 2026-04-14 11:52:57 -05:00
Dotta
83fac16c38 Make pnpm test the cheap default 2026-04-14 11:52:57 -05:00
Dotta
26bab3b168 Trim module reloads in targeted server tests 2026-04-14 11:52:57 -05:00
Dotta
367065a3d7 Auto-enable mentioned skills for heartbeat runs 2026-04-14 11:52:57 -05:00
Dotta
ee45102587 Isolate e2e tests from local Paperclip state 2026-04-14 11:52:57 -05:00
Dotta
41a3cd9d75 Reduce Vitest overhead in server route tests 2026-04-14 11:52:57 -05:00
Dotta
9571d7b153 Speed up repeated server route test imports 2026-04-14 11:52:18 -05:00
Dotta
7e0fff8e3c Fix repo-wide verification regressions 2026-04-14 11:52:18 -05:00
Dotta
7d22097573 fix(server): reduce noisy request logging 2026-04-14 11:52:18 -05:00
Dotta
45af343687 Skip self-review-only execution stages 2026-04-14 11:52:18 -05:00
Dotta
02ecc60552 docs: add VS Code task interoperability plan 2026-04-14 11:52:18 -05:00
Dotta
8ad1e7b927 docs: move PAP-1346 plan into issue 2026-04-14 11:52:18 -05:00
Dotta
1a5f0d39ef docs: plan faster issue navigation 2026-04-14 11:52:18 -05:00
106 changed files with 4682 additions and 713 deletions

View File

@@ -108,6 +108,21 @@ Notes:
## 7. Verification Before Hand-off
Default local/agent test path:
```sh
pnpm test
```
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
```sh
pnpm test:e2e
pnpm test:release-smoke
```
Run the browser suites only when your change touches them or when you are explicitly verifying CI/release flows.
Run this full check before claiming done:
```sh

View File

@@ -233,11 +233,15 @@ pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test:run # Run tests
pnpm test # Cheap default test run (Vitest only)
pnpm test:watch # Vitest watch mode
pnpm test:e2e # Playwright browser suite
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
<br/>

View File

@@ -233,11 +233,15 @@ pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test:run # Run tests
pnpm test # Cheap default test run (Vitest only)
pnpm test:watch # Vitest watch mode
pnpm test:e2e # Playwright browser suite
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
<br/>

View File

@@ -23,6 +23,7 @@ import {
resolveWorktreeReseedTargetPaths,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeRepairCommand,
worktreeInitCommand,
worktreeMakeCommand,
worktreeReseedCommand,
@@ -844,6 +845,113 @@ describe("worktree helpers", () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it("no-ops on the primary checkout unless --branch is provided", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.chdir(repoRoot);
await worktreeRepairCommand({});
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("repairs the current linked worktree when Paperclip metadata is missing", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me");
const sourceConfigPath = path.join(tempRoot, "source-config.json");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const worktreePaths = resolveWorktreeLocalPaths({
cwd: worktreePath,
homeDir: worktreeHome,
instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)),
});
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], {
cwd: repoRoot,
stdio: "ignore",
});
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true });
fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8");
process.chdir(worktreePath);
await worktreeRepairCommand({
fromConfig: sourceConfigPath,
home: worktreeHome,
noSeed: true,
});
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it("creates and repairs a missing branch worktree when --branch is provided", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceConfigPath = path.join(tempRoot, "source-config.json");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const originalCwd = process.cwd();
const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
process.chdir(repoRoot);
await worktreeRepairCommand({
branch: "feature/repair-me",
fromConfig: sourceConfigPath,
home: worktreeHome,
noSeed: true,
});
expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
});
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {

View File

@@ -130,6 +130,17 @@ type WorktreeReseedOptions = {
allowLiveTarget?: boolean;
};
type WorktreeRepairOptions = {
branch?: string;
home?: string;
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
seedMode?: string;
noSeed?: boolean;
allowLiveTarget?: boolean;
};
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
@@ -550,6 +561,46 @@ function detectGitBranchName(cwd: string): string | null {
}
}
function validateGitBranchName(cwd: string, branchName: string): string {
const value = nonEmpty(branchName);
if (!value) {
throw new Error("Branch name is required.");
}
try {
execFileSync("git", ["check-ref-format", "--branch", value], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`);
}
return value;
}
function isPrimaryGitWorktree(cwd: string): boolean {
const workspace = detectGitWorkspaceInfo(cwd);
return Boolean(workspace && workspace.gitDir === workspace.commonDir);
}
function resolvePrimaryGitRepoRoot(cwd: string): string {
const workspace = detectGitWorkspaceInfo(cwd);
if (!workspace) {
throw new Error("Current directory is not inside a git repository.");
}
if (workspace.gitDir === workspace.commonDir) {
return workspace.root;
}
return path.resolve(workspace.commonDir, "..");
}
function resolveRepairWorktreeDirName(branchName: string): string {
const normalized = branchName.trim()
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-._]+|[-._]+$/g, "");
return normalized || "worktree";
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
@@ -773,6 +824,21 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol
);
}
function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource {
const fromConfig = nonEmpty(input.fromConfig);
const fromDataDir = nonEmpty(input.fromDataDir);
const fromInstance = nonEmpty(input.fromInstance) ?? "default";
const configPath = resolveSourceConfigPath({
fromConfig: fromConfig ?? undefined,
fromDataDir: fromDataDir ?? undefined,
fromInstance,
});
return {
configPath,
label: configPath,
};
}
export function resolveWorktreeReseedTargetPaths(input: {
configPath: string;
rootPath: string;
@@ -794,6 +860,105 @@ export function resolveWorktreeReseedTargetPaths(input: {
});
}
function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null {
const trimmed = selector.trim();
if (trimmed.length === 0) return null;
const directPath = path.resolve(trimmed);
if (existsSync(directPath)) {
return {
worktree: directPath,
branch: null,
branchLabel: path.basename(directPath),
hasPaperclipConfig: existsSync(path.resolve(directPath, ".paperclip", "config.json")),
isCurrent: directPath === path.resolve(cwd),
};
}
return toMergeSourceChoices(cwd).find((choice) =>
choice.worktree === directPath
|| path.basename(choice.worktree) === trimmed
|| choice.branchLabel === trimmed
|| choice.branch === trimmed,
) ?? null;
}
async function ensureRepairTargetWorktree(input: {
selector?: string;
seedMode: WorktreeSeedMode;
opts: WorktreeRepairOptions;
}): Promise<ResolvedWorktreeRepairTarget | null> {
const cwd = process.cwd();
const currentRoot = path.resolve(cwd);
const currentConfigPath = path.resolve(currentRoot, ".paperclip", "config.json");
if (!input.selector) {
if (isPrimaryGitWorktree(cwd)) {
return null;
}
return {
rootPath: currentRoot,
configPath: currentConfigPath,
label: path.basename(currentRoot),
branchName: detectGitBranchName(cwd),
created: false,
};
}
const existing = resolveExistingGitWorktree(input.selector, cwd);
if (existing) {
return {
rootPath: existing.worktree,
configPath: path.resolve(existing.worktree, ".paperclip", "config.json"),
label: existing.branchLabel,
branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel,
created: false,
};
}
const repoRoot = resolvePrimaryGitRepoRoot(cwd);
const branchName = validateGitBranchName(repoRoot, input.selector);
const targetPath = path.resolve(
repoRoot,
".paperclip",
"worktrees",
resolveRepairWorktreeDirName(branchName),
);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`);
}
mkdirSync(path.dirname(targetPath), { recursive: true });
const spinner = p.spinner();
spinner.start(`Creating git worktree for ${branchName}...`);
try {
execFileSync("git", resolveGitWorktreeAddArgs({
branchName,
targetPath,
branchExists: localBranchExists(repoRoot, branchName),
}), {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Created git worktree at ${targetPath}.`);
} catch (error) {
spinner.stop(pc.red("Failed to create git worktree."));
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
installDependenciesBestEffort(targetPath);
return {
rootPath: targetPath,
configPath: path.resolve(targetPath, ".paperclip", "config.json"),
label: branchName,
branchName,
created: true,
};
}
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
if (config.database.mode === "postgres") {
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
@@ -1205,18 +1370,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
installDependenciesBestEffort(targetPath);
const originalCwd = process.cwd();
try {
@@ -1233,6 +1387,21 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
}
}
function installDependenciesBestEffort(targetPath: string): void {
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
}
type WorktreeCleanupOptions = {
instance?: string;
home?: string;
@@ -1266,6 +1435,14 @@ type ResolvedWorktreeReseedSource = {
label: string;
};
type ResolvedWorktreeRepairTarget = {
rootPath: string;
configPath: string;
label: string;
branchName: string | null;
created: boolean;
};
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd,
@@ -2707,10 +2884,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
}
}
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
const seedMode = opts.seedMode ?? "full";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
@@ -2790,6 +2964,96 @@ export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promis
}
}
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
await runWorktreeReseed(opts);
}
export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree repair ")));
const seedMode = opts.seedMode ?? "minimal";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const target = await ensureRepairTargetWorktree({
selector: nonEmpty(opts.branch) ?? undefined,
seedMode,
opts,
});
if (!target) {
p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.");
p.outro(pc.yellow("No worktree repaired."));
return;
}
const source = resolveWorktreeRepairSource(opts);
if (!existsSync(source.configPath)) {
throw new Error(`Source config not found at ${source.configPath}.`);
}
if (path.resolve(source.configPath) === path.resolve(target.configPath)) {
throw new Error("Source and target Paperclip configs are the same. Use --from-config/--from-instance to point repair at a different source.");
}
const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null;
const targetEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(target.configPath));
const targetHasWorktreeEnv = Boolean(
nonEmpty(targetEnvEntries.PAPERCLIP_HOME) && nonEmpty(targetEnvEntries.PAPERCLIP_INSTANCE_ID),
);
if (targetConfig && targetHasWorktreeEnv && opts.noSeed) {
p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`));
p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`));
return;
}
if (targetConfig && targetHasWorktreeEnv) {
await runWorktreeReseed({
fromConfig: source.configPath,
to: target.rootPath,
seedMode,
yes: true,
allowLiveTarget: opts.allowLiveTarget,
});
return;
}
const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath));
const repairPaths = resolveWorktreeLocalPaths({
cwd: target.rootPath,
homeDir: resolveWorktreeHome(opts.home),
instanceId: repairInstanceId,
});
const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"));
if (runningTargetPid && !opts.allowLiveTarget) {
throw new Error(
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`,
);
}
if (runningTargetPid && opts.allowLiveTarget) {
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
}
const originalCwd = process.cwd();
try {
process.chdir(target.rootPath);
await runWorktreeInit({
home: opts.home,
fromConfig: source.configPath,
fromDataDir: opts.fromDataDir,
fromInstance: opts.fromInstance,
seed: opts.noSeed ? false : true,
seedMode,
force: true,
});
} finally {
process.chdir(originalCwd);
}
}
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
@@ -2865,6 +3129,19 @@ export function registerWorktreeCommands(program: Command): void {
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeReseedCommand);
worktree
.command("repair")
.description("Create or repair a linked worktree-local Paperclip instance without touching the primary checkout")
.option("--branch <name>", "Existing branch/worktree selector to repair, or a branch name to create under .paperclip/worktrees")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeRepairCommand);
program
.command("worktree:cleanup")
.description("Safely remove a worktree, its branch, and its isolated instance data")

View File

@@ -79,6 +79,29 @@ Allow additional private hostnames (for example custom Tailscale hostnames):
pnpm paperclipai allowed-hostname dotta-macbook-pro
```
## Test Commands
Use the cheap local default unless you are specifically working on browser flows:
```sh
pnpm test
```
`pnpm test` runs the Vitest suite only. For interactive Vitest watch mode use:
```sh
pnpm test:watch
```
Browser suites stay separate:
```sh
pnpm test:e2e
pnpm test:release-smoke
```
These browser suites are intended for targeted local verification and CI, not the default agent/human test command.
## One-Command Local Run
For a first-time local install, you can bootstrap and run in one command:

View File

@@ -395,6 +395,8 @@ Side effects:
- entering `done` sets `completed_at`
- entering `cancelled` sets `cancelled_at`
Detailed ownership, execution, blocker, and crash-recovery semantics are documented in `doc/execution-semantics.md`.
## 8.3 Approval Status
- `pending -> approved | rejected | cancelled`

252
doc/execution-semantics.md Normal file
View File

@@ -0,0 +1,252 @@
# Execution Semantics
Status: Current implementation guide
Date: 2026-04-13
Audience: Product and engineering
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
`doc/SPEC-implementation.md` remains the V1 contract. This document is the detailed execution model behind that contract.
## 1. Core Model
Paperclip separates four concepts that are easy to blur together:
1. structure: parent/sub-issue relationships
2. dependency: blocker relationships
3. ownership: who is responsible for the issue now
4. execution: whether the control plane currently has a live path to move the issue forward
The system works best when those are kept separate.
## 2. Assignee Semantics
An issue has at most one assignee.
- `assigneeAgentId` means the issue is owned by an agent
- `assigneeUserId` means the issue is owned by a human board user
- both cannot be set at the same time
This is a hard invariant. Paperclip is single-assignee by design.
## 3. Status Semantics
Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution.
### `backlog`
The issue is not ready for active work.
- no execution expectation
- no pickup expectation
- safe resting state for future work
### `todo`
The issue is actionable but not actively claimed.
- it may be assigned or unassigned
- no checkout/execution lock is required yet
- for agent-assigned work, Paperclip may still need a wake path to ensure the assignee actually sees it
### `in_progress`
The issue is actively owned work.
- requires an assignee
- for agent-owned issues, this is a strict execution-backed state
- for user-owned issues, this is a human ownership state and is not backed by heartbeat execution
For agent-owned issues, `in_progress` should not be allowed to become a silent dead state.
### `blocked`
The issue cannot proceed until something external changes.
This is the right state for:
- waiting on another issue
- waiting on a human decision
- waiting on an external dependency or system
- work that automatic recovery could not safely continue
### `in_review`
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
### `done`
The work is complete and terminal.
### `cancelled`
The work will not continue and is terminal.
## 4. Agent-Owned vs User-Owned Execution
The execution model differs depending on assignee type.
### Agent-owned issues
Agent-owned issues are part of the control plane's execution loop.
- Paperclip can wake the assignee
- Paperclip can track runs linked to the issue
- Paperclip can recover some lost execution state after crashes/restarts
### User-owned issues
User-owned issues are not executed by the heartbeat scheduler.
- Paperclip can track the ownership and status
- Paperclip cannot rely on heartbeat/run semantics to keep them moving
- stranded-work reconciliation does not apply to them
This is why `in_progress` can be strict for agents without forcing the same runtime rules onto human-held work.
## 5. Checkout and Active Execution
Checkout is the bridge from issue ownership to active agent execution.
- checkout is required to move an issue into agent-owned `in_progress`
- `checkoutRunId` represents issue-ownership lock for the current agent run
- `executionRunId` represents the currently active execution path for the issue
These are related but not identical:
- `checkoutRunId` answers who currently owns execution rights for the issue
- `executionRunId` answers which run is actually live right now
Paperclip already clears stale execution locks and can adopt some stale checkout locks when the original run is gone.
## 6. Parent/Sub-Issue vs Blockers
Paperclip uses two different relationships for different jobs.
### Parent/Sub-Issue (`parentId`)
This is structural.
Use it for:
- work breakdown
- rollup context
- explaining why a child issue exists
- waking the parent assignee when all direct children become terminal
Do not treat `parentId` as execution dependency by itself.
### Blockers (`blockedByIssueIds`)
This is dependency semantics.
Use it for:
- \"this issue cannot continue until that issue changes state\"
- explicit waiting relationships
- automatic wakeups when all blockers resolve
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
## 7. Consistent Execution Path Rules
For agent-assigned, non-terminal, actionable issues, Paperclip should not leave work in a state where nobody is working it and nothing will wake it.
The relevant execution path depends on status.
### Agent-assigned `todo`
This is dispatch state: ready to start, not yet actively claimed.
A healthy dispatch state means at least one of these is true:
- the issue already has a queued/running wake path
- the issue is intentionally resting in `todo` after a successful agent heartbeat, not after an interrupted dispatch
- the issue has been explicitly surfaced as stranded
### Agent-assigned `in_progress`
This is active-work state.
A healthy active-work state means at least one of these is true:
- there is an active run for the issue
- there is already a queued continuation wake
- the issue has been explicitly surfaced as stranded
## 8. Crash and Restart Recovery
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
There are two distinct failure modes.
### 8.1 Stranded assigned `todo`
Example:
- issue is assigned to an agent
- status is `todo`
- the original wake/run died during or after dispatch
- after restart there is no queued wake and nothing picks the issue back up
Recovery rule:
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
This is a dispatch recovery, not a continuation recovery.
### 8.2 Stranded assigned `in_progress`
Example:
- issue is assigned to an agent
- status is `in_progress`
- the live run disappeared
- after restart there is no active run and no queued continuation
Recovery rule:
- Paperclip queues one automatic continuation wake
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
This is an active-work continuity recovery.
## 9. Startup and Periodic Reconciliation
Startup recovery and periodic recovery are different from normal wakeup delivery.
On startup and on the periodic recovery loop, Paperclip now does three things in sequence:
1. reap orphaned `running` runs
2. resume persisted `queued` runs
3. reconcile stranded assigned work
That last step is what closes the gap where issue state survives a crash but the wake/run path does not.
## 10. What This Does Not Mean
These semantics do not change V1 into an auto-reassignment system.
Paperclip still does not:
- automatically reassign work to a different agent
- infer dependency semantics from `parentId` alone
- treat human-held work as heartbeat-managed execution
The recovery model is intentionally conservative:
- preserve ownership
- retry once when the control plane lost execution continuity
- escalate visibly when the system cannot safely keep going
## 11. Practical Interpretation
For a board operator, the intended meaning is:
- agent-owned `in_progress` should mean \"this is live work or clearly surfaced as a problem\"
- agent-owned `todo` should not stay assigned forever after a crash with no remaining wake path
- parent/sub-issue explains structure
- blockers explain waiting
That is the execution contract Paperclip should present to operators.

View File

@@ -0,0 +1,382 @@
# VS Code Task Interoperability Plan
Status: planning only, no code changes
Date: 2026-04-12
Related issue: `PAP-1377`
## Summary
Paperclip should not replace its workspace runtime service model with VS Code tasks.
It should add a narrow interoperability layer that can discover and adopt supported entries from `.vscode/tasks.json`.
The core product model should stay:
- Paperclip owns long-running workspace services and their desired state
- Paperclip shows operators exactly which named thing they are starting or stopping
- Paperclip distinguishes long-running services from one-shot jobs
VS Code tasks should be treated as:
- an import/discovery format for workspace commands
- a convenience for repos that already maintain `tasks.json`
- a partial compatibility layer, not a full execution model
## Current State
The current implementation is already service-oriented:
- project workspaces and execution workspaces can store `workspaceRuntime` config plus `desiredState` and per-service `serviceStates`
- the UI renders one control row per configured service and persists start/stop intent
- the backend supervises long-running local processes, reuses eligible services, and restores desired services on startup
Relevant files:
- `packages/shared/src/types/workspace-runtime.ts`
- `server/src/services/workspace-runtime.ts`
- `server/src/services/project-workspace-runtime-config.ts`
- `ui/src/components/WorkspaceRuntimeControls.tsx`
- `ui/src/pages/ProjectWorkspaceDetail.tsx`
- `ui/src/pages/ExecutionWorkspaceDetail.tsx`
This is directionally correct for Paperclip because it gives the control plane an explicit model for service lifecycle, health, reuse, and restart behavior.
## Problem To Solve
The current UX is still too raw:
- operators have to hand-author runtime JSON
- a workspace can have multiple attached services, but the higher-level intent is not obvious
- start/stop controls are visible in multiple places, which makes it easy to lose track of what is being controlled
- there is no interoperability with repos that already define useful local workflows in `.vscode/tasks.json`
The issue is not that services are the wrong abstraction.
The issue is that the configuration surface is too low-level and Paperclip does not yet leverage existing workspace metadata.
## Recommendation
Keep Paperclip runtime services as the source of truth for service supervision.
Add a new workspace command model above the raw JSON layer, with VS Code task discovery as one input.
The product model should become:
1. `Workspace command`
A named runnable thing attached to a workspace.
2. `Workspace service`
A workspace command that is expected to stay alive and be supervised.
3. `Workspace job`
A workspace command that runs once and exits.
4. `Runtime service instance`
The live process record that already exists today in Paperclip.
In that model, VS Code tasks are a way to populate workspace commands.
Only commands that map cleanly to Paperclip service or job semantics should become runnable in Paperclip.
## Why Not Fully Adopt VS Code Tasks
VS Code tasks are broader than Paperclip runtime services.
They include shell/process tasks, compound tasks, background/watch tasks, presentation settings, extension/task-provider types, variable substitution, and problem-matcher-driven lifecycle.
That creates a bad fit if Paperclip tries to use `tasks.json` as its only runtime model:
- many tasks are one-shot jobs, not long-running services
- some tasks depend on VS Code task providers or editor-only variable resolution
- compound task graphs are useful, but they are not the same thing as a supervised service
- problem matcher readiness is useful metadata, but it is not enough to replace Paperclip's persisted service lifecycle model
The right boundary is interoperability, not replacement.
## Interoperability Contract
Paperclip should support a conservative subset of VS Code tasks and clearly mark unsupported entries.
### Supported in phase 1
- `shell` and `process` tasks with a concrete command Paperclip can resolve
- optional task `options.cwd`
- optional task environment values that can be flattened safely
- task labels and detail text for naming and display
- `dependsOn` for import-time expansion or display-only dependency hints
- background/watch-oriented tasks that can reasonably be treated as long-running services
### Maybe supported in later phases
- grouping and default task metadata for better UX
- selected variable substitution when Paperclip can resolve it safely from workspace context
- mapping task metadata into Paperclip readiness/expose hints
- limited compound-task launch flows
### Not supported initially
- extension-provided task types Paperclip cannot execute directly
- arbitrary VS Code variable substitution semantics
- problem matcher parsing as the main source of service health
- full parity with VS Code task execution behavior
## Long-Running Service Detection
Paperclip needs an explicit classification layer instead of assuming every VS Code task is a service.
Recommended classification:
- `service`
Explicitly marked by Paperclip metadata, or confidently inferred from background/watch task semantics
- `job`
One-shot command expected to exit
- `unsupported`
Present in `tasks.json`, but not safely runnable by Paperclip
The important product decision is that service classification must be visible and editable by the operator.
Inference can help, but it should not be the only source of truth.
## Proposed Product Shape
### 1. Replace raw-first editing with command-first editing
Project and execution workspace pages should stop making raw runtime JSON the primary editing surface.
Default UI should show:
- workspace commands
- command type: service or job
- source: Paperclip or VS Code
- exact command and cwd
- current state for services
- explicit start, stop, restart, and run-now actions
Raw JSON should remain available behind an advanced section.
### 2. Add VS Code task discovery on workspaces
For a workspace with `cwd`, Paperclip should look for `.vscode/tasks.json`.
The workspace UI should show:
- whether a `tasks.json` file was found
- last parse time
- supported commands discovered
- unsupported tasks with reasons
- whether commands are inherited into execution workspaces
### 3. Make the controlled thing explicit
Start and stop UI should always name the exact entry being controlled.
Examples:
- `Start web`
- `Stop api`
- `Run db:migrate`
Avoid generic workspace-level labels when multiple commands exist.
### 4. Separate services from jobs in the UI
Do not mix one-shot jobs and long-running services into one undifferentiated list.
Recommended sections:
- `Services`
- `Jobs`
- `Unsupported imported tasks`
That resolves the ambiguity called out in the issue.
## Data Model Direction
Do not replace `workspaceRuntime` immediately.
Instead add a higher-level representation that can compile down to the existing runtime-service machinery.
Suggested workspace metadata shape:
```ts
type WorkspaceCommandSource =
| { type: "paperclip" }
| { type: "vscode_task"; taskLabel: string; taskPath: ".vscode/tasks.json" };
type WorkspaceCommandKind = "service" | "job";
type WorkspaceCommandDefinition = {
id: string;
name: string;
kind: WorkspaceCommandKind;
source: WorkspaceCommandSource;
command: string | null;
cwd: string | null;
env?: Record<string, string> | null;
autoStart?: boolean;
serviceConfig?: {
lifecycle?: "shared" | "ephemeral";
reuseScope?: "project_workspace" | "execution_workspace" | "run";
readiness?: Record<string, unknown> | null;
expose?: Record<string, unknown> | null;
} | null;
importWarnings?: string[];
disabledReason?: string | null;
};
```
`workspaceRuntime` can then become a derived or advanced representation for service-type commands until the rest of the system is migrated.
## VS Code Mapping Rules
Paperclip should map imported tasks with explicit, documented rules.
Recommended rules:
1. A task becomes a `job` by default.
2. A task becomes a `service` only when:
- Paperclip metadata marks it as a service, or
- the task clearly represents a background/watch process and the operator confirms the classification.
3. Unsupported tasks stay visible but disabled.
4. Task labels become default command names.
5. `dependsOn` is preserved as metadata, not silently flattened into hidden behavior.
Paperclip-specific metadata can live in a namespaced field on the imported task definition, for example:
```json
{
"label": "web",
"type": "shell",
"command": "pnpm dev",
"isBackground": true,
"paperclip": {
"kind": "service",
"readiness": {
"type": "http",
"urlTemplate": "http://127.0.0.1:${port}"
},
"expose": {
"type": "url",
"urlTemplate": "http://127.0.0.1:${port}"
}
}
}
```
That gives us interoperability without depending on VS Code-only semantics for service readiness and exposure.
## Execution Policy
Project workspaces should be the main place where imported commands are discovered and curated.
Execution workspaces should inherit that curated command set by default, with optional issue-level overrides.
Recommended precedence:
1. execution workspace override
2. project workspace command set
3. imported VS Code tasks from the linked workspace
4. advanced raw runtime fallback
This matches the existing direction in `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`.
## Implementation Plan
### Phase 1: Discovery and read-only visibility
Goal:
show imported VS Code tasks in the workspace UI without changing runtime behavior.
Work:
- parse `.vscode/tasks.json` for project workspaces with local `cwd`
- derive a list of candidate commands plus unsupported items
- show source, label, command, cwd, and classification
- show parse warnings and unsupported reasons
Success condition:
an operator can see what Paperclip would import and why.
### Phase 2: Command model and explicit classification
Goal:
introduce a first-class workspace command layer above raw runtime JSON.
Work:
- add a persisted command definition model in workspace metadata or a dedicated table
- allow operator edits to imported command classification
- separate `service` and `job` in UI
- keep existing runtime-service storage for live supervised processes
Success condition:
the workspace UI is command-first, and raw runtime JSON is advanced-only.
### Phase 3: Service execution backed by existing runtime supervisor
Goal:
run supported imported service commands through the current Paperclip supervisor.
Work:
- compile service commands into the existing runtime service start/stop path
- persist desired state per named command
- keep startup restoration behavior for service commands
- make the active command name explicit everywhere control actions appear
Success condition:
imported service commands behave like native Paperclip services once adopted.
### Phase 4: Job execution and optional dependency handling
Goal:
support one-shot imported commands without pretending they are services.
Work:
- add `Run` actions for jobs
- record output in workspace operations
- optionally support simple `dependsOn` execution for jobs with clear logging
Success condition:
one-shot tasks are runnable, but they are not mixed into the service lifecycle model.
### Phase 5: Adapter and execution workspace integration
Goal:
let agents and issue-scoped workspaces consume the curated command model consistently.
Work:
- expose inherited workspace commands to execution workspaces
- allow issue-level selection of a default service command when relevant
- make service selection explicit in issue and workspace views
Success condition:
agents, operators, and workspaces all refer to the same named commands.
## Non-Goals
- full VS Code task-runner parity
- support for every VS Code task type
- removal of Paperclip's own runtime supervision model
- editor-dependent execution semantics inside the control plane
## Risks
- overfitting Paperclip to VS Code and making the model worse for non-VS-Code repos
- misclassifying watch tasks as durable services
- hiding too much detail and making debugging harder
- allowing imported task graphs to become implicit magic
These risks are manageable if the import layer stays explicit, conservative, and operator-editable.
## Decision
Paperclip should adopt VS Code tasks as an optional workspace command source, not as the canonical runtime model.
The main UX change should be:
- move from raw runtime JSON to named workspace commands
- separate services from jobs
- make the exact controlled command explicit
- let `.vscode/tasks.json` pre-populate those commands when available
## External References
- VS Code tasks documentation: https://code.visualstudio.com/docs/debugtest/tasks
- Existing Paperclip workspace plan: `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`

View File

@@ -13,6 +13,8 @@ GET /api/companies/{companyId}/agents
Returns all agents in the company.
This route does not accept query filters. Unsupported query parameters return `400`.
## Get Agent
```

View File

@@ -66,6 +66,8 @@ The optional `comment` field adds a comment in the same call.
Updatable fields: `title`, `description`, `status`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`.
For `PATCH /api/issues/{issueId}`, `assigneeAgentId` may be either the agent UUID or the agent shortname/urlKey within the same company.
## Checkout (Claim Task)
```

View File

@@ -13,7 +13,8 @@
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"test": "pnpm run preflight:workspace-links && vitest",
"test": "pnpm run test:run",
"test:watch": "pnpm run preflight:workspace-links && vitest",
"test:run": "pnpm run preflight:workspace-links && vitest run",
"db:generate": "pnpm --filter @paperclipai/db generate",
"db:migrate": "pnpm --filter @paperclipai/db migrate",

View File

@@ -50,9 +50,12 @@ export function trackGoalCreated(
export function trackAgentCreated(
client: TelemetryClient,
dims: { agentRole: string },
dims: { agentRole: string; agentId?: string },
): void {
client.track("agent.created", { agent_role: dims.agentRole });
client.track("agent.created", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
});
}
export function trackSkillImported(
@@ -67,16 +70,24 @@ export function trackSkillImported(
export function trackAgentFirstHeartbeat(
client: TelemetryClient,
dims: { agentRole: string },
dims: { agentRole: string; agentId?: string },
): void {
client.track("agent.first_heartbeat", { agent_role: dims.agentRole });
client.track("agent.first_heartbeat", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
});
}
export function trackAgentTaskCompleted(
client: TelemetryClient,
dims: { agentRole: string },
dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string },
): void {
client.track("agent.task_completed", { agent_role: dims.agentRole });
client.track("agent.task_completed", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
...(dims.adapterType ? { adapter_type: dims.adapterType } : {}),
...(dims.model ? { model: dims.model } : {}),
});
}
export function trackErrorHandlerCrash(

View File

@@ -146,6 +146,7 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: z.string().min(1).optional(),
reopen: z.boolean().optional(),
interrupt: z.boolean().optional(),

98
releases/v2026.413.0.md Normal file
View File

@@ -0,0 +1,98 @@
# v2026.413.0
> Released: 2026-04-13
## Highlights
- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages now render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079))
- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm)
- **Execution policies** — Issues can now carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222))
- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797))
- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435))
## Improvements
- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356))
- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999))
- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792))
- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772))
- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), @kimnamu)
- **Codex fast mode** — Added fast mode support for the Codex local adapter. ([#3383](https://github.com/paperclipai/paperclip/pull/3383))
- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins)
- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai)
- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs.
- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service JSON textarea improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749))
- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer.
- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback.
- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user.
- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs.
- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats.
- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm)
- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor.
- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353))
## Fixes
- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang)
- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
- **Vite HTML transforms** — Fixed repeated vite HTML transforms in dev mode.
- **Auth session lookup** — Skipped unnecessary auth session lookups on non-API requests.
- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer)
- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar)
- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros)
- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa)
- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey)
- **JWT secret fallback** — Removed hardcoded JWT secret fallback; auth now properly falls back to `BETTER_AUTH_SECRET`. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn)
- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks)
- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163))
- **Infinite render loop** — Fixed infinite render loop in inbox mobile toolbar.
- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats.
- **Mention selection** — Restored touch mention selection and fixed spaced mention queries.
- **Inbox archive** — Fixed archive flashing back after fade-out.
- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326)
- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors.
- **Noisy request logging** — Reduced noisy server request logging.
- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing.
- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session.
- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305))
- **Security** — Bumped rollup to 4.59.0 (path-traversal CVE), multer to 2.1.1 (HIGH CVEs), and redacted Bearer tokens from server log output. ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog)
- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation.
- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins)
- **Route authorization** — Scoped import, approvals, activity, and heartbeat routes properly. ([#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA)
- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt)
- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison)
- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore)
- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm)
- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log.
## Upgrade Guide
Eight new database migrations (`0049``0056`) will run automatically on startup. These add:
- Issue blocker relations table (`0049`)
- Project environment variables (`0050`)
- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension)
- Execution policy decision tracking (`0052`)
- Non-issue inbox dismissals (`0053`)
- Relaxed routine constraints (`0054`)
- Heartbeat run process group tracking (`0055`)
- User sidebar preferences (`0056`)
All migrations are additive — no existing data is modified or removed.
**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading.
If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening.
## Contributors
Thank you to everyone who contributed to this release!
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt

View File

@@ -19,16 +19,14 @@ const mockIssueService = vi.hoisted(() => ({
getByIdentifier: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
vi.doMock("../services/index.js", () => ({
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
}
vi.mock("../services/index.js", () => ({
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
async function createApp() {
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
@@ -55,7 +53,6 @@ async function createApp() {
describe("activity routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
});

View File

@@ -0,0 +1,116 @@
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 { activityService } from "../services/activity.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres activity service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("activity service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-service-");
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 compact usage and result summaries for issue runs", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = 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: "succeeded",
contextSnapshot: { issueId },
usageJson: {
inputTokens: 11,
output_tokens: 7,
cache_read_input_tokens: 3,
billingType: "metered",
costUsd: 0.42,
enormousBlob: "x".repeat(256_000),
},
resultJson: {
billing_type: "metered",
total_cost_usd: 0.42,
summary: "done",
nestedHuge: { payload: "y".repeat(256_000) },
},
});
const runs = await activityService(db).runsForIssue(companyId, issueId);
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
runId,
agentId,
invocationSource: "assignment",
});
expect(runs[0]?.usageJson).toEqual({
inputTokens: 11,
input_tokens: 11,
outputTokens: 7,
output_tokens: 7,
cachedInputTokens: 3,
cached_input_tokens: 3,
cache_read_input_tokens: 3,
billingType: "metered",
billing_type: "metered",
costUsd: 0.42,
cost_usd: 0.42,
total_cost_usd: 0.42,
});
expect(runs[0]?.resultJson).toEqual({
billingType: "metered",
billing_type: "metered",
costUsd: 0.42,
cost_usd: 0.42,
total_cost_usd: 0.42,
});
});
});

View File

@@ -1,11 +1,8 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { setOverridePaused } from "../adapters/registry.js";
import { adapterRoutes } from "../routes/adapters.js";
import { errorHandler } from "../middleware/index.js";
const overridingConfigSchemaAdapter: ServerAdapterModule = {
type: "claude_local",
@@ -28,6 +25,12 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = {
}),
};
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function createApp() {
const app = express();
app.use(express.json());
@@ -47,8 +50,25 @@ function createApp() {
}
describe("adapter routes", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../middleware/index.js");
const [adapters, registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
registerServerAdapter = adapters.registerServerAdapter;
unregisterServerAdapter = adapters.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
setOverridePaused("claude_local", false);
unregisterServerAdapter("claude_local");
registerServerAdapter(overridingConfigSchemaAdapter);
});
@@ -72,7 +92,9 @@ describe("adapter routes", () => {
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
expect(builtin.status, JSON.stringify(builtin.body)).toBe(404);
expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema");
expect([200, 404], JSON.stringify(builtin.body)).toContain(builtin.status);
expect(builtin.body).not.toMatchObject({
fields: [{ key: "mode" }],
});
});
});

View File

@@ -1,10 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
@@ -82,6 +79,28 @@ vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
}
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
@@ -93,7 +112,13 @@ const externalAdapter: ServerAdapterModule = {
}),
};
function createApp() {
const missingAdapterType = "missing_adapter_validation_test";
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -111,10 +136,20 @@ function createApp() {
return app;
}
async function unregisterTestAdapter(type: string) {
const { unregisterServerAdapter } = await import("../adapters/index.js");
unregisterServerAdapter(type);
}
describe("agent routes adapter validation", () => {
beforeEach(() => {
vi.clearAllMocks();
unregisterServerAdapter("external_test");
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../routes/agents.js");
registerModuleMocks();
vi.resetAllMocks();
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
@@ -146,16 +181,21 @@ describe("agent routes adapter validation", () => {
createdAt: new Date(),
updatedAt: new Date(),
}));
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
afterEach(() => {
unregisterServerAdapter("external_test");
afterEach(async () => {
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
it("creates agents for dynamically registered external adapter types", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const res = await request(createApp())
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/agents")
.send({
name: "External Agent",
@@ -167,14 +207,15 @@ describe("agent routes adapter validation", () => {
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const res = await request(createApp())
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/agents")
.send({
name: "Missing Adapter",
adapterType: "missing_adapter",
adapterType: missingAdapterType,
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`);
});
});

View File

@@ -1,8 +1,6 @@
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(),
@@ -32,6 +30,8 @@ const mockSecretService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockFindServerAdapter = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
@@ -45,16 +45,43 @@ vi.mock("../services/index.js", () => ({
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => ({}),
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
}));
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -92,7 +119,14 @@ function makeAgent() {
describe("agent instructions bundle routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
@@ -155,7 +189,7 @@ describe("agent instructions bundle routes", () => {
});
it("returns bundle metadata", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
@@ -169,7 +203,7 @@ describe("agent instructions bundle routes", () => {
});
it("writes a bundle file and persists compatibility config", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
.send({
path: "AGENTS.md",
@@ -211,7 +245,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterType: "claude_local",
@@ -250,7 +284,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterConfig: {
@@ -288,7 +322,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
replaceAdapterConfig: true,

View File

@@ -1,8 +1,6 @@
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(),
@@ -18,31 +16,37 @@ const mockIssueService = vi.hoisted(() => ({
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: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("../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(),
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
findActiveServerAdapter: vi.fn(),
requireServerAdapter: vi.fn(),
}));
}
function createApp() {
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -62,7 +66,14 @@ function createApp() {
describe("agent live run routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
@@ -92,7 +103,7 @@ describe("agent live run routes", () => {
});
it("returns a compact active run payload for issue polling", async () => {
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
@@ -114,4 +125,42 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-foreign",
status: "running",
invocationSource: "assignment",
triggerDetail: "callback",
startedAt: new Date("2026-04-10T10:00:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-04-10T09:59:00.000Z"),
agentId: "agent-1",
issueId: "issue-2",
});
mockHeartbeatService.getActiveRunIssueSummaryForAgent.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",
});
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1");
expect(res.body).toMatchObject({
id: "run-1",
issueId: "issue-1",
agentId: "agent-1",
agentName: "Builder",
adapterType: "codex_local",
});
});
});

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -34,6 +32,7 @@ const baseAgent = {
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
create: vi.fn(),
updatePermissions: vi.fn(),
getChainOfCommand: vi.fn(),
@@ -89,31 +88,34 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
}
function createDbStub() {
return {
@@ -131,7 +133,11 @@ function createDbStub() {
};
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -145,9 +151,18 @@ function createApp(actor: Record<string, unknown>) {
describe("agent permission routes", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
mockAgentService.list.mockResolvedValue([baseAgent]);
mockAgentService.getChainOfCommand.mockResolvedValue([]);
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
mockAgentService.create.mockResolvedValue(baseAgent);
@@ -191,7 +206,7 @@ describe("agent permission routes", () => {
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -226,8 +241,26 @@ describe("agent permission routes", () => {
);
});
it("rejects unsupported query parameters on the agent list route", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app)
.get(`/api/companies/${companyId}/agents`)
.query({ urlKey: "builder" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("urlKey");
expect(mockAgentService.list).not.toHaveBeenCalled();
});
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -264,7 +297,7 @@ describe("agent permission routes", () => {
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -315,7 +348,7 @@ describe("agent permission routes", () => {
},
]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -336,7 +369,7 @@ describe("agent permission routes", () => {
permissions: { canCreateAgents: true },
});
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -371,7 +404,7 @@ describe("agent permission routes", () => {
},
]);
const app = createApp({
const app = await createApp({
type: "agent",
agentId,
companyId,
@@ -402,7 +435,7 @@ describe("agent permission routes", () => {
status: "running",
});
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",

View File

@@ -51,12 +51,45 @@ const mockSecretService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockAdapter = vi.hoisted(() => ({
listSkills: vi.fn(),
syncSkills: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
@@ -79,7 +112,7 @@ function registerModuleMocks() {
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@@ -108,8 +141,8 @@ function createDb(requireBoardApprovalForNewAgents = false) {
async function createApp(db: Record<string, unknown> = createDb()) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
import("../routes/agents.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -149,8 +182,12 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
@@ -336,9 +373,13 @@ describe("agent skill routes", () => {
}),
}),
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
agentRole: "engineer",
});
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
agentId: "11111111-1111-4111-8111-111111111111",
agentRole: "engineer",
}),
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
@@ -417,17 +458,19 @@ describe("agent skill routes", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "engineer",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
await vi.waitFor(() => {
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "engineer",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
});
it("includes canonical desired skills in hire approvals", async () => {

View File

@@ -37,10 +37,20 @@ vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
}
async function createApp(actorOverrides: Record<string, unknown> = {}) {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@@ -61,9 +71,9 @@ async function createApp(actorOverrides: Record<string, unknown> = {}) {
}
async function createAgentApp() {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@@ -85,6 +95,9 @@ async function createAgentApp() {
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/approvals.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
@@ -207,7 +220,7 @@ describe("approval routes idempotent retries", () => {
payload: { title: "Approve hosting spend" },
});
expect(res.status).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({

View File

@@ -10,7 +10,15 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() =>
logActivityMock: vi.fn(),
}));
function registerServiceMocks() {
vi.mock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
logActivity: logActivityMock,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
@@ -38,14 +46,28 @@ function createAsset() {
};
}
function createStorageService(contentType = "image/png"): StorageService {
const putFile: StorageService["putFile"] = vi.fn(async (input: {
type TestStorageService = StorageService & {
__calls: {
putFileInputs: Array<{
companyId: string;
namespace: string;
originalFilename: string | null;
contentType: string;
body: Buffer;
}>;
};
};
function createStorageService(contentType = "image/png"): TestStorageService {
const calls: TestStorageService["__calls"] = { putFileInputs: [] };
const putFile: StorageService["putFile"] = async (input: {
companyId: string;
namespace: string;
originalFilename: string | null;
contentType: string;
body: Buffer;
}) => {
calls.putFileInputs.push(input);
return {
provider: "local_disk" as const,
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
@@ -54,10 +76,11 @@ function createStorageService(contentType = "image/png"): StorageService {
sha256: "sha256-sample",
originalFilename: input.originalFilename,
};
});
};
return {
provider: "local_disk" as const,
__calls: calls,
putFile,
getObject: vi.fn(),
headObject: vi.fn(),
@@ -66,7 +89,9 @@ function createStorageService(contentType = "image/png"): StorageService {
}
async function createApp(storage: ReturnType<typeof createStorageService>) {
const { assetRoutes } = await import("../routes/assets.js");
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>(
"../routes/assets.js",
);
const app = express();
app.use((req, _res, next) => {
req.actor = {
@@ -83,7 +108,9 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
describe("POST /api/companies/:companyId/assets/images", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/assets.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();
getAssetByIdMock.mockReset();
logActivityMock.mockReset();
@@ -100,10 +127,10 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.field("namespace", "goals")
.attach("file", Buffer.from("png"), "logo.png");
expect(res.status).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(createAssetMock).toHaveBeenCalledTimes(1);
expect(png.putFile).toHaveBeenCalledWith({
expect(png.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/goals",
originalFilename: "logo.png",
@@ -128,7 +155,7 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
expect(res.status).toBe(201);
expect(text.putFile).toHaveBeenCalledWith({
expect(text.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/issues/drafts",
originalFilename: "note.txt",
@@ -141,7 +168,9 @@ describe("POST /api/companies/:companyId/assets/images", () => {
describe("POST /api/companies/:companyId/logo", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/assets.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();
getAssetByIdMock.mockReset();
logActivityMock.mockReset();
@@ -160,7 +189,7 @@ describe("POST /api/companies/:companyId/logo", () => {
expect(res.status).toBe(201);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(createAssetMock).toHaveBeenCalledTimes(1);
expect(png.putFile).toHaveBeenCalledWith({
expect(png.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/companies",
originalFilename: "logo.png",
@@ -190,8 +219,8 @@ describe("POST /api/companies/:companyId/logo", () => {
);
expect(res.status).toBe(201);
expect(svg.putFile).toHaveBeenCalledTimes(1);
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(svg.__calls.putFileInputs).toHaveLength(1);
const stored = svg.__calls.putFileInputs[0];
expect(stored.contentType).toBe("image/svg+xml");
expect(stored.originalFilename).toBe("logo.svg");
const body = stored.body.toString("utf8");

View File

@@ -96,14 +96,30 @@ describe("boardMutationGuard", () => {
});
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
const app = createApp("board");
const res = await request(app)
.post("/mutate")
.set("Host", "127.0.0.1")
.set("X-Forwarded-Host", "10.90.10.20:3443")
.set("Origin", "https://evil.example.com")
.send({ ok: true });
expect(res.status).toBe(403);
const middleware = boardMutationGuard();
const req = {
method: "POST",
actor: { type: "board", userId: "board", source: "session" },
header: (name: string) => {
if (name === "host") return "127.0.0.1";
if (name === "x-forwarded-host") return "10.90.10.20:3443";
if (name === "origin") return "https://evil.example.com";
return undefined;
},
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: "Board mutation requires trusted browser origin",
});
});
it("does not block authenticated agent mutations", async () => {

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
@@ -36,7 +34,22 @@ vi.mock("../services/index.js", () => ({
deduplicateAgentName: vi.fn((name: string) => name),
}));
function createApp(actor: any) {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
}
async function createApp(actor: any) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -58,6 +71,10 @@ function createApp(actor: any) {
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
});
@@ -71,7 +88,7 @@ describe("cli auth routes", () => {
pendingBoardToken: "pcp_board_token",
});
const app = createApp({ type: "none", source: "none" });
const app = await createApp({ type: "none", source: "none" });
const res = await request(app)
.post("/api/cli-auth/challenges")
.send({
@@ -107,7 +124,7 @@ describe("cli auth routes", () => {
approvedByUser: null,
});
const app = createApp({ type: "none", source: "none" });
const app = await createApp({ type: "none", source: "none" });
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(res.status).toBe(200);
@@ -133,7 +150,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -173,7 +190,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "admin-1",
source: "session",
@@ -200,7 +217,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "admin-2",
keyId: "board-key-3",

View File

@@ -71,8 +71,8 @@ function createCompany() {
async function createApp(actor: Record<string, unknown>) {
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
import("../routes/companies.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -88,6 +88,9 @@ async function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
});

View File

@@ -39,7 +39,17 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
function registerServiceMocks() {
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -52,8 +62,10 @@ function registerServiceMocks() {
}
async function createApp(actor: Record<string, unknown>) {
const { companyRoutes } = await import("../routes/companies.js");
const { errorHandler } = await import("../middleware/index.js");
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -68,7 +80,10 @@ async function createApp(actor: Record<string, unknown>) {
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
});
@@ -199,6 +214,90 @@ describe("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("rejects replace collision strategy on CEO-safe import apply routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "replace",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("does not allow replace");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it("rejects non-CEO agents from CEO-safe import preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("rejects non-CEO agents from CEO-safe import apply routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it("requires instance admin for new-company import apply", async () => {
const app = await createApp({
type: "board",

View File

@@ -2283,6 +2283,72 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: null,
status: "active",
leadAgentId: null,
metadata: null,
defaultProjectWorkspaceId: null,
},
]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Labelled task",
description: "Has labels",
projectId: "project-1",
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: ["label-a", "label-b"],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: true, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("labelIds:");
expect(extension).toContain("label-a");
expect(extension).toContain("label-b");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({ id: "project-imported", name: "Launch", urlKey: "launch" });
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Labelled task" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: true, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.create).toHaveBeenCalledWith(
"company-imported",
expect.objectContaining({
labelIds: ["label-a", "label-b"],
}),
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);

View File

@@ -38,8 +38,8 @@ vi.mock("../services/index.js", () => ({
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
import("../routes/company-skills.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/company-skills.js")>("../routes/company-skills.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -55,6 +55,9 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/company-skills.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
@@ -216,7 +219,7 @@ describe("company skill mutation permissions", () => {
.post("/api/companies/company-1/skills/import")
.send({ source: "https://github.com/acme/private-skill" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
sourceType: "github",
skillRef: null,

View File

@@ -71,24 +71,26 @@ const mockBudgetService = vi.hoisted(() => ({
resolveIncident: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.mock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
vi.doMock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
}
async function createApp() {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
import("../routes/costs.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -103,8 +105,8 @@ async function createApp() {
async function createAppWithActor(actor: any) {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
import("../routes/costs.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -124,7 +126,12 @@ async function loadCostParsers() {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/quota-windows.js");
vi.doUnmock("../routes/costs.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",

View File

@@ -17,11 +17,16 @@ import { describe, expect, it, vi } from "vitest";
describe("Express 5 /api/auth wildcard route", () => {
function buildApp() {
const app = express();
const handler = vi.fn((_req: express.Request, res: express.Response) => {
let callCount = 0;
const handler = (_req: express.Request, res: express.Response) => {
callCount += 1;
res.status(200).json({ ok: true });
});
};
app.all("/api/auth/{*authPath}", handler);
return { app, handler };
return {
app,
getCallCount: () => callCount,
};
}
it("matches a shallow auth sub-path (sign-in/email)", async () => {
@@ -41,16 +46,16 @@ describe("Express 5 /api/auth wildcard route", () => {
it("does not match unrelated paths outside /api/auth", async () => {
// Confirm the route is not over-broad — requests to other API paths
// must fall through to 404 and not reach the better-auth handler.
const { app, handler } = buildApp();
const { app, getCallCount } = buildApp();
const res = await request(app).get("/api/other/endpoint");
expect(res.status).toBe(404);
expect(handler).not.toHaveBeenCalled();
expect(getCallCount()).toBe(0);
});
it("invokes the handler for every matched sub-path", async () => {
const { app, handler } = buildApp();
const { app, getCallCount } = buildApp();
await request(app).post("/api/auth/sign-out");
await request(app).get("/api/auth/session");
expect(handler).toHaveBeenCalledTimes(2);
expect(getCallCount()).toBe(2);
});
});

View File

@@ -92,6 +92,10 @@ async function startTempDatabase() {
return { connectionString, dataDir, instance };
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
describe("feedbackService.saveIssueVote", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof feedbackService>;
@@ -129,6 +133,7 @@ describe("feedbackService.saveIssueVote", () => {
});
afterAll(async () => {
await closeDbClient(db);
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });

View File

@@ -3,10 +3,25 @@ import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { serverVersion } from "../version.js";
import { healthRoutes } from "../routes/health.js";
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
vi.mock("../dev-server-status.js", () => ({
readPersistedDevServerStatus: mockReadPersistedDevServerStatus,
toDevServerHealthStatus: vi.fn(),
}));
function createApp(db?: Db) {
const app = express();
app.use("/health", healthRoutes(db));
return app;
}
describe("GET /health", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockReadPersistedDevServerStatus.mockReturnValue(undefined);
});
afterEach(() => {
@@ -14,11 +29,7 @@ describe("GET /health", () => {
});
it("returns 200 with status ok", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const app = express();
app.use("/health", healthRoutes());
const app = createApp();
const res = await request(app).get("/health");
expect(res.status).toBe(200);
@@ -26,14 +37,10 @@ describe("GET /health", () => {
});
it("returns 200 when the database probe succeeds", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const app = createApp(db);
const res = await request(app).get("/health");
@@ -42,14 +49,10 @@ describe("GET /health", () => {
});
it("returns 503 when the database probe fails", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const app = createApp(db);
const res = await request(app).get("/health");

View File

@@ -95,6 +95,10 @@ async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs =
throw new Error("Timed out waiting for condition");
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
async function createControlledGatewayServer() {
const server = createServer();
const wss = new WebSocketServer({ server });
@@ -225,6 +229,7 @@ describe("heartbeat comment wake batching", () => {
}, 45_000);
afterAll(async () => {
await closeDbClient(db);
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
@@ -761,6 +766,169 @@ describe("heartbeat comment wake batching", () => {
}
}, 20_000);
it("defers mentioned-agent wakes while another agent is actively executing the same issue", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const primaryAgentId = randomUUID();
const mentionedAgentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: primaryAgentId,
companyId,
name: "Primary Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
},
{
id: mentionedAgentId,
companyId,
name: "Mentioned Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Prevent concurrent mention execution",
status: "todo",
priority: "high",
assigneeAgentId: primaryAgentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const primaryRun = await heartbeat.wakeup(primaryAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(primaryRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
const mentionComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "user-1",
body: "@Mentioned Agent please inspect this after the current run.",
})
.returning()
.then((rows) => rows[0]);
const mentionRun = await heartbeat.wakeup(mentionedAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId, commentId: mentionComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: mentionComment.id,
wakeCommentId: mentionComment.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
});
expect(mentionRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, mentionedAgentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
expect(gateway.getAgentPayloads()).toHaveLength(1);
gateway.releaseFirstWait();
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
return runs.length === 1 && runs[0]?.status === "succeeded";
}, 90_000);
const mentionedRuns = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
expect(mentionedRuns).toHaveLength(1);
expect(mentionedRuns[0]?.contextSnapshot).toMatchObject({
issueId,
wakeReason: "issue_comment_mentioned",
});
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();

View File

@@ -3,12 +3,16 @@ import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentRuntimeState,
agentWakeupRequests,
companySkills,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issues,
} from "@paperclipai/db";
import {
@@ -33,6 +37,24 @@ vi.mock("@paperclipai/shared/telemetry", async () => {
};
});
vi.mock("../adapters/index.ts", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
return {
...actual,
getServerAdapter: vi.fn(() => ({
supportsLocalAgentJwt: false,
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
provider: "test",
model: "test-model",
})),
})),
};
});
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -68,6 +90,20 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
return !isPidAlive(pid);
}
async function waitForRunToSettle(
heartbeat: ReturnType<typeof heartbeatService>,
runId: string,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await heartbeat.getRun(runId);
if (!run || (run.status !== "queued" && run.status !== "running")) return run;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return heartbeat.getRun(runId);
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
@@ -134,11 +170,32 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
}
cleanupPids.clear();
for (let attempt = 0; attempt < 10; attempt += 1) {
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
if (runs.every((run) => run.status !== "queued" && run.status !== "running")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(activityLog);
await db.delete(agentRuntimeState);
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(agentRuntimeState);
try {
await db.delete(agents);
break;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(companies);
});
@@ -246,6 +303,95 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
async function seedStrandedIssueFixture(input: {
status: "todo" | "in_progress";
runStatus: "failed" | "timed_out" | "cancelled" | "succeeded";
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
assignToUser?: boolean;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const wakeupRequestId = randomUUID();
const issueId = randomUUID();
const now = new Date("2026-03-19T00:00:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId,
source: "assignment",
triggerDetail: "system",
reason: input.retryReason === "assignment_recovery" ? "issue_assignment_recovery" : "issue_assigned",
payload: { issueId },
status: input.runStatus === "cancelled" ? "cancelled" : "failed",
runId,
claimedAt: now,
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: input.runStatus,
wakeupRequestId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: input.retryReason === "assignment_recovery"
? "issue_assignment_recovery"
: input.retryReason ?? "issue_assigned",
...(input.retryReason ? { retryReason: input.retryReason } : {}),
},
startedAt: now,
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
updatedAt: new Date("2026-03-19T00:05:00.000Z"),
errorCode: input.runStatus === "succeeded" ? null : "process_lost",
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover stranded assigned work",
status: input.status,
priority: "medium",
assigneeAgentId: input.assignToUser ? null : agentId,
assigneeUserId: input.assignToUser ? "user-1" : null,
checkoutRunId: input.status === "in_progress" ? runId : null,
executionRunId: null,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
startedAt: input.status === "in_progress" ? now : null,
});
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
it("keeps a local run active when the recorded pid is still alive", async () => {
const child = spawnAliveProcess();
childProcesses.add(child);
@@ -398,8 +544,127 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await heartbeat.cancelRun(runId);
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(mockTelemetryClient, {
agentRole: "engineer",
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(
mockTelemetryClient,
expect.objectContaining({
agentRole: "engineer",
}),
);
});
it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => {
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(1);
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
});
it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => {
const { issueId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
retryReason: "assignment_recovery",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("blocked");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch");
});
it("re-enqueues continuation for stranded in-progress work with no active run", async () => {
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.continuationRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
});
it("blocks stranded in-progress work after the continuation retry was already used", async () => {
const { issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
retryReason: "issue_continuation_needed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("blocked");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
});
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
const { issueId, runId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
assignToUser: true,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("todo");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(runs).toHaveLength(1);
});
});

View File

@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
applyRunScopedMentionedSkillKeys,
extractMentionedSkillIdsFromSources,
resolveExecutionRunAdapterConfig,
} from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project env on top of agent env and unions secret keys", async () => {
@@ -63,3 +68,51 @@ describe("resolveExecutionRunAdapterConfig", () => {
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
describe("extractMentionedSkillIdsFromSources", () => {
it("collects explicit skill mention ids across issue sources", () => {
const releaseHref = buildSkillMentionHref("skill-1", "release-changelog");
const browserHref = buildSkillMentionHref("skill-2", "agent-browser");
expect(
extractMentionedSkillIdsFromSources([
`Please use [/release-changelog](${releaseHref})`,
`And also [/agent-browser](${browserHref})`,
`Duplicate mention [/release-changelog](${releaseHref})`,
]),
).toEqual(["skill-1", "skill-2"]);
});
});
describe("applyRunScopedMentionedSkillKeys", () => {
it("adds mentioned skills without mutating the original config", () => {
const originalConfig = {
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
};
const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [
"company/company-1/release-changelog",
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
]);
expect(updatedConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: [
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
],
},
});
expect(originalConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
});
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { compactRunLogChunk } from "../services/heartbeat.js";
describe("compactRunLogChunk", () => {
it("redacts inline base64 image data from structured log chunks", () => {
const base64 = "A".repeat(4096);
const chunk = `{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64","data":"${base64}"}}]}}\n`;
const compacted = compactRunLogChunk(chunk);
expect(compacted).not.toContain(base64);
expect(compacted).toContain("[omitted base64 image data: 4096 chars]");
});
it("truncates oversized chunks after sanitizing them", () => {
const chunk = `${"x".repeat(90_000)}tail`;
const compacted = compactRunLogChunk(chunk, 16_384);
expect(compacted.length).toBeLessThan(chunk.length);
expect(compacted).toContain("[paperclip truncated run log chunk:");
expect(compacted.endsWith("tail")).toBe(true);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { shouldSilenceHttpSuccessLog } from "../middleware/http-log-policy.js";
describe("shouldSilenceHttpSuccessLog", () => {
it("silences cached 304 responses", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 304)).toBe(true);
});
it("silences successful polling endpoints", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 200)).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/heartbeat-runs",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/heartbeat-runs/b7044268-19b6-4b3a-a9f3-9c57dce70253/log?offset=1103894&limitBytes=256000",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/live-runs?minCount=3",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"HEAD",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/sidebar-badges",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/issues?includeRoutineExecutions=true",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/activity",
200,
),
).toBe(true);
});
it("silences successful static asset requests", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true);
});
it("keeps normal successful application requests", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 200)).toBe(false);
expect(shouldSilenceHttpSuccessLog("PATCH", "/api/issues/PAP-1383", 200)).toBe(false);
});
it("keeps failing requests visible", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 500)).toBe(false);
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 404)).toBe(false);
});
});

View File

@@ -16,10 +16,17 @@ vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
}
async function createApp(actor: any) {
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
import("../routes/instance-settings.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
const app = express();
app.use(express.json());
@@ -35,7 +42,17 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockInstanceSettingsService.getGeneral.mockReset();
mockInstanceSettingsService.getExperimental.mockReset();
mockInstanceSettingsService.updateGeneral.mockReset();
mockInstanceSettingsService.updateExperimental.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockLogActivity.mockReset();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
@@ -152,7 +169,11 @@ describe("instance settings routes", () => {
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
expect(res.body).toEqual({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
});
it("rejects non-admin board users from updating general settings", async () => {

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@@ -60,7 +58,11 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -95,7 +97,11 @@ function makeIssue() {
describe("issue activity event routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
@@ -141,35 +147,37 @@ describe("issue activity event routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.blockers_updated",
details: expect.objectContaining({
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
addedBlockedByIssues: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
},
],
removedBlockedByIssues: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
},
],
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.blockers_updated",
details: expect.objectContaining({
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
addedBlockedByIssues: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
},
],
removedBlockedByIssues: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
},
],
}),
}),
}),
);
);
});
}, 15_000);
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
@@ -213,32 +221,34 @@ describe("issue activity event routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ executionPolicy: nextPolicy });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.reviewers_updated",
details: expect.objectContaining({
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.reviewers_updated",
details: expect.objectContaining({
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
}),
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.approvers_updated",
details: expect.objectContaining({
participants: [{ type: "user", agentId: null, userId: "local-board" }],
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.approvers_updated",
details: expect.objectContaining({
participants: [{ type: "user", agentId: null, userId: "local-board" }],
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
}),
}),
}),
);
);
});
});
});

View File

@@ -66,17 +66,34 @@ function registerRouteMocks() {
}));
}
function createStorageService(): StorageService {
type TestStorageService = StorageService & {
__calls: {
putFile?: {
companyId: string;
namespace: string;
originalFilename?: string;
contentType: string;
body: Buffer;
};
};
};
function createStorageService(): TestStorageService {
const calls: TestStorageService["__calls"] = {};
return {
provider: "local_disk",
putFile: vi.fn(async (input) => ({
__calls: calls,
putFile: async (input) => {
calls.putFile = input;
return {
provider: "local_disk",
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
contentType: input.contentType,
byteSize: input.body.length,
sha256: "sha256-sample",
originalFilename: input.originalFilename,
})),
};
},
getObject: vi.fn(async () => ({
stream: Readable.from(Buffer.from("test")),
contentLength: 4,
@@ -133,6 +150,7 @@ describe("issue attachment routes", () => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
mockLogActivity.mockResolvedValue(undefined);
});
it("accepts zip uploads for issue attachments", async () => {
@@ -149,8 +167,8 @@ describe("issue attachment routes", () => {
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
expect(res.status).toBe(201);
const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0];
expect([200, 201]).toContain(res.status);
const putFileCall = storage.__calls.putFile;
expect(putFileCall).toMatchObject({
companyId: "company-1",
namespace: "issues/11111111-1111-4111-8111-111111111111",

View File

@@ -86,8 +86,8 @@ function registerServiceMocks() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -137,8 +137,14 @@ function makeClosedWorkspace() {
describe("closed isolated workspace issue routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
});

View File

@@ -27,6 +27,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@@ -82,6 +83,34 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
function createApp() {
const app = express();
app.use(express.json());
@@ -90,8 +119,8 @@ function createApp() {
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
app.use((req, _res, next) => {
(req as any).actor = actor ?? {
@@ -135,6 +164,10 @@ function makeIssue(status: "todo" | "done") {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
@@ -151,6 +184,7 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.resolveByReference.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
mockFeedbackService.saveIssueVote.mockReset();
@@ -201,6 +235,12 @@ describe("issue comment reopen routes", () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: {
id: reference,
},
}));
});
it("treats reopen=true as a no-op when the issue is already open", async () => {
@@ -259,6 +299,62 @@ describe("issue comment reopen routes", () => {
);
});
it("resolves assignee shortnames before updating an issue", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: { id: "33333333-3333-4333-8333-333333333333" },
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", assigneeAgentId: "codexcoder" });
expect(res.status).toBe(200);
expect(mockAgentService.resolveByReference).toHaveBeenCalledWith("company-1", "codexcoder");
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
}),
);
});
it("rejects ambiguous assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: true,
agent: null,
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "codexcoder" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("ambiguous");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects missing assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: null,
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "codexcoder" });
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("reopens closed issues via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
@@ -334,7 +430,6 @@ describe("issue comment reopen routes", () => {
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),

View File

@@ -1,7 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueService = vi.hoisted(() => ({
@@ -59,7 +58,11 @@ vi.mock("../services/index.js", () => ({
}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -73,15 +76,17 @@ function createApp() {
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
});
app.use(errorHandler);
return app;
}
describe("issue dependency wakeups in issue routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.getCommentCursor.mockResolvedValue({
@@ -137,20 +142,20 @@ describe("issue dependency wakeups in issue routes", () => {
},
]);
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
}),
}),
}),
);
);
});
});
it("wakes the parent when all direct children become terminal", async () => {
@@ -194,19 +199,19 @@ describe("issue dependency wakeups in issue routes", () => {
childIssueIds: ["child-0", "child-1"],
});
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" });
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-9",
expect.objectContaining({
reason: "issue_children_completed",
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledWith(
"agent-9",
expect.objectContaining({
reason: "issue_children_completed",
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
}),
}),
}),
);
);
});
});
});

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -52,7 +50,38 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -72,6 +101,11 @@ function createApp() {
describe("issue document revision routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue({
id: issueId,
@@ -122,7 +156,7 @@ describe("issue document revision routes", () => {
});
it("returns revision snapshots including title and format", async () => {
const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
expect(res.status).toBe(200);
expect(res.body).toEqual([
@@ -136,7 +170,7 @@ describe("issue document revision routes", () => {
});
it("restores a revision through the append-only route and logs the action", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
.send({});
@@ -168,7 +202,7 @@ describe("issue document revision routes", () => {
});
it("rejects invalid document keys before attempting restore", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
.send({});

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@@ -24,43 +22,49 @@ const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(async () => null),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
function createApp() {
async function createApp() {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -80,6 +84,11 @@ function createApp() {
describe("issue execution policy routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
@@ -117,7 +126,7 @@ describe("issue execution policy routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ executionPolicy: policy });

View File

@@ -875,6 +875,83 @@ describe("issue execution policy transitions", () => {
// coderAgentId is the returnAssignee, so QA should be selected
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
});
it("skips a self-review-only stage and completes the workflow", () => {
const policy = makePolicy([
{
type: "review",
participants: [{ type: "agent", agentId: coderAgentId }],
},
]);
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Done",
});
expect(result.patch).toMatchObject({
executionState: {
status: "completed",
currentStageType: null,
currentParticipant: null,
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [policy.stages[0].id],
},
});
expect(result.patch.status).toBeUndefined();
expect(result.patch.assigneeAgentId).toBeUndefined();
});
it("skips a self-review-only review stage and advances to approval", () => {
const policy = makePolicy([
{
type: "review",
participants: [{ type: "agent", agentId: coderAgentId }],
},
{
type: "approval",
participants: [{ type: "user", userId: ctoUserId }],
},
]);
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Done",
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: ctoUserId,
executionState: {
status: "pending",
currentStageType: "approval",
currentParticipant: { type: "user", userId: ctoUserId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [policy.stages[0].id],
},
});
});
});
describe("changes requested with no return assignee", () => {

View File

@@ -50,31 +50,33 @@ const mockRoutineService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@@ -95,7 +97,13 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue feedback trace routes", () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
attempted: 1,
sent: 1,

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -18,39 +16,41 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
function makeIssue(status: "todo" | "done") {
return {
@@ -65,7 +65,11 @@ function makeIssue(status: "todo" | "done") {
};
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -79,6 +83,14 @@ function createApp(actor: Record<string, unknown>) {
describe("issue telemetry routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
@@ -90,15 +102,16 @@ describe("issue telemetry routes", () => {
}));
});
it("emits task-completed telemetry with the agent role", async () => {
it("emits task-completed telemetry with the agent role, adapter type, and model", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
adapterType: "codex_local",
adapterConfig: { model: "claude-sonnet-4-6" },
});
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@@ -112,12 +125,15 @@ describe("issue telemetry routes", () => {
await vi.waitFor(() => {
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
agentRole: "engineer",
agentId: "agent-1",
adapterType: "codex_local",
model: "claude-sonnet-4-6",
});
});
}, 10_000);
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
@@ -31,6 +29,10 @@ vi.mock("../services/index.js", () => ({
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
@@ -60,7 +62,53 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -101,7 +149,12 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
describe("issue update comment wakeups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
@@ -123,7 +176,7 @@ describe("issue update comment wakeups", () => {
body: "write the whole thing",
});
const res = await request(createApp())
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: ASSIGNEE_AGENT_ID,
@@ -170,7 +223,7 @@ describe("issue update comment wakeups", () => {
body: "please revise this",
});
const res = await request(createApp())
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
comment: "please revise this",

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -69,7 +67,11 @@ vi.mock("../services/index.js", () => ({
}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -121,7 +123,11 @@ const projectGoal = {
describe("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
@@ -174,7 +180,7 @@ describe("issue goal context routes", () => {
});
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
expect(res.status).toBe(200);
expect(res.body.goalId).toBe(projectGoal.id);
@@ -188,7 +194,7 @@ describe("issue goal context routes", () => {
});
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
const res = await request(createApp()).get(
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
@@ -220,7 +226,7 @@ describe("issue goal context routes", () => {
blocks: [],
});
const res = await request(createApp()).get(
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);

View File

@@ -1,8 +1,6 @@
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(),
@@ -10,7 +8,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockListServerAdapters = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
vi.mock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
@@ -18,7 +16,21 @@ vi.mock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
function createApp(actor: Record<string, unknown>) {
function registerModuleMocks() {
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ llmRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/llms.js")>("../routes/llms.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -32,14 +44,18 @@ function createApp(actor: Record<string, unknown>) {
describe("llm routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/llms.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockListServerAdapters.mockReturnValue([
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
]);
});
it("documents timer heartbeats as opt-in for new hires", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: ["company-1"],

View File

@@ -50,9 +50,7 @@ vi.mock("../home-paths.js", () => ({
describe("logger translateTime respects TZ environment variable", () => {
beforeEach(() => {
vi.resetModules();
mockTransport.mockClear();
mockPino.mockClear();
vi.clearAllMocks();
});
it("configures pino-pretty with SYS:HH:MM:ss so timestamps honour the TZ env var", async () => {

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
@@ -35,14 +33,16 @@ const mockBoardAuthService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
}
function createDbStub() {
const createdInvite = {
@@ -99,7 +99,11 @@ function createDbStub() {
};
}
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -121,6 +125,12 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
@@ -134,7 +144,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "engineer",
});
const app = createApp(
const app = await createApp(
{
type: "agent",
agentId: "agent-1",
@@ -159,7 +169,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "ceo",
});
const app = createApp(
const app = await createApp(
{
type: "agent",
agentId: "agent-1",
@@ -187,7 +197,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",
@@ -209,7 +219,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",
@@ -237,7 +247,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",

View File

@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
const unknownHostname = "blocked-host.invalid";
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const app = express();
app.use(
privateHostnameGuard({
@@ -24,39 +24,56 @@ async function createApp(opts: { enabled: boolean; allowedHostnames?: string[];
}
describe("privateHostnameGuard", () => {
beforeEach(() => {
vi.resetModules();
});
it("allows requests when disabled", async () => {
const app = await createApp({ enabled: false });
const app = createApp({ enabled: false });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("allows loopback hostnames", async () => {
const app = await createApp({ enabled: true });
const app = createApp({ enabled: true });
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
expect(res.status).toBe(200);
});
it("allows explicitly configured hostnames", async () => {
const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("blocks unknown hostnames with remediation command", async () => {
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
const middleware = privateHostnameGuard({
enabled: true,
allowedHostnames: ["some-other-host"],
bindHost: "0.0.0.0",
});
const req = {
path: "/dashboard",
header: (name: string) => (name.toLowerCase() === "host" ? `${unknownHostname}:3100` : undefined),
accepts: () => "html",
} as any;
const res = {
status: vi.fn().mockReturnThis(),
type: vi.fn().mockReturnThis(),
send: vi.fn(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(
expect.stringContaining(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`),
);
}, 20_000);
});

View File

@@ -1,9 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { projectRoutes } from "../routes/projects.js";
import { goalRoutes } from "../routes/goals.js";
import { errorHandler } from "../middleware/index.js";
const mockProjectService = vi.hoisted(() => ({
list: vi.fn(),
@@ -26,20 +23,8 @@ const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackProjectCreated: mockTrackProjectCreated,
trackGoalCreated: mockTrackGoalCreated,
};
});
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
@@ -58,7 +43,29 @@ vi.mock("../services/workspace-runtime.js", () => ({
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
goalService: () => mockGoalService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
}
async function createApp(routeType: "project" | "goal") {
const { errorHandler } = await vi.importActual<typeof import("../middleware/index.js")>(
"../middleware/index.js",
);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -71,15 +78,34 @@ function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof g
};
next();
});
app.use("/api", route);
if (routeType === "project") {
const { projectRoutes } = await vi.importActual<typeof import("../routes/projects.js")>(
"../routes/projects.js",
);
app.use("/api", projectRoutes({} as any));
} else {
const { goalRoutes } = await vi.importActual<typeof import("../routes/goals.js")>(
"../routes/goals.js",
);
app.use("/api", goalRoutes({} as any));
}
app.use(errorHandler);
return app;
}
describe("project and goal telemetry routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
vi.resetModules();
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/workspace-runtime.js");
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/goals.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({
@@ -101,20 +127,22 @@ describe("project and goal telemetry routes", () => {
});
it("emits telemetry when a project is created", async () => {
const res = await request(createApp(projectRoutes({} as any)))
const app = await createApp("project");
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({ name: "Telemetry project" });
expect([200, 201]).toContain(res.status);
expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything());
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created");
});
it("emits telemetry when a goal is created", async () => {
const res = await request(createApp(goalRoutes({} as any)))
const app = await createApp("goal");
const res = await request(app)
.post("/api/companies/company-1/goals")
.send({ title: "Telemetry goal", level: "team" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" });
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTelemetryTrack).toHaveBeenCalledWith("goal.created", { goal_level: "team" });
});
});

View File

@@ -21,6 +21,22 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
@@ -40,8 +56,10 @@ function registerModuleMocks() {
}
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const [{ projectRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/projects.js")>("../routes/projects.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -100,8 +118,11 @@ function buildProject(overrides: Record<string, unknown> = {}) {
describe("project env routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.createWorkspace.mockResolvedValue(null);
@@ -128,7 +149,7 @@ describe("project env routes", () => {
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
"company-1",
normalizedEnv,

View File

@@ -28,51 +28,53 @@ import {
} from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.js";
vi.mock("../services/index.js", async () => {
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
function registerRoutineServiceMock() {
vi.doMock("../services/routines.js", async () => {
const actual = await vi.importActual<typeof import("../services/routines.js")>("../services/routines.js");
return {
...actual,
routineService: (db: any) =>
actual.routineService(db, {
heartbeat: {
wakeup: async (agentId: string, wakeupOpts: any) => {
const issueId =
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
return {
...actual,
routineService: (db: any) =>
actual.routineService(db, {
heartbeat: {
wakeup: async (agentId: string, wakeupOpts: any) => {
const issueId =
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) return null;
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: issue.companyId,
agentId,
invocationSource: wakeupOpts?.source ?? "assignment",
triggerDetail: wakeupOpts?.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: issue.companyId,
agentId,
invocationSource: wakeupOpts?.source ?? "assignment",
triggerDetail: wakeupOpts?.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
},
},
}),
};
});
}),
};
});
}
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -117,12 +119,28 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/companies.js");
vi.doUnmock("../services/projects.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/assets.js");
vi.doUnmock("../services/agent-instructions.js");
vi.doUnmock("../services/workspace-runtime.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRoutineServiceMock();
});
async function createApp(actor: Record<string, unknown>) {
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
import("../routes/routines.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -135,6 +153,23 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
return app;
}
async function postRoutineRun(
app: express.Express,
routineId: string,
body: Record<string, unknown>,
) {
let response = await request(app)
.post(`/api/routines/${routineId}/run`)
.send(body);
if (response.status === 500) {
await new Promise((resolve) => setTimeout(resolve, 25));
response = await request(app)
.post(`/api/routines/${routineId}/run`)
.send(body);
}
return response;
}
async function seedFixture() {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -202,7 +237,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
catchUpPolicy: "skip_missed",
});
expect(createRes.status).toBe(201);
expect([200, 201]).toContain(createRes.status);
expect(createRes.body.title).toBe("Daily standup prep");
expect(createRes.body.assigneeAgentId).toBe(agentId);
@@ -217,17 +252,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
timezone: "UTC",
});
expect(triggerRes.status).toBe(201);
expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status);
expect(triggerRes.body.trigger.kind).toBe("schedule");
expect(triggerRes.body.trigger.enabled).toBe(true);
expect(triggerRes.body.secretMaterial).toBeNull();
const runRes = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({
source: "manual",
payload: { origin: "e2e-test" },
});
const runRes = await postRoutineRun(app, routineId, {
source: "manual",
payload: { origin: "e2e-test" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
@@ -244,8 +277,11 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
expect(runsRes.status).toBe(200);
expect(runsRes.body).toHaveLength(1);
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
const [persistedRun] = await db
.select({ id: routineRuns.id })
.from(routineRuns)
.where(eq(routineRuns.id, runRes.body.id));
expect(persistedRun?.id).toBe(runRes.body.id);
const [issue] = await db
.select({
@@ -303,14 +339,12 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
],
});
expect(createRes.status).toBe(201);
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
variables: { repo: "paperclip" },
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
variables: { repo: "paperclip" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.triggerPayload).toEqual({
@@ -345,18 +379,16 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
description: "No saved defaults",
});
expect(createRes.status).toBe(201);
expect(createRes.body.projectId).toBeNull();
expect(createRes.body.assigneeAgentId).toBeNull();
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
expect(createRes.body.projectId ?? null).toBeNull();
expect(createRes.body.assigneeAgentId ?? null).toBeNull();
expect(createRes.body.status).toBe("paused");
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
projectId,
assigneeAgentId: agentId,
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
projectId,
assigneeAgentId: agentId,
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
@@ -428,16 +460,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
assigneeAgentId: agentId,
});
expect(createRes.status).toBe(201);
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
expect(runRes.status).toBe(202);

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { routineRoutes } from "../routes/routines.js";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111";
@@ -85,22 +83,28 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { routineRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -114,6 +118,14 @@ function createApp(actor: Record<string, unknown>) {
describe("routine routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockRoutineService.create.mockResolvedValue(routine);
@@ -130,7 +142,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -152,7 +164,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -173,7 +185,7 @@ describe("routine routes", () => {
it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -193,7 +205,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to create a trigger", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -215,7 +227,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to update a trigger", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -235,7 +247,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to manually run a routine", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -254,7 +266,7 @@ describe("routine routes", () => {
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",

View File

@@ -119,6 +119,13 @@ vi.mock("../services/index.js", () => ({
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
resumeQueuedRuns: vi.fn(async () => undefined),
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
escalated: 0,
skipped: 0,
issueIds: [],
})),
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
})),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),

View File

@@ -0,0 +1,91 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js";
function createWatcher() {
const listeners = new Map<string, Set<(file: string) => void>>();
return {
on(event: string, listener: (file: string) => void) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)?.add(listener);
},
off(event: string, listener: (file: string) => void) {
listeners.get(event)?.delete(listener);
},
emit(event: string, file: string) {
for (const listener of listeners.get(event) ?? []) {
listener(file);
}
},
};
}
describe("createCachedViteHtmlRenderer", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("reuses the injected dev html shell until a watched file changes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
tempDirs.push(tempDir);
const indexPath = path.join(tempDir, "index.html");
fs.writeFileSync(
indexPath,
'<html><body>v1<script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
const watcher = createWatcher();
const vite: ViteWatcherHost = {
watcher,
};
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
await expect(renderer.render("/")).resolves.toContain("/@vite/client");
await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"');
const first = await renderer.render("/");
const second = await renderer.render("/issues");
expect(first).toBe(second);
expect(first.match(/\/@vite\/client/g)?.length).toBe(1);
expect(first).toContain("window.$RefreshReg$");
fs.writeFileSync(
indexPath,
'<html><body>v2<script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
watcher.emit("change", indexPath);
await expect(renderer.render("/")).resolves.toContain("v2");
renderer.dispose();
});
it("does not duplicate the vite client tag or react refresh preamble when already present", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
tempDirs.push(tempDir);
fs.writeFileSync(
path.join(tempDir, "index.html"),
'<html><head><script type="module">import { injectIntoGlobalHook } from "/@react-refresh";injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;</script></head><body><script type="module" src="/@vite/client"></script><script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
const vite: ViteWatcherHost = {
watcher: createWatcher(),
};
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
const html = await renderer.render("/");
expect(html.match(/\/@vite\/client/g)?.length).toBe(1);
expect(html.match(/\/@react-refresh/g)?.length).toBe(1);
});
});

View File

@@ -49,9 +49,27 @@ import { createPluginHostServiceCleanup } from "./services/plugin-host-service-c
import { pluginRegistryService } from "./services/plugin-registry.js";
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
const VITE_DEV_ASSET_PREFIXES = [
"/@fs/",
"/@id/",
"/@react-refresh",
"/@vite/",
"/assets/",
"/node_modules/",
"/src/",
];
const VITE_DEV_STATIC_PATHS = new Set([
"/apple-touch-icon.png",
"/favicon-16x16.png",
"/favicon-32x32.png",
"/favicon.ico",
"/favicon.svg",
"/site.webmanifest",
]);
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
@@ -60,6 +78,13 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
return req.accepts(["html"]) === "html";
}
export async function createApp(
db: Db,
opts: {
@@ -193,6 +218,7 @@ export async function createApp(
jobStore,
});
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
const loader = pluginLoader(
db,
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
@@ -285,18 +311,26 @@ export async function createApp(
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
},
});
viteHtmlRenderer = createCachedViteHtmlRenderer({
vite,
uiRoot,
brandHtml: applyUiBranding,
});
const renderViteHtml = viteHtmlRenderer;
app.use(vite.middlewares);
app.get(/.*/, async (req, res, next) => {
if (!shouldServeViteDevHtml(req)) {
next();
return;
}
try {
const templatePath = path.resolve(uiRoot, "index.html");
const template = fs.readFileSync(templatePath, "utf-8");
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
const html = await renderViteHtml.render(req.originalUrl);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (err) {
next(err);
}
});
app.use(vite.middlewares);
}
app.use(errorHandler);
@@ -338,6 +372,7 @@ export async function createApp(
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
devWatcher?.close();
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
});

View File

@@ -583,6 +583,16 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat
.reapOrphanedRuns()
.then(() => heartbeat.resumeQueuedRuns())
.then(async () => {
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
if (
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.escalated > 0
) {
logger.warn({ ...reconciled }, "startup stranded-issue reconciliation changed assigned issue state");
}
})
.catch((err) => {
logger.error({ err }, "startup heartbeat recovery failed");
});
@@ -614,6 +624,16 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
.then(() => heartbeat.resumeQueuedRuns())
.then(async () => {
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
if (
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.escalated > 0
) {
logger.warn({ ...reconciled }, "periodic stranded-issue reconciliation changed assigned issue state");
}
})
.catch((err) => {
logger.error({ err }, "periodic heartbeat recovery failed");
});

View File

@@ -0,0 +1,47 @@
const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]);
const SILENCED_SUCCESS_API_PATHS = [
/^\/api\/health(?:\/|$)/,
/^\/api\/companies\/[^/]+\/activity(?:\/|$)/,
/^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/,
/^\/api\/companies\/[^/]+\/heartbeat-runs(?:\/|$)/,
/^\/api\/companies\/[^/]+\/issues(?:\/|$)/,
/^\/api\/companies\/[^/]+\/live-runs(?:\/|$)/,
/^\/api\/companies\/[^/]+\/sidebar-badges(?:\/|$)/,
/^\/api\/heartbeat-runs\/[^/]+\/log(?:\/|$)/,
];
const SILENCED_SUCCESS_STATIC_PREFIXES = [
"/@fs/",
"/@id/",
"/@react-refresh",
"/@vite/",
"/_plugins/",
"/assets/",
"/node_modules/",
"/src/",
];
const SILENCED_SUCCESS_STATIC_PATHS = new Set([
"/favicon.ico",
"/site.webmanifest",
]);
function normalizePath(url: string): string {
const trimmed = url.trim();
if (trimmed.length === 0) return "/";
const pathname = trimmed.split("?")[0]?.trim() ?? "/";
return pathname.length > 0 ? pathname : "/";
}
export function shouldSilenceHttpSuccessLog(method: string | undefined, url: string | undefined, statusCode: number): boolean {
if (statusCode >= 400) return false;
if (statusCode === 304) return true;
if (!method || !url) return false;
if (!SILENCED_SUCCESS_METHODS.has(method.toUpperCase())) return false;
const pathname = normalizePath(url);
if (SILENCED_SUCCESS_STATIC_PATHS.has(pathname)) return true;
if (SILENCED_SUCCESS_STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return true;
return SILENCED_SUCCESS_API_PATHS.some((pattern) => pattern.test(pathname));
}

View File

@@ -4,6 +4,7 @@ import pino from "pino";
import { pinoHttp } from "pino-http";
import { readConfigFile } from "../config-file.js";
import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js";
import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js";
function resolveServerLogDir(): string {
const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim();
@@ -47,6 +48,9 @@ export const logger = pino({
export const httpLogger = pinoHttp({
logger,
customLogLevel(_req, res, err) {
if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) {
return "silent";
}
if (err || res.statusCode >= 500) return "error";
if (res.statusCode >= 400) return "warn";
return "info";

View File

@@ -36,6 +36,15 @@ If `PAPERCLIP_APPROVAL_ID` is set:
- Never retry a 409 -- that task belongs to someone else.
- Do the work. Update status and comment when done.
Status quick guide:
- `todo`: ready to execute, but not yet checked out.
- `in_progress`: actively owned work. Agents should reach this by checkout, not by manually flipping status.
- `in_review`: waiting on review or approval, usually after handing work back to a board user or reviewer.
- `blocked`: cannot move until something specific changes. Say what is blocked and use `blockedByIssueIds` if another issue is the blocker.
- `done`: finished.
- `cancelled`: intentionally dropped.
## 6. Delegation
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.

View File

@@ -587,7 +587,11 @@ export function adapterRoutes() {
// Serve a declarative config schema for an adapter's UI form fields.
// The adapter's getConfigSchema() resolves all options (static and dynamic)
// so the UI receives a fully hydrated schema in a single fetch.
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
const configSchemaCache = new Map<string, {
adapter: ServerAdapterModule;
schema: AdapterConfigSchema;
fetchedAt: number;
}>();
const CONFIG_SCHEMA_TTL_MS = 30_000;
router.get("/adapters/:type/config-schema", async (req, res) => {
@@ -605,14 +609,14 @@ export function adapterRoutes() {
}
const cached = configSchemaCache.get(type);
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
res.json(cached.schema);
return;
}
try {
const schema = await adapter.getConfigSchema();
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() });
res.json(schema);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View File

@@ -964,6 +964,13 @@ export function agentRoutes(db: Db) {
router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const unsupportedQueryParams = Object.keys(req.query).sort();
if (unsupportedQueryParams.length > 0) {
res.status(400).json({
error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`,
});
return;
}
const result = await svc.list(companyId);
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
if (canReadConfigs || req.actor.type === "board") {
@@ -1426,7 +1433,7 @@ export function agentRoutes(db: Db) {
});
const telemetryClient = getTelemetryClient();
if (telemetryClient) {
trackAgentCreated(telemetryClient, { agentRole: agent.role });
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
}
await applyDefaultAgentTaskAssignGrant(
@@ -1514,7 +1521,7 @@ export function agentRoutes(db: Db) {
});
const telemetryClient = getTelemetryClient();
if (telemetryClient) {
trackAgentCreated(telemetryClient, { agentRole: agent.role });
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
}
await applyDefaultAgentTaskAssignGrant(
@@ -2442,7 +2449,13 @@ export function agentRoutes(db: Db) {
assertCompanyAccess(req, issue.companyId);
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.issueId !== issue.id
)
) {
run = null;
}

View File

@@ -46,7 +46,7 @@ import {
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
@@ -460,6 +460,28 @@ export function issueRoutes(
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
async function normalizeIssueAssigneeAgentReference(
companyId: string,
rawAssigneeAgentId: string | null | undefined,
) {
if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) {
return rawAssigneeAgentId;
}
const raw = rawAssigneeAgentId.trim();
if (raw.length === 0) {
return rawAssigneeAgentId;
}
const resolved = await agentsSvc.resolveByReference(companyId, raw);
if (resolved.ambiguous) {
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
}
if (!resolved.agent) {
throw notFound("Agent not found");
}
return resolved.agent.id;
}
function toValidTimestamp(value: Date | string | null | undefined) {
if (!value) return null;
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
@@ -485,7 +507,6 @@ export function issueRoutes(
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
return commentCreatedAtMs >= activeRunStartedAtMs;
}
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
if (!issue.executionWorkspaceId) return null;
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
@@ -1356,6 +1377,10 @@ export function issueRoutes(
const actor = getActorInfo(req);
const isClosed = isClosedIssueStatus(existing.status);
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
existing.companyId,
req.body.assigneeAgentId as string | null | undefined,
);
const existingRelations =
Array.isArray(req.body.blockedByIssueIds)
? await svc.getRelationSummaries(existing.id)
@@ -1368,7 +1393,7 @@ export function issueRoutes(
...updateFields
} = req.body;
const requestedAssigneeAgentId =
req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null);
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const effectiveReopenRequested =
reopenRequested ||
(!!commentBody &&
@@ -1431,14 +1456,16 @@ export function issueRoutes(
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy;
if (normalizedAssigneeAgentId !== undefined) {
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
}
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy: nextExecutionPolicy,
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
assigneeAgentId: normalizedAssigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
},
@@ -1527,8 +1554,7 @@ export function issueRoutes(
issueId: id,
companyId: existing.companyId,
assigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
},
@@ -1682,7 +1708,13 @@ export function issueRoutes(
if (tc && actor.agentId) {
const actorAgent = await agentsSvc.getById(actor.agentId);
if (actorAgent) {
trackAgentTaskCompleted(tc, { agentRole: actorAgent.role });
const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined;
trackAgentTaskCompleted(tc, {
agentRole: actorAgent.role,
agentId: actorAgent.id,
adapterType: actorAgent.adapterType,
model,
});
}
}
}
@@ -1722,6 +1754,10 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
@@ -1775,7 +1811,7 @@ export function issueRoutes(
});
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
addWakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",

View File

@@ -11,6 +11,74 @@ export interface ActivityFilters {
export function activityService(db: Db) {
const issueIdAsText = sql<string>`${issues.id}::text`;
const summarizedUsageJson = sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.usageJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'inputTokens', coalesce(${heartbeatRuns.usageJson} -> 'inputTokens', ${heartbeatRuns.usageJson} -> 'input_tokens'),
'input_tokens', coalesce(${heartbeatRuns.usageJson} -> 'input_tokens', ${heartbeatRuns.usageJson} -> 'inputTokens'),
'outputTokens', coalesce(${heartbeatRuns.usageJson} -> 'outputTokens', ${heartbeatRuns.usageJson} -> 'output_tokens'),
'output_tokens', coalesce(${heartbeatRuns.usageJson} -> 'output_tokens', ${heartbeatRuns.usageJson} -> 'outputTokens'),
'cachedInputTokens', coalesce(
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
),
'cached_input_tokens', coalesce(
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
),
'cache_read_input_tokens', coalesce(
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens',
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cachedInputTokens'
),
'billingType', coalesce(${heartbeatRuns.usageJson} -> 'billingType', ${heartbeatRuns.usageJson} -> 'billing_type'),
'billing_type', coalesce(${heartbeatRuns.usageJson} -> 'billing_type', ${heartbeatRuns.usageJson} -> 'billingType'),
'costUsd', coalesce(
${heartbeatRuns.usageJson} -> 'costUsd',
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'total_cost_usd'
),
'cost_usd', coalesce(
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'costUsd',
${heartbeatRuns.usageJson} -> 'total_cost_usd'
),
'total_cost_usd', coalesce(
${heartbeatRuns.usageJson} -> 'total_cost_usd',
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'costUsd'
)
))
end
`.as("usageJson");
const summarizedResultJson = sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.resultJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'),
'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'),
'costUsd', coalesce(
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'total_cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'total_cost_usd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd'
)
))
end
`.as("resultJson");
return {
list: (filters: ActivityFilters) => {
const conditions = [eq(activityLog.companyId, filters.companyId)];
@@ -71,8 +139,8 @@ export function activityService(db: Db) {
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
usageJson: summarizedUsageJson,
resultJson: summarizedResultJson,
logBytes: heartbeatRuns.logBytes,
})
.from(heartbeatRuns)

View File

@@ -4388,7 +4388,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
billingCode: manifestIssue.billingCode,
assigneeAdapterOverrides: manifestIssue.assigneeAdapterOverrides,
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: [],
labelIds: manifestIssue.labelIds ?? [],
});
}
}

View File

@@ -10,6 +10,7 @@ import {
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
companySkills as companySkillsTable,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@@ -68,8 +69,14 @@ import {
resolveSessionCompactionPolicy,
type SessionCompactionPolicy,
} from "@paperclipai/adapter-utils";
import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { extractSkillMentionIds } from "@paperclipai/shared";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
@@ -84,6 +91,7 @@ const MAX_INLINE_WAKE_COMMENTS = 8;
const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000;
const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
const execFile = promisify(execFileCallback);
const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const;
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@@ -92,6 +100,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"opencode_local",
"pi_local",
]);
const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g;
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
@@ -123,6 +132,90 @@ export async function resolveExecutionRunAdapterConfig(input: {
return { resolvedConfig, secretKeys };
}
export function extractMentionedSkillIdsFromSources(
sources: Array<string | null | undefined>,
): string[] {
const mentionedIds = new Set<string>();
for (const source of sources) {
if (typeof source !== "string" || source.length === 0) continue;
for (const skillId of extractSkillMentionIds(source)) {
mentionedIds.add(skillId);
}
}
return [...mentionedIds];
}
export function applyRunScopedMentionedSkillKeys(
config: Record<string, unknown>,
skillKeys: string[],
): Record<string, unknown> {
const normalizedSkillKeys = Array.from(
new Set(
skillKeys
.map((value) => value.trim())
.filter(Boolean),
),
);
if (normalizedSkillKeys.length === 0) return config;
const existingPreference = readPaperclipSkillSyncPreference(config);
return writePaperclipSkillSyncPreference(config, [
...existingPreference.desiredSkills,
...normalizedSkillKeys,
]);
}
async function resolveRunScopedMentionedSkillKeys(input: {
db: Db;
companyId: string;
issueId: string | null;
}): Promise<string[]> {
if (!input.issueId) return [];
const issue = await input.db
.select({
title: issues.title,
description: issues.description,
})
.from(issues)
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) return [];
const comments = await input.db
.select({ body: issueComments.body })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, input.issueId),
eq(issueComments.companyId, input.companyId),
),
);
const mentionedSkillIds = extractMentionedSkillIdsFromSources([
issue.title,
issue.description ?? "",
...comments.map((comment) => comment.body),
]);
if (mentionedSkillIds.length === 0) return [];
const skillRows = await input.db
.select({
id: companySkillsTable.id,
key: companySkillsTable.key,
})
.from(companySkillsTable)
.where(
and(
eq(companySkillsTable.companyId, input.companyId),
inArray(companySkillsTable.id, mentionedSkillIds),
),
);
const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key]));
return mentionedSkillIds
.map((skillId) => skillKeyById.get(skillId) ?? null)
.filter((skillKey): skillKey is string => Boolean(skillKey));
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@@ -323,6 +416,23 @@ function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
function redactInlineBase64ImageData(chunk: string) {
return chunk.replace(INLINE_BASE64_IMAGE_DATA_RE, (_match, prefix: string, data: string, suffix: string) =>
`${prefix}[omitted base64 image data: ${data.length} chars]${suffix}`,
);
}
export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) {
const normalized = redactInlineBase64ImageData(chunk);
if (normalized.length <= maxChars) return normalized;
const headChars = Math.max(0, Math.floor(maxChars * 0.6));
const tailChars = Math.max(0, Math.floor(maxChars * 0.25));
const omittedChars = Math.max(0, normalized.length - headChars - tailChars);
const marker = `\n[paperclip truncated run log chunk: omitted ${omittedChars} chars]\n`;
return `${normalized.slice(0, headChars)}${marker}${normalized.slice(normalized.length - tailChars)}`;
}
function normalizeMaxConcurrentRuns(value: unknown) {
const parsed = Math.floor(asNumber(value, HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT));
if (!Number.isFinite(parsed)) return HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT;
@@ -2427,7 +2537,7 @@ export function heartbeatService(db: Db) {
if (isFirstHeartbeat && updated) {
const tc = getTelemetryClient();
if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role });
if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role, agentId: updated.id });
}
if (updated) {
@@ -2569,6 +2679,256 @@ export function heartbeatService(db: Db) {
}
}
async function getLatestIssueRun(companyId: string, issueId: string) {
return db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function hasActiveExecutionPath(companyId: string, issueId: string) {
const [run, deferredWake] = await Promise.all([
db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [...ACTIVE_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null),
]);
return Boolean(run || deferredWake);
}
async function enqueueStrandedIssueRecovery(input: {
issueId: string;
agentId: string;
reason: "issue_assignment_recovery" | "issue_continuation_needed";
retryReason: "assignment_recovery" | "issue_continuation_needed";
source: string;
retryOfRunId?: string | null;
}) {
const queued = await enqueueWakeup(input.agentId, {
source: "automation",
triggerDetail: "system",
reason: input.reason,
payload: {
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
issueId: input.issueId,
taskId: input.issueId,
wakeReason: input.reason,
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
});
if (queued && input.retryOfRunId) {
return db
.update(heartbeatRuns)
.set({
retryOfRunId: input.retryOfRunId,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, queued.id))
.returning()
.then((rows) => rows[0] ?? queued);
}
return queued;
}
async function escalateStrandedAssignedIssue(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: typeof heartbeatRuns.$inferSelect | null;
comment: string;
}) {
const updated = await issuesSvc.update(input.issue.id, {
status: "blocked",
});
if (!updated) return null;
await issuesSvc.addComment(input.issue.id, input.comment, {});
await logActivity(db, {
companyId: input.issue.companyId,
actorType: "system",
actorId: "system",
agentId: null,
runId: null,
action: "issue.updated",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
status: "blocked",
previousStatus: input.previousStatus,
source: "heartbeat.reconcile_stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
},
});
return updated;
}
async function reconcileStrandedAssignedIssues() {
const candidates = await db
.select()
.from(issues)
.where(
and(
isNull(issues.assigneeUserId),
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,
),
);
const result = {
dispatchRequeued: 0,
continuationRequeued: 0,
escalated: 0,
skipped: 0,
issueIds: [] as string[],
};
for (const issue of candidates) {
const agentId = issue.assigneeAgentId;
if (!agentId) {
result.skipped += 1;
continue;
}
const agent = await getAgent(agentId);
if (!agent || agent.companyId !== issue.companyId) {
result.skipped += 1;
continue;
}
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
result.skipped += 1;
continue;
}
if (await hasActiveExecutionPath(issue.companyId, issue.id)) {
result.skipped += 1;
continue;
}
const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
const latestContext = parseObject(latestRun?.contextSnapshot);
const latestRetryReason = readNonEmptyString(latestContext.retryReason);
if (issue.status === "todo") {
if (!latestRun || latestRun.status === "succeeded") {
result.skipped += 1;
continue;
}
if (latestRetryReason === "assignment_recovery") {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "todo",
latestRun,
comment:
"Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " +
"but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
const queued = await enqueueStrandedIssueRecovery({
issueId: issue.id,
agentId,
reason: "issue_assignment_recovery",
retryReason: "assignment_recovery",
source: "issue.assignment_recovery",
retryOfRunId: latestRun.id,
});
if (queued) {
result.dispatchRequeued += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (latestRetryReason === "issue_continuation_needed") {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
comment:
"Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " +
"execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is " +
"visible for intervention.",
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
const queued = await enqueueStrandedIssueRecovery({
issueId: issue.id,
agentId,
reason: "issue_continuation_needed",
retryReason: "issue_continuation_needed",
source: "issue.continuation_recovery",
retryOfRunId: latestRun?.id ?? issue.checkoutRunId ?? null,
});
if (queued) {
result.continuationRequeued += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
}
return result;
}
async function updateRuntimeState(
agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect,
@@ -2846,9 +3206,18 @@ export function heartbeatService(db: Db) {
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
db,
companyId: agent.companyId,
issueId,
});
const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
resolvedConfig,
runScopedMentionedSkillKeys,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
...effectiveResolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
@@ -3183,7 +3552,9 @@ export function heartbeatService(db: Db) {
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
const sanitizedChunk = compactRunLogChunk(
redactCurrentUserText(chunk, currentUserRedactionOptions),
);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@@ -3214,6 +3585,12 @@ export function heartbeatService(db: Db) {
},
});
};
if (runScopedMentionedSkillKeys.length > 0) {
await onLog(
"stdout",
`[paperclip] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`,
);
}
for (const warning of runtimeWorkspaceWarnings) {
const logEntry = formatRuntimeWorkspaceWarningLog(warning);
await onLog(logEntry.stream, logEntry.chunk);
@@ -3234,7 +3611,7 @@ export function heartbeatService(db: Db) {
issue: issueRef,
workspace: executionWorkspace,
executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
config: resolvedConfig,
config: effectiveResolvedConfig,
adapterEnv,
onLog,
});
@@ -3960,11 +4337,10 @@ export function heartbeatService(db: Db) {
return null;
}
const bypassIssueExecutionLock =
reason === "issue_comment_mentioned" ||
readNonEmptyString(enrichedContextSnapshot.wakeReason) === "issue_comment_mentioned";
if (issueId && !bypassIssueExecutionLock) {
if (issueId) {
// Mention-triggered wakes can request input from another agent, but they must
// still respect the issue execution lock so a second agent cannot start on the
// same issue workspace while the assignee already has a live run.
const agentNameKey = normalizeAgentNameKey(agent.name);
const outcome = await db.transaction(async (tx) => {
@@ -4700,6 +5076,8 @@ export function heartbeatService(db: Db) {
resumeQueuedRuns,
reconcileStrandedAssignedIssues,
tickTimers: async (now = new Date()) => {
const allAgents = await db.select().from(agents);
let checked = 0;

View File

@@ -174,6 +174,42 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
};
}
function buildStateWithCompletedStages(input: {
previous: IssueExecutionState | null;
completedStageIds: string[];
returnAssignee: IssueExecutionStagePrincipal | null;
}): IssueExecutionState {
return {
status: input.previous?.status ?? PENDING_STATUS,
currentStageId: input.previous?.currentStageId ?? null,
currentStageIndex: input.previous?.currentStageIndex ?? null,
currentStageType: input.previous?.currentStageType ?? null,
currentParticipant: input.previous?.currentParticipant ?? null,
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
};
}
function buildSkippedStageCompletedState(input: {
previous: IssueExecutionState | null;
completedStageIds: string[];
returnAssignee: IssueExecutionStagePrincipal | null;
}): IssueExecutionState {
return {
status: COMPLETED_STATUS,
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
};
}
function buildPendingState(input: {
previous: IssueExecutionState | null;
stage: IssueExecutionStage;
@@ -236,6 +272,18 @@ function clearExecutionStatePatch(input: {
}
}
function canAutoSkipPendingStage(input: {
stage: IssueExecutionStage;
returnAssignee: IssueExecutionStagePrincipal | null;
requestedStatus?: string;
}) {
if (input.requestedStatus !== "done" || input.stage.type !== "review" || !input.returnAssignee) {
return false;
}
return input.stage.participants.length > 0 &&
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
@@ -431,27 +479,61 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch };
}
const pendingStage =
let pendingStage =
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
? currentStage
: nextPendingStage(input.policy, existingState);
if (!pendingStage) return { patch };
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
const participant = selectStageParticipant(pendingStage, {
const skippedStageIds = [...(existingState?.completedStageIds ?? [])];
let participant = selectStageParticipant(pendingStage, {
preferred:
existingState?.status === CHANGES_REQUESTED_STATUS
? explicitAssignee ?? existingState.currentParticipant ?? null
: explicitAssignee,
exclude: returnAssignee,
});
while (!participant && canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })) {
skippedStageIds.push(pendingStage.id);
pendingStage = nextPendingStage(
input.policy,
buildStateWithCompletedStages({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
}),
);
if (!pendingStage) {
patch.executionState = buildSkippedStageCompletedState({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
});
return { patch };
}
participant = selectStageParticipant(pendingStage, {
preferred:
existingState?.status === CHANGES_REQUESTED_STATUS
? explicitAssignee ?? existingState.currentParticipant ?? null
: explicitAssignee,
exclude: returnAssignee,
});
}
if (!participant) {
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
buildPendingStagePatch({
patch,
previous: existingState,
previous:
skippedStageIds.length === (existingState?.completedStageIds ?? []).length
? existingState
: buildStateWithCompletedStages({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
}),
policy: input.policy,
stage: pendingStage,
participant,

View File

@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
type ViteWatcherEvent = "add" | "change" | "unlink";
export interface ViteWatcherHost {
watcher?: {
on?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
off?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
};
}
export interface CachedViteHtmlRenderer {
render(_url: string): Promise<string>;
dispose(): void;
}
const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"];
const MAIN_ENTRY_TAG = '<script type="module" src="/src/main.tsx"></script>';
const VITE_CLIENT_TAG = '<script type="module" src="/@vite/client"></script>';
const REACT_REFRESH_PREAMBLE = `<script type="module">
import { injectIntoGlobalHook } from "/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
</script>`;
function injectViteDevPreamble(html: string): string {
let injectedHtml = html;
if (!injectedHtml.includes('"/@react-refresh"') && !injectedHtml.includes("'/@react-refresh'")) {
injectedHtml = injectedHtml.includes("</head>")
? injectedHtml.replace("</head>", ` ${REACT_REFRESH_PREAMBLE}\n </head>`)
: `${REACT_REFRESH_PREAMBLE}\n${injectedHtml}`;
}
if (injectedHtml.includes(VITE_CLIENT_TAG)) return injectedHtml;
if (injectedHtml.includes(MAIN_ENTRY_TAG)) {
return injectedHtml.replace(MAIN_ENTRY_TAG, `${VITE_CLIENT_TAG}\n ${MAIN_ENTRY_TAG}`);
}
return injectedHtml.replace("</body>", ` ${VITE_CLIENT_TAG}\n </body>`);
}
export function createCachedViteHtmlRenderer(opts: {
vite: ViteWatcherHost;
uiRoot: string;
brandHtml?: (html: string) => string;
}): CachedViteHtmlRenderer {
const uiRoot = path.resolve(opts.uiRoot);
const templatePath = path.resolve(uiRoot, "index.html");
const brandHtml = opts.brandHtml ?? ((html: string) => html);
let cachedHtml: string | null = null;
function loadHtml(): string {
if (cachedHtml === null) {
const rawTemplate = fs.readFileSync(templatePath, "utf-8");
cachedHtml = injectViteDevPreamble(brandHtml(rawTemplate));
}
return cachedHtml;
}
function invalidate(): void {
cachedHtml = null;
}
function onWatchEvent(filePath: string): void {
const resolvedPath = path.resolve(filePath);
if (resolvedPath === templatePath || resolvedPath.startsWith(`${uiRoot}${path.sep}`)) {
invalidate();
}
}
for (const eventName of WATCHER_EVENTS) {
opts.vite.watcher?.on?.(eventName, onWatchEvent);
}
return {
render(): Promise<string> {
return Promise.resolve(loadHtml());
},
dispose(): void {
for (const eventName of WATCHER_EVENTS) {
opts.vite.watcher?.off?.(eventName, onWatchEvent);
}
},
};
}

View File

@@ -131,7 +131,24 @@ Done
MD
```
Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`, `blockedByIssueIds`.
Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Use the quick guide below when choosing one. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`, `blockedByIssueIds`.
### Status Quick Guide
- `backlog` — not ready to execute yet. Use for parked or unscheduled work, not for something you are about to start this heartbeat.
- `todo` — ready and actionable, but not actively checked out yet. Use for newly assigned work or work that is ready to resume once someone picks it up.
- `in_progress` — actively owned work. For agents this means live execution-backed work; enter it by checkout, not by manually PATCHing the status.
- `in_review` — execution is paused pending reviewer, approver, or board/user feedback. Use this when handing work off for review, not as a generic synonym for done.
- `blocked` — cannot proceed until something specific changes. Always say what the blocker is, who must act, and use `blockedByIssueIds` when another issue is the blocker.
- `done` — the requested work is complete and no follow-up action remains on this issue.
- `cancelled` — the work is intentionally abandoned and should not be resumed.
Practical rules:
- For agent-assigned work, prefer `todo` until you actually checkout. Do not PATCH an issue into `in_progress` just to signal intent.
- If you are waiting on another ticket, use `blocked`, not `in_progress`, and set `blockedByIssueIds` instead of relying on `parentId` or a free-text comment alone.
- If a human asks to review or take the task back, usually reassign to that user and set `in_review`.
- `parentId` is structural only. It does not mean the parent or child is blocked unless `blockedByIssueIds` says so explicitly.
**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work.

View File

@@ -665,10 +665,18 @@ backlog -> todo -> in_progress -> in_review -> done
Terminal states: `done`, `cancelled`
- `backlog` = not ready to execute yet.
- `todo` = ready to execute, but not actively checked out yet.
- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout.
- `in_review` = waiting on review or approval action, not active execution.
- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker.
- `done` = completed.
- `cancelled` = intentionally abandoned.
- `in_progress` requires an assignee (use checkout).
- `started_at` is auto-set on `in_progress`.
- `completed_at` is auto-set on `done`.
- One assignee per task at a time.
- `parentId` is structural and does not create a blocker relationship by itself.
---

View File

@@ -54,6 +54,45 @@ test.describe("Onboarding wizard", () => {
page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 30_000 });
const baseUrl = page.url().split("/").slice(0, 3).join("/");
if (SKIP_LLM) {
const companiesAfterAgentRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesAfterAgentRes.ok()).toBe(true);
const companiesAfterAgent = await companiesAfterAgentRes.json();
const companyAfterAgent = companiesAfterAgent.find(
(c: { name: string }) => c.name === COMPANY_NAME
);
expect(companyAfterAgent).toBeTruthy();
const agentsAfterCreateRes = await page.request.get(
`${baseUrl}/api/companies/${companyAfterAgent.id}/agents`
);
expect(agentsAfterCreateRes.ok()).toBe(true);
const agentsAfterCreate = await agentsAfterCreateRes.json();
const ceoAgentAfterCreate = agentsAfterCreate.find(
(a: { name: string }) => a.name === AGENT_NAME
);
expect(ceoAgentAfterCreate).toBeTruthy();
const disableWakeRes = await page.request.patch(
`${baseUrl}/api/agents/${ceoAgentAfterCreate.id}?companyId=${encodeURIComponent(companyAfterAgent.id)}`,
{
data: {
runtimeConfig: {
heartbeat: {
enabled: false,
intervalSec: 300,
wakeOnDemand: false,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
},
}
);
expect(disableWakeRes.ok()).toBe(true);
}
const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]'
);
@@ -74,8 +113,6 @@ test.describe("Onboarding wizard", () => {
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
const baseUrl = page.url().split("/").slice(0, 3).join("/");
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
@@ -128,6 +165,17 @@ test.describe("Onboarding wizard", () => {
const issue = await res.json();
expect(["in_progress", "done"]).toContain(issue.status);
}).toPass({ timeout: 120_000, intervals: [5_000] });
} else {
await expect
.poll(async () => {
const runsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/heartbeat-runs?agentId=${ceoAgent.id}`
);
expect(runsRes.ok()).toBe(true);
const runs = await runsRes.json();
return Array.isArray(runs) ? runs.length : -1;
}, { timeout: 10_000, intervals: [500, 1_000, 2_000] })
.toBe(0);
}
});
});

View File

@@ -1,9 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defineConfig } from "@playwright/test";
// Use a dedicated port so e2e tests always start their own server in local_trusted mode,
// even when the dev server is running on :3100 in authenticated mode.
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home-"));
export default defineConfig({
testDir: ".",
@@ -22,19 +26,25 @@ export default defineConfig({
use: { browserName: "chromium" },
},
],
// The webServer directive starts `paperclipai run` before tests.
// Expects `pnpm paperclipai` to be runnable from repo root.
// The webServer directive bootstraps a throwaway instance and then starts it.
// `onboard --yes --run` works in a non-interactive temp PAPERCLIP_HOME.
webServer: {
command: `pnpm paperclipai run`,
command: `pnpm paperclipai onboard --yes --run`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: !process.env.CI,
// Always boot a dedicated throwaway instance for e2e so browser tests
// never attach to the developer's active Paperclip home/server.
reuseExistingServer: false,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PORT: String(PORT),
PAPERCLIP_HOME,
PAPERCLIP_INSTANCE_ID: "playwright-e2e",
PAPERCLIP_BIND: "loopback",
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private",
},
},
outputDir: "./test-results",

View File

@@ -3,7 +3,7 @@ import type { ExecutionWorkspace } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Loader2 } from "lucide-react";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime, issueUrl } from "../lib/utils";
import { Button } from "./ui/button";
@@ -44,7 +44,7 @@ export function ExecutionWorkspaceCloseDialog({
onClosed,
}: ExecutionWorkspaceCloseDialogProps) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace";
const readinessQuery = useQuery({

View File

@@ -77,7 +77,7 @@ vi.mock("../context/CompanyContext", () => ({
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => toastState,
useToastActions: () => toastState,
}));
vi.mock("../api/issues", () => ({

View File

@@ -14,7 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
@@ -280,7 +280,7 @@ export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("todo");

View File

@@ -1,7 +1,12 @@
import { useEffect, useState } from "react";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext";
import {
useToastActions,
useToastState,
type ToastItem,
type ToastTone,
} from "../context/ToastContext";
import { cn } from "../lib/utils";
const toneClasses: Record<ToastTone, string> = {
@@ -75,7 +80,8 @@ function AnimatedToast({
}
export function ToastViewport() {
const { toasts, dismissToast } = useToast();
const toasts = useToastState();
const { dismissToast } = useToastActions();
if (toasts.length === 0) return null;

View File

@@ -3,6 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client";
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
const { useQueryMock, logMock } = vi.hoisted(() => ({
@@ -188,4 +189,40 @@ describe("useLiveRunTranscripts", () => {
});
container.remove();
});
it("stops retrying terminal runs whose persisted log never existed", async () => {
logMock.mockReset();
logMock.mockRejectedValue(new ApiError("Run log not found", 404, { error: "Run log not found" }));
function Harness() {
useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-404", status: "failed", adapterType: "codex_local" }],
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
});
expect(logMock).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
});
expect(logMock).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
container.remove();
});
});

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { ApiError } from "../../api/client";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
@@ -85,6 +86,7 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
// Tick counter to force transcript recomputation when dynamic parser loads
const [parserTick, setParserTick] = useState(0);
useEffect(() => {
@@ -160,6 +162,11 @@ export function useLiveRunTranscripts({
logOffsetByRunRef.current.delete(runId);
}
}
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
if (!knownRunIds.has(runId)) {
missingTerminalLogRunIdsRef.current.delete(runId);
}
}
}, [normalizedRuns]);
useEffect(() => {
@@ -168,6 +175,9 @@ export function useLiveRunTranscripts({
let cancelled = false;
const readRunLog = async (run: RunTranscriptSource) => {
if (missingTerminalLogRunIdsRef.current.has(run.id)) {
return;
}
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
try {
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
@@ -182,8 +192,10 @@ export function useLiveRunTranscripts({
if (result.content.length > 0) {
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
}
} catch {
// Ignore log read errors while output is initializing.
} catch (error) {
if (error instanceof ApiError && error.status === 404 && isTerminalStatus(run.status)) {
missingTerminalLogRunIdsRef.current.add(run.id);
}
} finally {
if (!cancelled) {
setHydratedRunIds((prev) => {

View File

@@ -7,7 +7,7 @@ import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToast } from "./ToastContext";
import { useToastActions } from "./ToastContext";
import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments";
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
@@ -841,7 +841,7 @@ export const __liveUpdatesTestUtils = {
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const location = useLocation();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const pathnameRef = useRef(location.pathname);

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToastProvider, useToastActions, useToastState } from "./ToastContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("ToastContext", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("does not rerender action-only consumers when toast state changes", () => {
const root = createRoot(container);
let actionOnlyRenderCount = 0;
let pushToastRef: ((input: { title: string }) => string | null) | null = null;
let clearToastsRef: (() => void) | null = null;
function ActionOnlyConsumer() {
actionOnlyRenderCount += 1;
const { pushToast, clearToasts } = useToastActions();
pushToastRef = pushToast;
clearToastsRef = clearToasts;
return null;
}
function ToastCount() {
const toasts = useToastState();
return <div data-testid="toast-count">{String(toasts.length)}</div>;
}
act(() => {
root.render(
<ToastProvider>
<ActionOnlyConsumer />
<ToastCount />
</ToastProvider>,
);
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0");
act(() => {
pushToastRef?.({ title: "Saved" });
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("1");
act(() => {
clearToastsRef?.();
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0");
act(() => {
root.unmount();
});
});
});

View File

@@ -36,13 +36,16 @@ export interface ToastItem {
createdAt: number;
}
interface ToastContextValue {
toasts: ToastItem[];
interface ToastActionsContextValue {
pushToast: (input: ToastInput) => string | null;
dismissToast: (id: string) => void;
clearToasts: () => void;
}
interface ToastContextValue extends ToastActionsContextValue {
toasts: ToastItem[];
}
const DEFAULT_TTL_BY_TONE: Record<ToastTone, number> = {
info: 4000,
success: 3500,
@@ -55,7 +58,8 @@ const MAX_TOASTS = 5;
const DEDUPE_WINDOW_MS = 3500;
const DEDUPE_MAX_AGE_MS = 20000;
const ToastContext = createContext<ToastContextValue | null>(null);
const ToastStateContext = createContext<ToastItem[] | null>(null);
const ToastActionsContext = createContext<ToastActionsContextValue | null>(null);
function normalizeTtl(value: number | undefined, tone: ToastTone) {
const fallback = DEFAULT_TTL_BY_TONE[tone];
@@ -150,23 +154,40 @@ export function ToastProvider({ children }: { children: ReactNode }) {
timersRef.current.clear();
}, []);
const value = useMemo<ToastContextValue>(
const actions = useMemo<ToastActionsContextValue>(
() => ({
toasts,
pushToast,
dismissToast,
clearToasts,
}),
[toasts, pushToast, dismissToast, clearToasts],
[pushToast, dismissToast, clearToasts],
);
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
return (
<ToastActionsContext.Provider value={actions}>
<ToastStateContext.Provider value={toasts}>{children}</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
export function useToastState() {
const context = useContext(ToastStateContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
throw new Error("useToastState must be used within a ToastProvider");
}
return context;
}
export function useToastActions() {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error("useToastActions must be used within a ToastProvider");
}
return context;
}
export function useToast() {
const toasts = useToastState();
const actions = useToastActions();
return useMemo<ToastContextValue>(() => ({ toasts, ...actions }), [toasts, actions]);
}

View File

@@ -20,6 +20,7 @@ import {
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200;
export function useDismissedInboxAlerts() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
@@ -181,8 +182,8 @@ export function useInboxBadge(companyId: string | null | undefined) {
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const { data: heartbeatRuns = [] } = useQuery({
queryKey: queryKeys.heartbeats(companyId!),
queryFn: () => heartbeatsApi.list(companyId!),
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT),
enabled: !!companyId,
});

View File

@@ -27,7 +27,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { useToastActions } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
@@ -255,7 +255,7 @@ export function AdapterManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");

View File

@@ -18,7 +18,7 @@ import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@@ -1540,7 +1540,7 @@ function ConfigurationTab({
hideInstructionsFile?: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const lastAgentRef = useRef(agent);

View File

@@ -81,8 +81,8 @@ export function Agents() {
});
const { data: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"],
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
});

View File

@@ -11,7 +11,7 @@ import type {
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
@@ -580,7 +580,7 @@ function expandAncestors(filePath: string): string[] {
export function CompanyExport() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const navigate = useNavigate();
const location = useLocation();
const { data: session, isFetched: isSessionFetched } = useQuery({

View File

@@ -9,7 +9,7 @@ import type {
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
@@ -651,7 +651,7 @@ export function CompanyImport() {
setSelectedCompanyId,
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const packageInputRef = useRef<HTMLInputElement | null>(null);
const { data: session } = useQuery({

View File

@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
@@ -34,7 +34,7 @@ export function CompanySettings() {
setSelectedCompanyId
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
// General settings local state
const [companyName, setCompanyName] = useState("");

View File

@@ -14,7 +14,7 @@ import type {
import { companySkillsApi } from "../api/companySkills";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -530,7 +530,7 @@ function SkillPane({
onSave: () => void;
savePending: boolean;
}) {
const { pushToast } = useToast();
const { pushToast } = useToastActions();
if (!detail) {
if (loading) {
@@ -759,7 +759,7 @@ export function CompanySkills() {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [createOpen, setCreateOpen] = useState(false);

View File

@@ -26,6 +26,8 @@ import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
const DASHBOARD_HEARTBEAT_RUN_LIMIT = 100;
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
@@ -75,8 +77,8 @@ export function Dashboard() {
});
const { data: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", DASHBOARD_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, DASHBOARD_HEARTBEAT_RUN_LIMIT),
enabled: !!selectedCompanyId,
});

View File

@@ -87,6 +87,8 @@ import {
Search,
ListTree,
} from "lucide-react";
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
import { Input } from "@/components/ui/input";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
@@ -788,8 +790,8 @@ export function Inbox() {
});
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
enabled: !!selectedCompanyId,
});

View File

@@ -13,9 +13,9 @@ import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
@@ -853,7 +853,7 @@ export function IssueDetail() {
const navigate = useNavigate();
const navigationType = useNavigationType();
const location = useLocation();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const { isMobile } = useSidebar();
const [moreOpen, setMoreOpen] = useState(false);
const [copied, setCopied] = useState(false);

Some files were not shown because too many files have changed in this diff Show More