diff --git a/.agents/skills/check-pr/SKILL.md b/.agents/skills/check-pr/SKILL.md index 88d52c10f0f..b02e2c941fa 100644 --- a/.agents/skills/check-pr/SKILL.md +++ b/.agents/skills/check-pr/SKILL.md @@ -1,11 +1,9 @@ --- name: check-pr description: > - Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist) - for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for - pending checks to complete, categorizes issues as actionable or informational, and optionally fixes - and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare - a change for submission. + Check a GitHub, GitLab, or Perforce PR/MR/CL for review comments, failing + checks, and PR-body gaps. Use when asked to inspect, fix, or prepare a change + for submission. license: MIT compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated. metadata: diff --git a/.agents/skills/company-creator/SKILL.md b/.agents/skills/company-creator/SKILL.md index b2f4432bcdb..afe4be9b621 100644 --- a/.agents/skills/company-creator/SKILL.md +++ b/.agents/skills/company-creator/SKILL.md @@ -1,15 +1,9 @@ --- name: company-creator description: > - Create agent company packages conforming to the Agent Companies specification - (agentcompanies/v1). Use when a user wants to create a new agent company from - scratch, build a company around an existing git repo or skills collection, or - scaffold a team/department of agents. Triggers on: "create a company", "make me - a company", "build a company from this repo", "set up an agent company", - "create a team of agents", "hire some agents", or when given a repo URL and - asked to turn it into a company. Do NOT use for importing an existing company - package (use the CLI import command instead) or for modifying a company that - is already running in Paperclip. + Create agent company packages that conform to agentcompanies/v1. Use when asked + to create a company, scaffold an agent team, hire agents, or turn a repo/skills + collection into a company package. --- # Company Creator diff --git a/.agents/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md index a41318580c8..6d11362b099 100644 --- a/.agents/skills/create-agent-adapter/SKILL.md +++ b/.agents/skills/create-agent-adapter/SKILL.md @@ -1,11 +1,9 @@ --- name: create-agent-adapter description: > - Technical guide for creating a new Paperclip agent adapter. Use when building - a new adapter package, adding support for a new AI coding tool (e.g. a new - CLI agent, API-based agent, or custom process), or when modifying the adapter - system. Covers the required interfaces, module structure, registration points, - and conventions derived from the existing claude-local and codex-local adapters. + Create or modify Paperclip agent adapters across server, UI, and CLI surfaces. + Use when adding support for a new CLI agent, API agent, custom process, or + adapter package. --- # Creating a Paperclip Agent Adapter diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md index 9a3bdde25ca..9cebfacc95b 100644 --- a/.agents/skills/create-issue-interaction-ui/SKILL.md +++ b/.agents/skills/create-issue-interaction-ui/SKILL.md @@ -1,18 +1,14 @@ --- name: create-issue-interaction-ui description: > - Developer/maintainer skill for adding a new issue-thread interaction kind to - the Paperclip codebase end-to-end: shared contract, server service/routes, - UI card, fixtures/Storybook, CLI/MCP/plugin SDK helpers, agent guidance, and - tests. Use when a Paperclip contributor is asked to introduce a new - interaction family (something analogous to `request_confirmation`, - `request_checkbox_confirmation`, `ask_user_questions`, or `suggest_tasks`) - or to extend the issue-thread interaction system with a new card type. Do - NOT install this on production Paperclip agents — it is for repo work, not - agent runtime behavior. + Add a new Paperclip issue-thread interaction kind end-to-end. Use when repo + work introduces or extends interaction cards like request_confirmation, + checkbox confirmations, ask_user_questions, or suggest_tasks. --- -# Create a new issue-thread interaction UI (developer skill) +# Create a new issue-thread interaction UI (Developer/maintainer skill) + +Developer/maintainer skill. Do NOT install this on production Paperclip agents. This skill walks a Paperclip contributor through introducing a new issue-thread interaction kind from shared contract to issue-detail wiring, helpers, and @@ -20,6 +16,8 @@ docs. It is intentionally a developer/maintainer skill: the audience is a human or coding agent making code changes inside `paperclipai/paperclip`, not the operational agents that run inside a deployed Paperclip company. +Do NOT install this on production Paperclip agents. This guide is for repository contributors changing Paperclip itself. + ## When to use - A new interaction kind is being introduced (compact picker, structured diff --git a/.agents/skills/deal-with-security-advisory/SKILL.md b/.agents/skills/deal-with-security-advisory/SKILL.md index 0ba122366ec..8ddccd52cc8 100644 --- a/.agents/skills/deal-with-security-advisory/SKILL.md +++ b/.agents/skills/deal-with-security-advisory/SKILL.md @@ -1,10 +1,9 @@ --- name: deal-with-security-advisory description: > - Handle a GitHub Security Advisory response for Paperclip, including - confidential fix development in a temporary private fork, human coordination - on advisory-thread comments, CVE request, synchronized advisory publication, - and immediate security release steps. + Handle confidential GitHub Security Advisory response for Paperclip. Use when + coordinating advisory triage, private-fork fixes, CVE/publication steps, and + immediate security releases. --- # Security Vulnerability Response Instructions diff --git a/.agents/skills/diagnose-why-work-stopped/SKILL.md b/.agents/skills/diagnose-why-work-stopped/SKILL.md index 001738731b4..91caa62fa8b 100644 --- a/.agents/skills/diagnose-why-work-stopped/SKILL.md +++ b/.agents/skills/diagnose-why-work-stopped/SKILL.md @@ -1,13 +1,9 @@ --- name: diagnose-why-work-stopped description: > - How to handle "why did this work stop / why is this looping?" assignments. - Forensics first on the named tree, surface the exact stop-point, frame the - fix as a general product rule that respects three invariants (productive - work continues, only real blockers stop work, no infinite loops), and - deliver a plan — no code changes — gated by board/CTO approval before - child issues are created. Use whenever the issue title or body asks for - forensics on a stalled, looping, or "went too deep" tree. + Diagnose stalled, looping, or over-recovered Paperclip issue trees and propose + a no-code product-rule plan. Use when asked why work stopped, why it looped, or + how to prevent a tree from going too deep. --- # Diagnose Why Work Stopped diff --git a/.agents/skills/doc-maintenance/SKILL.md b/.agents/skills/doc-maintenance/SKILL.md index a597e90ccbc..839786bc826 100644 --- a/.agents/skills/doc-maintenance/SKILL.md +++ b/.agents/skills/doc-maintenance/SKILL.md @@ -1,11 +1,9 @@ --- name: doc-maintenance description: > - Audit top-level documentation (README, SPEC, PRODUCT) against recent git - history to find drift — shipped features missing from docs or features - listed as upcoming that already landed. Proposes minimal edits, creates - a branch, and opens a PR. Use when asked to review docs for accuracy, - after major feature merges, or on a periodic schedule. + Audit README, SPEC, and PRODUCT docs against recent git history for drift and + make minimal PR-ready edits. Use when asked to review docs for accuracy, after + major feature merges, or on a schedule. --- # Doc Maintenance Skill diff --git a/.agents/skills/paperclip-create-plugin/SKILL.md b/.agents/skills/paperclip-create-plugin/SKILL.md index 4dda697d64f..7e660eadd6a 100644 --- a/.agents/skills/paperclip-create-plugin/SKILL.md +++ b/.agents/skills/paperclip-create-plugin/SKILL.md @@ -1,11 +1,9 @@ --- name: paperclip-create-plugin description: > - Create and develop external Paperclip plugins with the CLI-first workflow. - Use when scaffolding a new plugin, working on a local plugin against a running - Paperclip instance, or updating plugin authoring docs. Covers `paperclipai - plugin init`, the local install loop via `paperclipai plugin install `, - worker/UI rebuild and reload semantics, and the required success checklist. + Create and develop external Paperclip plugins with the CLI-first workflow. Use + when scaffolding a plugin, iterating on a local plugin, installing it into + Paperclip, or updating plugin authoring docs. --- # Create and develop a Paperclip plugin diff --git a/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md b/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md index 1fae60e3071..9b41eed6a1c 100644 --- a/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md +++ b/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md @@ -1,12 +1,9 @@ --- name: paperclip-dev-workspace-run-verify-fix description: > - Run, verify, reseed, and repair a Paperclip isolated dev workspace service. - Use when asked to start or fix a Paperclip project/worktree service and prove - that it is managed by the Paperclip runtime, has the full bootstrapped cloned - database, is healthy, accepts normal dev credentials, exposes populated app - data, and is visible as running from both the control-plane and the served - workspace app. + Run, verify, reseed, and repair Paperclip isolated dev workspace services. Use + when asked to start or fix a managed project/worktree service and prove health, + login readiness, cloned data, and runtime visibility. --- # Paperclip Dev Workspace Run / Verify / Fix diff --git a/.agents/skills/paperclip-page/SKILL.md b/.agents/skills/paperclip-page/SKILL.md index cf2852466d3..e9e74cfe085 100644 --- a/.agents/skills/paperclip-page/SKILL.md +++ b/.agents/skills/paperclip-page/SKILL.md @@ -1,10 +1,9 @@ --- name: paperclip-page description: > - Publish static HTML pages and asset folders to the Paperclip-approved S3 and - CloudFront website host. Use when asked to deploy, publish, host, or share a - persistent Paperclip page, wireframe viewer, prototype, report, or other static - site without using here.now. + Publish static HTML pages and asset folders to the Paperclip S3/CloudFront page + host. Use when asked to deploy, host, or share a persistent page, viewer, + prototype, report, or static site without here.now. --- # Paperclip Page diff --git a/.agents/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md index 5064b67c090..3ebe590ee76 100644 --- a/.agents/skills/pr-report/SKILL.md +++ b/.agents/skills/pr-report/SKILL.md @@ -1,10 +1,9 @@ --- name: pr-report description: > - Review a pull request or contribution deeply, explain it tutorial-style for a - maintainer, and produce a polished report artifact such as HTML or Markdown. - Use when asked to analyze a PR, explain a contributor's design decisions, - compare it with similar systems, or prepare a merge recommendation. + Produce a maintainer-grade PR or contribution report as HTML or Markdown. Use + when asked to deeply review a PR, explain a design, compare systems, or prepare + a merge recommendation. --- # PR Report Skill diff --git a/.agents/skills/prcheckloop/SKILL.md b/.agents/skills/prcheckloop/SKILL.md index 70d9e19af66..32dc5d5b442 100644 --- a/.agents/skills/prcheckloop/SKILL.md +++ b/.agents/skills/prcheckloop/SKILL.md @@ -1,12 +1,9 @@ --- name: prcheckloop description: > - Iteratively gets a GitHub pull request's checks green. Detects the PR for the - current branch or uses a provided PR number, waits for every check on the - latest head SHA to appear and finish, investigates failing checks, fixes - actionable code or test issues, pushes, and repeats. Escalates with a precise - blocker when failures are external, flaky, or not safely fixable. Use when a - PR still has unsuccessful checks after review fixes, including after greploop. + Iterate on a GitHub PR until latest-head checks are green or a precise blocker + is named. Use when a PR still has failing or pending checks after review fixes, + including after greploop. --- # PRCheckloop diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md index 374f3a089d8..367ee8474d2 100644 --- a/.agents/skills/release-changelog-discord-message/SKILL.md +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -1,11 +1,9 @@ --- name: release-changelog-discord-message description: > - Write the Discord release announcement for a stable Paperclip release. Companion - to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`; - this one turns that file into a single copy-pasteable Discord post in dotta's - voice and attaches it as the `discord_announcement` document on the release - issue. + Write the Discord announcement for a stable Paperclip release from the release + changelog. Use when a release issue needs a copy-pasteable dotta-voice Discord + post or refreshed discord_announcement document. --- # Release Discord Announcement Skill diff --git a/.agents/skills/terminal-bench-loop/SKILL.md b/.agents/skills/terminal-bench-loop/SKILL.md index 6a3c8d21159..f106f77450f 100644 --- a/.agents/skills/terminal-bench-loop/SKILL.md +++ b/.agents/skills/terminal-bench-loop/SKILL.md @@ -1,17 +1,9 @@ --- name: terminal-bench-loop description: > - Run a single Terminal-Bench problem through Paperclip in a bounded, - human-in-the-loop improvement cycle until the smoke passes, the board - rejects the next fix, the iteration budget is exhausted, or a real - blocker is named. Each iteration runs a bounded smoke against an - isolated Paperclip App worktree, captures artifacts, diagnoses the - exact stop point with `/diagnose-why-work-stopped`, requests board - confirmation before any product fix, then reruns against the same - worktree. Use whenever an issue asks to "run Terminal-Bench in a - loop", "drive Terminal-Bench until it passes", "loop fix-git through - Paperclip", or otherwise points at a Terminal-Bench task and asks for - bounded iteration with diagnosis. + Run one Terminal-Bench task through a bounded Paperclip smoke/diagnosis/fix + loop. Use when asked to drive Terminal-Bench until it passes, rerun a + Terminal-Bench loop, or iterate with board-gated fixes and diagnosis. --- # Terminal-Bench Loop diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f39042aa31a..295195409fe 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -491,6 +491,10 @@ V1 non-terminal liveness rule: - agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path - `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action - a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting +- external waits are durable only when persisted as a bounded monitor/scheduled wake, a first-class blocker with a named owner and action, or healthy delegated child work connected by a blocker edge when the source must wait; parent/child structure alone is not a wait path +- unmanaged shell jobs, detached sessions, adapter child processes, local polling loops, PIDs, logs, and comments are evidence rather than liveness; a managed runtime service counts only when paired with a persisted monitor, wake, blocker, or delegated issue that owns the next check +- heartbeat finalization evaluates liveness from persisted Paperclip state; an issue cannot remain healthy `in_progress` solely because the exiting heartbeat started a local/background watcher +- invalid external-wait recovery queues at most one normal-model continuation per source-state fingerprint, then requires a real blocker or explicit recovery action instead of repeating equivalent recovery wakes; new durable source activity may establish a new fingerprint - when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work - explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 1fc4fcac366..ca0eeeb8069 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -258,6 +258,29 @@ The valid action-path primitives are: - a first-class blocker chain whose unresolved leaf issues are themselves healthy - an open explicit recovery action that names the owner and action needed to restore liveness +### Durable external waits and heartbeat finalization + +An external wait counts as a live or waiting path only when the next move survives the current heartbeat and is represented in Paperclip's durable control-plane state. Valid external-wait shapes are: + +- a one-shot issue monitor or other persisted scheduled wake that names the responsible assignee, next check time, and bounded timeout/attempt policy +- a first-class blocker or `blocked` disposition that names the external owner and concrete action required to unblock the issue +- a delegated child issue with a responsible owner and its own healthy action path, plus a blocker edge when the source issue must wait for that child; `parentId` alone is not a dependency + +An unmanaged local process is not a durable action path. Shell jobs started with `&`, `nohup`, local polling loops, detached PTY sessions, adapter child processes, or similar background watchers do not keep an issue live unless Paperclip persists them as a run or pairs a managed runtime service with a monitor, scheduled wake, blocker, or delegated issue that owns the next check. A PID, session id, log file, comment, or promise to check later is evidence only. The process may be killed when the adapter invocation or heartbeat exits and cannot be assumed observable or recoverable by another worker. + +Before a heartbeat finalizes, its issue disposition must therefore be evaluated from durable Paperclip state, not from processes still visible only to that heartbeat. An agent-owned issue may remain `in_progress` after the heartbeat only when another valid action-path primitive already exists. If the only claimed continuation is a local/background watcher, finalization treats the issue as having no live path even when the process has not yet been observed exiting. + +If useful deliverable work can continue without the external result, the agent should continue that work or delegate it rather than parking the issue. Use `blocked` only for a real dependency that prevents productive progress. Use a monitor when the assignee owns a bounded future check, and use delegated child work when another owner can make progress independently. + +Recovery from an invalid external wait is bounded and idempotent: + +1. Record bounded evidence that the completed heartbeat left no durable action path, including the terminal run and any reported local watcher metadata without treating that metadata as liveness. +2. Queue at most one normal-model continuation for the same source state and recovery fingerprint so the assignee can inspect the external result, replace the watcher with a durable wait, continue productive work, or choose a valid disposition. +3. If that continuation also exits without creating a durable path, do not queue another equivalent continuation. Move the issue to `blocked` only when a real external dependency can be named; otherwise open or update an explicit recovery action with a named owner and concrete repair/escalation action. +4. New durable source activity may produce a new recovery fingerprint, but unchanged killed/local-watcher evidence must not create an infinite wake/recovery loop. + +This rule is intentionally conservative: local watcher evidence can help the recovery owner decide what happened, but only persisted control-plane state can prove that the work will move again. + ### Comment and document activity wake sources Issue-thread comments and document-scoped comments have different wake semantics. @@ -383,7 +406,7 @@ A healthy active-work state means at least one of these is true: - there is an active one-shot monitor that will wake the assignee for a future check - there is an open explicit recovery action for the lost execution path -An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract. +An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, no persisted monitor, and no explicit recovery surface. An unmanaged local/background watcher does not satisfy any of those conditions. A Paperclip-tracked run that is still running but silent is not automatically stalled; it is handled by the active-run watchdog contract. ### `in_review` @@ -478,6 +501,8 @@ Recovery rule: This is an active-work continuity recovery. +The same bounded rule applies when the previous heartbeat reported waiting on a local/background watcher and that watcher was killed, disappeared, or was never represented by a durable Paperclip primitive. Paperclip queues at most one continuation for the same recovery fingerprint. If the continuation also leaves only local watcher evidence, Paperclip must surface a real blocker or explicit recovery action instead of repeating continuation recovery. A new monitor, scheduled wake, healthy delegated blocker issue, or other durable source mutation resolves that recovery fingerprint normally. + #### Deliberate wait is not a lost run A continuation that the staleness gate cancelled with `issue_continuation_waiting_on_review` is a *deliberate park*, not a disappeared execution path. The latest run reported that the issue is waiting for review/approval (for example, an umbrella issue whose work was just decomposed into sub-tasks). Treating that park as a stranded run would retry it, then escalate it to `blocked` with a recovery action and an operator-facing failure notice — even though nothing failed and there is nothing for a human to do. diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1176421ab8c..ec88d74bd8c 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -13,6 +13,7 @@ import { parseGeminiVersionParts, rewriteGeminiAcpFlagForVersion, } from "./execute.js"; +import { runChildProcess } from "../server-utils.js"; const execFileAsync = promisify(execFile); @@ -50,6 +51,44 @@ async function createSkill(root: string, name: string, body = `---\nrequired: fa }; } +function createLocalSandboxRunner( + onExecute?: (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + }) => void, +) { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + counter += 1; + onExecute?.(input); + const command = input.command === "bash" ? "/bin/bash" : input.command; + return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + onSpawn: input.onSpawn + ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) + : undefined, + }); + }, + }; +} + function buildRuntime() { return { ensureSession: async () => ({ @@ -159,6 +198,61 @@ describe("shared ACPX engine runtime behavior", () => { expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); }); + it("emits ACP text deltas as stdout transcript records", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { + type: "text_delta", + text: "streamed hello", + stream: "output", + tag: "agent_message_chunk", + }; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-streaming-text-delta", + agent: { + id: "agent-1", + companyId: "company-1", + }, + runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + expect(logs).toContainEqual({ + stream: "stdout", + text: `${JSON.stringify({ + type: "acpx.text_delta", + text: "streamed hello", + channel: "output", + tag: "agent_message_chunk", + })}\n`, + }); + }); + it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => { const root = await makeTempRoot(); const skillRoot = path.join(root, "skills"); @@ -559,6 +653,57 @@ describe("shared ACPX engine runtime behavior", () => { expect(wrapper).toContain("exec node ./fake-acp.js"); }); + it("starts sandbox ACP process sessions in the remote execution cwd", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + + let sessionPayload: Record | null = null; + const runner = createLocalSandboxRunner( + (input: { args?: string[]; env?: Record }) => { + if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") { + const script = input.args?.[1] ?? ""; + const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); + if (match) { + sessionPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record; + } + } + }, + ); + + await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, + { + authToken: "real-run-jwt", + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner, + }, + }, + ); + + expect(sessionPayload).toMatchObject({ + command: "sh", + args: ["-lc", "exec node ./fake-acp.js"], + cwd: remoteCwd, + }); + const payloadEnv = ((sessionPayload as Record | null)?.env ?? {}) as Record; + expect(payloadEnv).toMatchObject({ + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }); + expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch( + /^http:\/\/127\.0\.0\.1:\d+$/, + ); + expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy(); + expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); + }); + it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 41c5ea02e65..078f49fbe3d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -12,6 +12,10 @@ import { formatAdapterExecutionTimeoutStartLogLine, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, + startAdapterExecutionTargetPaperclipBridge, + startAdapterExecutionTargetProcessSessionBridge, + type AdapterExecutionTargetPaperclipBridgeHandle, + type AdapterExecutionTargetProcessSessionBridgeHandle, type AdapterExecutionTargetTimeoutResolution, } from "@paperclipai/adapter-utils/execution-target"; import { @@ -112,6 +116,8 @@ interface AcpxPreparedRuntime { fingerprint: string; agentCommand: string | null; agentRegistry: AcpAgentRegistry; + processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null; + paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null; remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; @@ -180,12 +186,20 @@ interface BuiltInAgentCommand { shellCommand: string; } -async function resolveBuiltInAgentCommand(agent: string, packageRootDir: string): Promise { +async function resolveBuiltInAgentCommand(input: { + agent: string; + packageRootDir: string; + executionTargetIsRemote: boolean; +}): Promise { + const { agent, packageRootDir, executionTargetIsRemote } = input; if (agent === "gemini") { return { command: "gemini --acp", shellCommand: "gemini --acp" }; } const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null; if (!binName) return null; + if (executionTargetIsRemote) { + return { command: binName, shellCommand: binName }; + } const resolved = (await findAncestorBin(packageRootDir, binName)) ?? binName; return { command: resolved, shellCommand: shellQuote(resolved) }; } @@ -1062,7 +1076,11 @@ async function buildRuntime(input: { } const configuredCommand = asString(config.agentCommand, "").trim(); - const builtInCommand = await resolveBuiltInAgentCommand(acpxAgent, input.engine.packageRootDir); + const builtInCommand = await resolveBuiltInAgentCommand({ + agent: acpxAgent, + packageRootDir: input.engine.packageRootDir, + executionTargetIsRemote, + }); let agentCommand = configuredCommand || builtInCommand?.command || null; let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || ""; if (acpxAgent === "gemini" && agentCommandShell) { @@ -1097,7 +1115,58 @@ async function buildRuntime(input: { }) : null; const wrapperPath = wrapper?.wrapperPath ?? null; - const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined; + let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null; + if ( + executionTarget?.kind === "remote" && + executionTarget.transport === "sandbox" && + Boolean(executionTarget.runner) && + agentCommandShell + ) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: { ...executionTarget, streamRunLogs: false }, + runtimeRootDir: null, + adapterKey: input.engine.adapterType, + timeoutSec, + hostApiToken: env.PAPERCLIP_API_KEY, + onLog: input.ctx.onLog, + }); + if (paperclipBridge) { + Object.assign(env, paperclipBridge.env); + await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n"); + } + } + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null; + try { + processSessionBridge = + executionTarget?.kind === "remote" && + executionTarget.transport === "sandbox" && + Boolean(executionTarget.runner) && + agentCommandShell + ? await startAdapterExecutionTargetProcessSessionBridge({ + runId, + target: executionTarget, + runtimeRootDir: null, + adapterKey: input.engine.adapterType, + command: "sh", + args: ["-lc", `exec ${agentCommandShell}`], + cwd: effectiveExecutionCwd, + env: runtimeEnv, + timeoutSec, + onLog: input.ctx.onLog, + }) + : null; + } catch (err) { + await paperclipBridge?.stop().catch(() => {}); + throw err; + } + const overrideCommand = processSessionBridge?.agentCommand ?? wrapperPath; + const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined; const agentRegistry = createAgentRegistry({ overrides }); const fingerprint = shortHash({ acpxAgent, @@ -1123,7 +1192,6 @@ async function buildRuntime(input: { }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const loggedEnv = buildInvocationEnvForLogs(env, { runtimeEnv, includeRuntimeKeys: ["HOME"], @@ -1151,6 +1219,8 @@ async function buildRuntime(input: { fingerprint, agentCommand, agentRegistry, + processSessionBridge, + paperclipBridge, remoteExecutionIdentity, skillPromptInstructions, skillsIdentity: { @@ -1229,6 +1299,13 @@ async function applySessionConfigOptions(input: { return notes; } +async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise { + await Promise.allSettled([ + prepared.processSessionBridge?.stop(), + prepared.paperclipBridge?.stop(), + ]); +} + function renderPaperclipEnvNote(env: Record): string { const paperclipKeys = Object.keys(env) .filter((key) => key.startsWith("PAPERCLIP_")) @@ -1718,6 +1795,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { err, phase: "ensure_session", }); + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: null, @@ -1733,6 +1811,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { } if (!handle) { + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: null, @@ -1848,7 +1927,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { discardPersistentState: terminal.status === "cancelled" || timedOut, }).catch(() => {}); } - } else if (prepared.mode === "persistent" && warmIdleMs > 0) { + } else if (prepared.mode === "persistent" && warmIdleMs > 0 && !prepared.processSessionBridge) { const existing = warmHandles.get(prepared.sessionKey); if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) { await runtime.close({ @@ -1900,6 +1979,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { stopReason: terminalStopReason, message: errorMessage, }); + await cleanupRemoteBridges(prepared); return { exitCode: terminal.status === "completed" ? 0 : 1, signal: timedOut ? "SIGTERM" : null, @@ -1951,6 +2031,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { phase: "turn", messageOverride, }); + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: timedOut ? "SIGTERM" : null, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index a28ca2d1501..657a506557a 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,7 +1,10 @@ import { createServer } from "node:http"; -import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import net from "node:net"; +import { execFile, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -16,6 +19,7 @@ import { resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, + startAdapterExecutionTargetProcessSessionBridge, startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, } from "./execution-target.js"; @@ -23,6 +27,8 @@ import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; import { runChildProcess } from "./server-utils.js"; import { shellQuote } from "./ssh.js"; +const execFileAsync = promisify(execFile); + describe("sandbox adapter execution targets", () => { const cleanupDirs: string[] = []; @@ -90,8 +96,8 @@ describe("sandbox adapter execution targets", () => { ].join("\n"); } - async function waitForCondition(predicate: () => boolean, message: string): Promise { - const deadline = Date.now() + 1000; + async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); @@ -99,6 +105,36 @@ describe("sandbox adapter execution targets", () => { throw new Error(message); } + async function runProxyWithInput(command: string, input: string): Promise<{ stdout: string; stderr: string; code: number | null }> { + const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdin.end(input); + const code = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Timed out waiting for process session proxy.")); + }, 5000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("exit", (exitCode) => { + clearTimeout(timeout); + resolve(exitCode); + }); + }); + return { stdout, stderr, code }; + } + function combinedStream( events: Array<{ stream: "stdout" | "stderr"; chunk: string }>, stream: "stdout" | "stderr", @@ -158,6 +194,301 @@ describe("sandbox adapter execution targets", () => { }); }); + it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "fake-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdin.on('data', (chunk) => {", + " process.stdout.write('out:' + chunk.toString());", + " process.stderr.write('err:' + chunk.toString());", + "});", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); + expect(result.code).toBe(0); + expect(result.stdout).toBe("out:hello\n"); + expect(result.stderr).toBe("err:hello\n"); + } finally { + await bridge?.stop(); + } + }); + + it("buffers sandbox process session output until the local proxy connects", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "fast-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdout.write('early-out\\n');", + "process.stderr.write('early-err\\n');", + "setTimeout(() => process.exit(0), 20);", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-buffer", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("early-out\n"); + expect(result.stderr).toBe("early-err\n"); + } finally { + await bridge?.stop(); + } + }); + + it("delivers full output when the sandbox child exits immediately after writing", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "instant-exit-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdout.write('final-out\\n');", + "process.stderr.write('final-err\\n');", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-fast-exit", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("final-out\n"); + expect(result.stderr).toBe("final-err\n"); + } finally { + await bridge?.stop(); + } + }); + + it("ignores unauthenticated connections to the process session bridge", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "guarded-acp-child.mjs"); + await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8"); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-auth", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + let squatter: net.Socket | null = null; + try { + const proxySource = await readFile(bridge!.agentCommand, "utf8"); + const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN); + expect(Number.isFinite(port)).toBe(true); + + // An idle local connection must not claim the session or see buffered output. + const squatterSocket = net.createConnection({ host: "127.0.0.1", port }); + squatter = squatterSocket; + let squatterReceived = ""; + squatterSocket.setEncoding("utf8"); + squatterSocket.on("data", (chunk: string) => { + squatterReceived += chunk; + }); + squatterSocket.on("error", () => undefined); + await new Promise((resolve, reject) => { + squatterSocket.once("connect", () => resolve()); + squatterSocket.once("error", reject); + }); + + // A peer presenting the wrong token is disconnected outright. + const badPeer = net.createConnection({ host: "127.0.0.1", port }); + badPeer.on("error", () => undefined); + const badPeerClosed = new Promise((resolve) => badPeer.once("close", () => resolve())); + badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`)); + await badPeerClosed; + + // The authenticated proxy still attaches and receives the buffered output. + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("guarded-out\n"); + expect(squatterReceived).toBe(""); + } finally { + squatter?.destroy(); + await bridge?.stop(); + } + }); + + it("streams sandbox process session output before the remote child exits", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "streaming-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', (chunk) => {", + " if (chunk.includes('ping')) {", + " process.stdout.write('delta:ping\\n');", + " process.stderr.write('trace:ping\\n');", + " }", + " if (chunk.includes('finish')) process.exit(0);", + "});", + "process.stdin.resume();", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-stream", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let exited = false; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const exitPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Timed out waiting for streaming process session proxy.")); + }, 5000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("exit", (exitCode) => { + exited = true; + clearTimeout(timeout); + resolve(exitCode); + }); + }); + + try { + child.stdin.write("ping\n"); + await waitForCondition( + () => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"), + "Timed out waiting for live process session output.", + 3000, + ); + expect(exited).toBe(false); + + child.stdin.end("finish\n"); + await expect(exitPromise).resolves.toBe(0); + } finally { + if (!exited) { + child.kill("SIGKILL"); + await exitPromise.catch(() => undefined); + } + await bridge?.stop(); + } + }); + it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => { const sandboxTarget: AdapterSandboxExecutionTarget = { kind: "remote", diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 948b44501f6..e264ab7698e 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { prepareCommandManagedRuntime, @@ -125,6 +129,11 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle { stop(): Promise; } +export interface AdapterExecutionTargetProcessSessionBridgeHandle { + agentCommand: string; + stop(): Promise; +} + export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; // 4-hour wall-clock backstop for sandbox-backed adapter runs. This is a @@ -1200,6 +1209,412 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n return Buffer.concat(chunks, totalBytes).toString("utf8"); } +const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs"; +const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs"; +const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000; + +function jsonLine(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +function splitJsonLines(buffer: string): { lines: string[]; rest: string } { + const parts = buffer.split(/\n/); + return { lines: parts.slice(0, -1), rest: parts.at(-1) ?? "" }; +} + +async function writeProcessSessionProxyScript(dir: string, port: number, token: string): Promise { + await fs.mkdir(dir, { recursive: true }); + const proxyPath = path.join(dir, PROCESS_SESSION_PROXY_SCRIPT); + await fs.writeFile(proxyPath, getProcessSessionProxySource({ port, token }), { mode: 0o700 }); + return proxyPath; +} + +async function syncProcessSessionRemoteScript(input: { + client: ReturnType; + remoteScriptPath: string; +}): Promise { + await input.client.writeTextFile(input.remoteScriptPath, getProcessSessionRemoteSource()); +} + +async function readRemoteJsonFiles(input: { + client: ReturnType; + dir: string; +}): Promise> { + const names = await input.client.listJsonFiles(input.dir); + const out: Array<{ name: string; body: string }> = []; + for (const name of names) { + const filePath = path.posix.join(input.dir, name); + const body = await input.client.readTextFile(filePath); + await input.client.remove(filePath).catch(() => undefined); + out.push({ name, body }); + } + return out; +} + +async function waitForLocalServerListen(server: net.Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Process session bridge did not expose a TCP port."); + } + return address.port; +} + +export async function startAdapterExecutionTargetProcessSessionBridge(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + runtimeRootDir: string | null | undefined; + adapterKey: string; + command: string; + args: string[]; + cwd: string; + env: Record; + timeoutSec?: number | null; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + return null; + } + + const target = input.target; + const onLog = input.onLog ?? (async () => {}); + const runner = requireSandboxRunner(target); + const shellCommand = preferredSandboxShell(target); + const timeoutMs = + typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0 + ? Math.trunc(input.timeoutSec * 1000) + : target.timeoutMs ?? undefined; + const bridgeRuntimeDir = path.posix.join( + input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey), + "process-sessions", + ); + const sessionId = randomUUID(); + const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId); + const stdinDir = path.posix.join(sessionDir, "stdin"); + const eventsDir = path.posix.join(sessionDir, "events"); + const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT); + const client = createCommandManagedSandboxCallbackBridgeQueueClient({ + runner, + remoteCwd: target.remoteCwd, + timeoutMs, + shellCommand, + }); + + await client.makeDir(stdinDir); + await client.makeDir(eventsDir); + await syncProcessSessionRemoteScript({ client, remoteScriptPath }); + + const commandPayload = Buffer.from(JSON.stringify({ + command: input.command, + args: input.args, + cwd: input.cwd || target.remoteCwd, + env: sanitizeRemoteExecutionEnv(input.env), + }), "utf8").toString("base64"); + + await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`); + const startResult = await runner.execute({ + command: shellCommand, + args: shellCommandArgs( + [ + `mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`, + `PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` + + `PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` + + `nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`, + "printf '%s\\n' \"$!\"", + ].join("\n"), + ), + cwd: target.remoteCwd, + env: { + PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge", + }, + timeoutMs, + }); + if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) { + throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`); + } + + let socket: net.Socket | null = null; + let stopping = false; + let stdinSeq = 0; + let pollTimer: NodeJS.Timeout | null = null; + const pendingRemoteEvents: Array<{ + type?: string; + stream?: "stdout" | "stderr"; + data?: string; + code?: number | null; + signal?: string | null; + message?: string; + }> = []; + const token = createSandboxCallbackBridgeToken(18); + const proxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-proxy-")); + + const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => { + if (!socket) return false; + socket.write(jsonLine(event)); + if (event.type === "exit") { + stopping = true; + socket.end(); + } else if (event.type === "error") { + stopping = true; + socket.destroy(); + } + return true; + }; + + const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => { + if (socket) { + writeRemoteEventToSocket(event); + return; + } + pendingRemoteEvents.push(event); + if (event.type === "exit" || event.type === "error") { + stopping = true; + } + }; + + const flushPendingRemoteEvents = () => { + if (!socket) return; + while (pendingRemoteEvents.length > 0 && socket) { + const event = pendingRemoteEvents.shift(); + if (event) writeRemoteEventToSocket(event); + } + }; + + const liveSockets = new Set(); + const server = net.createServer((nextSocket) => { + liveSockets.add(nextSocket); + nextSocket.setEncoding("utf8"); + nextSocket.on("error", () => undefined); + let connectionBuffer = ""; + let authenticated = false; + // Connections own the session (and receive buffered process output) only + // after presenting the bridge token; idle unauthenticated peers are dropped. + const authTimer = setTimeout(() => { + if (!authenticated) nextSocket.destroy(); + }, PROCESS_SESSION_AUTH_TIMEOUT_MS); + authTimer.unref?.(); + nextSocket.on("close", () => { + clearTimeout(authTimer); + liveSockets.delete(nextSocket); + }); + nextSocket.on("data", (chunk) => { + connectionBuffer += chunk; + const split = splitJsonLines(connectionBuffer); + connectionBuffer = split.rest; + for (const line of split.lines) { + if (!line.trim()) continue; + let message: { token?: string; type?: string; data?: string }; + try { + message = JSON.parse(line) as { token?: string; type?: string; data?: string }; + } catch { + nextSocket.destroy(); + return; + } + if (message.token !== token) { + nextSocket.destroy(); + return; + } + if (!authenticated) { + if (socket) { + nextSocket.destroy(); + return; + } + authenticated = true; + clearTimeout(authTimer); + socket = nextSocket; + flushPendingRemoteEvents(); + } + void (async () => { + if (message.type === "stdin" && typeof message.data === "string") { + stdinSeq += 1; + const name = `${String(stdinSeq).padStart(12, "0")}.json`; + await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdin", data: message.data })); + } else if (message.type === "stdinEnd") { + stdinSeq += 1; + const name = `${String(stdinSeq).padStart(12, "0")}.json`; + await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdinEnd" })); + } + })().catch((error) => { + nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) })); + nextSocket.destroy(); + }); + } + }); + }); + + const poll = async () => { + if (stopping) return; + try { + const events = await readRemoteJsonFiles({ client, dir: eventsDir }); + for (const event of events) { + const parsed = JSON.parse(event.body) as { + type?: string; + stream?: "stdout" | "stderr"; + data?: string; + code?: number | null; + signal?: string | null; + message?: string; + }; + deliverRemoteEvent(parsed); + if (parsed.type === "exit" || parsed.type === "error") return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`); + deliverRemoteEvent({ type: "error", message }); + return; + } finally { + if (!stopping) { + pollTimer = setTimeout(() => void poll(), 100); + pollTimer.unref?.(); + } + } + }; + + const port = await waitForLocalServerListen(server); + const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token); + pollTimer = setTimeout(() => void poll(), 100); + pollTimer.unref?.(); + + return { + agentCommand, + stop: async () => { + stopping = true; + if (pollTimer) clearTimeout(pollTimer); + for (const liveSocket of liveSockets) liveSocket.destroy(); + await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined); + await client.writeTextFile( + path.posix.join(stdinDir, `${String(stdinSeq + 1).padStart(12, "0")}.json`), + jsonLine({ type: "stdinEnd" }), + ).catch(() => undefined); + await client.remove(sessionDir).catch(() => undefined); + await fs.rm(proxyDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} + +function getProcessSessionProxySource(input: { port: number; token: string }): string { + return `#!/usr/bin/env node +import net from "node:net"; + +const socket = net.createConnection({ host: "127.0.0.1", port: ${input.port} }); +const token = ${JSON.stringify(input.token)}; +let buffer = ""; +let exiting = false; + +function send(message) { + socket.write(JSON.stringify({ token, ...message }) + "\\n"); +} + +socket.on("connect", () => send({ type: "hello" })); +process.stdin.on("data", (chunk) => send({ type: "stdin", data: Buffer.from(chunk).toString("base64") })); +process.stdin.on("end", () => send({ type: "stdinEnd" })); +process.stdin.resume(); + +socket.setEncoding("utf8"); +socket.on("data", (chunk) => { + buffer += chunk; + const parts = buffer.split(/\\n/); + buffer = parts.pop() || ""; + for (const line of parts) { + if (!line.trim()) continue; + const message = JSON.parse(line); + if (message.type === "data") { + const out = Buffer.from(message.data || "", "base64"); + (message.stream === "stderr" ? process.stderr : process.stdout).write(out); + } else if (message.type === "error") { + process.stderr.write(String(message.message || "Process session bridge failed.") + "\\n"); + exiting = true; + process.exitCode = 1; + socket.end(); + } else if (message.type === "exit") { + exiting = true; + process.exitCode = typeof message.code === "number" ? message.code : 1; + socket.end(); + } + } +}); +socket.on("close", () => { + if (!exiting) process.exit(1); +}); +`; +} + +function getProcessSessionRemoteSource(): string { + return `import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR; +const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64; +if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env."); + +const stdinDir = path.posix.join(sessionDir, "stdin"); +const eventsDir = path.posix.join(sessionDir, "events"); +let seq = 0; +let stdinClosed = false; + +const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8")); +await fs.mkdir(stdinDir, { recursive: true }); +await fs.mkdir(eventsDir, { recursive: true }); + +let writeChain = Promise.resolve(); + +function writeEvent(event) { + seq += 1; + const file = path.posix.join(eventsDir, String(seq).padStart(12, "0") + ".json"); + const write = writeChain.then(async () => { + await fs.writeFile(file + ".tmp", JSON.stringify(event) + "\\n", "utf8"); + await fs.rename(file + ".tmp", file); + }); + writeChain = write.catch(() => undefined); + return write; +} + +const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], { + cwd: config.cwd || process.cwd(), + env: { ...process.env, ...(config.env || {}) }, + stdio: ["pipe", "pipe", "pipe"], +}); + +child.stdout.on("data", (chunk) => void writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") })); +child.stderr.on("data", (chunk) => void writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") })); +child.on("error", (error) => void writeEvent({ type: "error", message: error.message })); +// "close" (not "exit") so stdout/stderr fully drain before the exit event; +// the write chain then guarantees the exit file lands after every data file. +child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal })); + +async function pollStdin() { + while (!stdinClosed) { + const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort(); + for (const name of entries) { + const file = path.posix.join(stdinDir, name); + const raw = await fs.readFile(file, "utf8").catch(() => null); + await fs.rm(file, { force: true }).catch(() => undefined); + if (!raw) continue; + const message = JSON.parse(raw); + if (message.type === "stdin" && typeof message.data === "string") { + child.stdin.write(Buffer.from(message.data, "base64")); + } else if (message.type === "stdinEnd") { + stdinClosed = true; + child.stdin.end(); + break; + } + } + if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) })); +`; +} + export async function startAdapterExecutionTargetPaperclipBridge(input: { runId: string; target: AdapterExecutionTarget | null | undefined; diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index c0ada020ab2..d47e1dc0389 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -896,6 +896,9 @@ describe("sandbox callback bridge", () => { { method: "POST", path: "/api/issues/issue-1/release" }, { method: "PATCH", path: "/api/issues/issue-1" }, { method: "GET", path: "/api/issues/issue-1/approvals" }, + { method: "GET", path: "/api/issues/issue-1/work-products" }, + { method: "POST", path: "/api/issues/issue-1/work-products" }, + { method: "PATCH", path: "/api/work-products/wp-1" }, { method: "GET", path: "/api/issues/issue-1/interactions" }, { method: "GET", path: "/api/issues/issue-1/interactions/inter-1" }, { method: "POST", path: "/api/issues/issue-1/interactions" }, @@ -940,6 +943,7 @@ describe("sandbox callback bridge", () => { { method: "POST", path: "/api/companies/co-1/archive" }, { method: "DELETE", path: "/api/issues/issue-1/documents/plan" }, { method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" }, + { method: "DELETE", path: "/api/work-products/wp-1" }, { method: "POST", path: "/api/approvals/ap-1/approve" }, { method: "POST", path: "/api/approvals/ap-1/reject" }, { method: "POST", path: "/api/companies/co-1/logo" }, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 6531aacfe97..4bc19f08aec 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -67,6 +67,11 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa { method: "PATCH", path: /^\/api\/issues\/[^/]+$/ }, { method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ }, + // Work products: publish branch/commit/artifact metadata for completed work. + { method: "GET", path: /^\/api\/issues\/[^/]+\/work-products$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/work-products$/ }, + { method: "PATCH", path: /^\/api\/work-products\/[^/]+$/ }, + // Issue-thread interactions (suggest tasks, ask questions, request confirmation) { method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ }, { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ }, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index e1b4b9f066c..7c7071030b4 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; @@ -16,9 +17,12 @@ import { runningProcesses, runChildProcess, sanitizeSshRemoteEnv, + signalRunningProcess, shapePaperclipWorkspaceEnvForExecution, rewriteWorkspaceCwdEnvVarsForExecution, stringifyPaperclipWakePayload, + UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + UNMANAGED_BACKGROUND_TASK_STOP_REASON, WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; @@ -470,6 +474,97 @@ describe("runChildProcess", () => { expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true); }); + it.skipIf(process.platform === "win32")( + "force-kills a child that ignores SIGTERM once the grace window elapses", + async () => { + // Residual hang case: a child that installs a SIGTERM handler which + // swallows the signal and keeps running. The timeout sends SIGTERM at + // timeoutSec, then must escalate to SIGKILL graceSec later. If the + // escalation were gated on `child.killed` (which is true the instant + // SIGTERM is *sent*, not when the process exits) the SIGKILL would be + // suppressed and this child would outlive its deadline. + const result = await runChildProcess( + randomUUID(), + process.execPath, + [ + "-e", + [ + "process.on('SIGTERM', () => {});", + "process.stdout.write(String(process.pid));", + "setInterval(() => {}, 1000);", + ].join(" "), + ], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 1, + graceSec: 1, + onLog: async () => {}, + onSpawn: async () => {}, + }, + ); + + const childPid = Number.parseInt(result.stdout.trim(), 10); + expect(result.timedOut).toBe(true); + expect(result.signal).toBe("SIGKILL"); + expect(Number.isInteger(childPid) && childPid > 0).toBe(true); + expect(await waitForPidExit(childPid, 2_000)).toBe(true); + }, + ); + + it.skipIf(process.platform === "win32")( + "signalRunningProcess escalates SIGKILL on the direct-child fallback after SIGTERM is sent", + async () => { + // Directly cover the branch this PR changed: the direct-child fallback + // (processGroupId === null), which runChildProcess's POSIX timeout tests + // never reach because they always spawn detached and take the + // process-group path. This reproduces the exact regression: once SIGTERM + // has been *sent*, `child.killed` is already true, so the old + // `!child.killed` guard would suppress the SIGKILL escalation and leave a + // SIGTERM-ignoring child alive. The liveness guard + // (exitCode === null && signalCode === null) must still let SIGKILL through. + const child = spawn( + process.execPath, + [ + "-e", + [ + "process.on('SIGTERM', () => {});", + "process.stdout.write(String(process.pid));", + "setInterval(() => {}, 1000);", + ].join(" "), + ], + { detached: false, stdio: ["ignore", "pipe", "ignore"] }, + ); + try { + const pid = await new Promise((resolvePid, rejectPid) => { + child.stdout!.on("data", (d) => resolvePid(Number.parseInt(String(d).trim(), 10))); + child.on("error", rejectPid); + }); + expect(Number.isInteger(pid) && pid > 0).toBe(true); + + // First SIGTERM via the fallback (no process group). The child swallows + // it and stays alive — but child.killed is now true. + signalRunningProcess({ child, processGroupId: null }, "SIGTERM"); + await new Promise((r) => setTimeout(r, 300)); + expect(child.killed).toBe(true); // signal was sent… + expect(isPidAlive(pid)).toBe(true); // …but the process ignored it and lives + + // Escalation: with the old `!child.killed` guard this would be a no-op + // and the child would survive. The liveness guard must still fire. + signalRunningProcess({ child, processGroupId: null }, "SIGKILL"); + expect(await waitForPidExit(pid, 2_000)).toBe(true); + } finally { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + } + } + }, + ); + it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => { const result = await runChildProcess( randomUUID(), @@ -500,6 +595,13 @@ describe("runChildProcess", () => { const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10); expect(result.timedOut).toBe(false); expect(result.exitCode).toBe(0); + expect(result.terminalResultCleanup).toMatchObject({ + kind: "terminal_result_cleanup", + stopped: true, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + terminalResultSeen: true, + }); expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); expect(await waitForPidExit(descendantPid, 2_000)).toBe(true); }); @@ -530,6 +632,14 @@ describe("runChildProcess", () => { expect(result.timedOut).toBe(false); expect(result.signal).toBe("SIGTERM"); + expect(result.terminalResultCleanup).toMatchObject({ + kind: "terminal_result_cleanup", + stopped: true, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + terminalResultSeen: true, + signal: "SIGTERM", + }); expect(result.stdout).toContain('"type":"result"'); }); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index c9b58901ed4..5a4c1e04cfa 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -19,6 +19,7 @@ export interface RunProcessResult { stderr: string; pid: number | null; startedAt: string | null; + terminalResultCleanup?: TerminalResultCleanupEvidence | null; } export interface TerminalResultCleanupOptions { @@ -26,6 +27,20 @@ export interface TerminalResultCleanupOptions { graceMs?: number; } +export const UNMANAGED_BACKGROUND_TASK_STOP_REASON = "unmanaged_background_task_stopped"; +export const UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON = + "unmanaged background task stopped; no durable live path"; + +export interface TerminalResultCleanupEvidence { + kind: "terminal_result_cleanup"; + stopped: true; + stopReason: typeof UNMANAGED_BACKGROUND_TASK_STOP_REASON; + reason: typeof UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON; + terminalResultSeen: boolean; + signal: NodeJS.Signals | null; + forceKilled: boolean; +} + interface RunningProcess { child: ChildProcess; graceSec: number; @@ -58,7 +73,8 @@ function resolveProcessGroupId(child: ChildProcess) { return typeof child.pid === "number" && child.pid > 0 ? child.pid : null; } -function signalRunningProcess( +// Exported so the direct-child fallback branch can be unit-tested directly. +export function signalRunningProcess( running: Pick, signal: NodeJS.Signals, ) { @@ -70,7 +86,10 @@ function signalRunningProcess( // Fall back to the direct child signal if group signaling fails. } } - if (!running.child.killed) { + // Gate on real liveness: `child.killed` only means a signal was sent, not that + // the process exited, so escalating on it would suppress a follow-up SIGKILL. + // `exitCode`/`signalCode` are null until the child actually closes. + if (running.child.exitCode === null && running.child.signalCode === null) { running.child.kill(signal); } } @@ -2917,6 +2936,8 @@ export async function runChildProcess( let logChain: Promise = Promise.resolve(); let terminalResultSeen = false; let terminalCleanupStarted = false; + let terminalCleanupSignal: NodeJS.Signals | null = null; + let terminalCleanupForceKilled = false; let terminalCleanupTimer: NodeJS.Timeout | null = null; let terminalCleanupKillTimer: NodeJS.Timeout | null = null; let terminalResultStdoutScanOffset = 0; @@ -2956,9 +2977,12 @@ export async function runChildProcess( terminalCleanupTimer = null; if (terminalCleanupStarted || timedOut) return; terminalCleanupStarted = true; + terminalCleanupSignal = "SIGTERM"; signalRunningProcess({ child, processGroupId }, "SIGTERM"); terminalCleanupKillTimer = setTimeout(() => { terminalCleanupKillTimer = null; + terminalCleanupSignal = "SIGKILL"; + terminalCleanupForceKilled = true; signalRunningProcess({ child, processGroupId }, "SIGKILL"); }, Math.max(1, opts.graceSec) * 1000); }, graceMs); @@ -3051,6 +3075,17 @@ export async function runChildProcess( stderr, pid: child.pid ?? null, startedAt, + terminalResultCleanup: terminalCleanupStarted + ? { + kind: "terminal_result_cleanup", + stopped: true, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + terminalResultSeen, + signal: terminalCleanupSignal, + forceKilled: terminalCleanupForceKilled, + } + : null, }); }); }); diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 791f0b719cd..ba8cb5bc34c 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -258,6 +258,79 @@ describe("claude_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { agentCommand: "claude-agent-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/work", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteCwd: "/work", + remoteWorkspacePath: "/work", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + it("reports ACP prerequisites for the ACP lane", async () => { const root = await makeTempRoot("paperclip-claude-acp-env-"); const commandPath = path.join(root, "bin", "claude-agent-acp"); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 63dcd132545..8b74ddfdef0 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise { +async function commandIsResolvable( + command: string, + input?: ClaudeEngineResolutionInput, +): Promise { const trimmed = command.trim(); if (!trimmed) return false; if (looksLikeShellCommand(trimmed)) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + trimmed, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); return (await findCommandOnPath(trimmed)) !== null; } @@ -197,6 +221,22 @@ async function resolveClaudeAcpCommand(config: Record): Promise ); } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + +async function resolveClaudeAcpCommandForTarget( + config: Record, + target: ReturnType, +): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + if (target?.kind === "remote") return "claude-agent-acp"; + return resolveClaudeAcpCommand(config); +} + async function defaultClaudeAcpFallbackReason( input: ClaudeEngineResolutionInput, ): Promise { @@ -204,14 +244,17 @@ async function defaultClaudeAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Claude ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Claude ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsClaudeAcpMinimum()) { return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } - const command = await resolveClaudeAcpCommand(input.config); - if (!(await commandIsResolvable(command))) { + const command = await resolveClaudeAcpCommandForTarget(input.config, target); + if (!(await commandIsResolvable(command, input))) { return `Claude ACP server command is not available: ${command}.`; } return null; @@ -244,10 +287,10 @@ export async function testClaudeAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "claude_acp_remote_target_unsupported", - level: "error", - message: "Claude ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Claude runs.", + code: "claude_acp_remote_target", + level: "info", + message: "Claude ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -279,8 +322,11 @@ export async function testClaudeAcpEnvironment( : `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, }); - const command = await resolveClaudeAcpCommand(config); - const commandResolvable = await commandIsResolvable(command); + const command = await resolveClaudeAcpCommandForTarget(config, target); + const commandResolvable = await commandIsResolvable(command, { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "claude_acp_command_resolvable" : "claude_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 38d6d218e83..97ca1994ca2 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -921,6 +921,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude", model: "claude-opus-4-8" }, + config: { engine: "cli", command: "claude", model: "claude-opus-4-8" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -114,7 +114,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -133,7 +133,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -152,7 +152,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); diff --git a/packages/adapters/codex-local/src/index.test.ts b/packages/adapters/codex-local/src/index.test.ts index a40331e778b..213edf017e4 100644 --- a/packages/adapters/codex-local/src/index.test.ts +++ b/packages/adapters/codex-local/src/index.test.ts @@ -1,9 +1,18 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_CODEX_LOCAL_MODEL, models } from "./index.js"; +import { DEFAULT_CODEX_LOCAL_MODEL, isCodexLocalFastModeSupported, models } from "./index.js"; describe("codex local adapter metadata", () => { - it("does not advertise the ChatGPT-unsupported gpt-5.3-codex model as a default option", () => { - expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.5"); - expect(models.map((model) => model.id)).not.toContain("gpt-5.3-codex"); + it("advertises current GPT-5.6 Codex-capable OpenAI models by default", () => { + const modelIds = models.map((model) => model.id); + + expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.6"); + expect(modelIds.slice(0, 4)).toEqual([ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ]); + expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true); + expect(modelIds).not.toContain("gpt-5.3-codex"); }); }); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index cd836564974..5024c259225 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -5,9 +5,9 @@ export const label = "Codex"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex"; -export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.5"; +export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.6"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; -export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.5", "gpt-5.4"] as const; +export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.6", "gpt-5.5", "gpt-5.4"] as const; function normalizeModelId(model: string | null | undefined): string { return typeof model === "string" ? model.trim() : ""; @@ -28,9 +28,8 @@ export function isCodexLocalFastModeSupported(model: string | null | undefined): if (isCodexLocalManualModel(model)) return true; const normalizedModel = typeof model === "string" ? model.trim() : ""; // Empty means we're omitting --model so the Codex CLI picks its own default. - // On subscription auth that's gpt-5.5 (fast-mode capable); manual model IDs - // are also treated as supported. Match that policy: pass the fast-mode - // overrides through and let the CLI reject them if the chosen model can't use them. + // Manual model IDs are also treated as supported: pass the fast-mode overrides + // through and let the CLI reject them if the chosen model can't use them. if (!normalizedModel) return true; return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes( normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number], @@ -39,6 +38,9 @@ export function isCodexLocalFastModeSupported(model: string | null | undefined): export const models = [ { id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL }, + { id: "gpt-5.6-sol", label: "gpt-5.6-sol" }, + { id: "gpt-5.6-terra", label: "gpt-5.6-terra" }, + { id: "gpt-5.6-luna", label: "gpt-5.6-luna" }, { id: "gpt-5.4", label: "gpt-5.4" }, { id: "gpt-5.4-mini", label: "gpt-5.4-mini" }, { id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" }, @@ -77,7 +79,7 @@ Core fields: - modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=... - promptTemplate (string, optional): run prompt template - search (boolean, optional): run codex with --search -- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.5, GPT-5.4 and passed through for manual model IDs +- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.6, GPT-5.5, GPT-5.4 and passed through for manual model IDs - dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args @@ -102,7 +104,7 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances//companies//agents//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). -- Fast mode is supported on GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. +- Fast mode is supported on GPT-5.6, GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. The configured model is applied through a \`-c model=...\` startup config override, which accepts manual model ids; other session config options are best-effort, so a rejected option is logged and the run continues on the agent default instead of failing. Auto selection falls back to CLI only when ACP prerequisites are unavailable; explicit engine="acp" fails loudly. `; diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index d6ca1d0b852..294c1eb3c4a 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -245,6 +245,79 @@ describe("codex_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: { agentCommand: "codex-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/work", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteCwd: "/work", + remoteWorkspacePath: "/work", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + it("maps Codex config to the ACPX Codex target", () => { expect(buildCodexAcpConfig({ engine: "acp", diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 8336babae68..6b8ae974ca4 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise { +async function commandIsResolvable( + command: string, + input?: CodexEngineResolutionInput, +): Promise { const trimmed = command.trim(); if (!trimmed) return false; if (looksLikeShellCommand(trimmed)) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + trimmed, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); return (await findCommandOnPath(trimmed)) !== null; } @@ -197,6 +221,22 @@ async function resolveCodexAcpCommand(config: Record): Promise< ); } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + +async function resolveCodexAcpCommandForTarget( + config: Record, + target: ReturnType, +): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + if (target?.kind === "remote") return "codex-acp"; + return resolveCodexAcpCommand(config); +} + async function defaultCodexAcpFallbackReason( input: CodexEngineResolutionInput, ): Promise { @@ -204,14 +244,17 @@ async function defaultCodexAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Codex ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Codex ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Codex ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsCodexAcpMinimum()) { return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } - const command = await resolveCodexAcpCommand(input.config); - if (!(await commandIsResolvable(command))) { + const command = await resolveCodexAcpCommandForTarget(input.config, target); + if (!(await commandIsResolvable(command, input))) { return `Codex ACP server command is not available: ${command}.`; } return null; @@ -257,10 +300,10 @@ export async function testCodexAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "codex_acp_remote_target_unsupported", - level: "error", - message: "Codex ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Codex runs.", + code: "codex_acp_remote_target", + level: "info", + message: "Codex ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -292,8 +335,11 @@ export async function testCodexAcpEnvironment( : `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, }); - const command = await resolveCodexAcpCommand(config); - const commandResolvable = await commandIsResolvable(command); + const command = await resolveCodexAcpCommandForTarget(config, target); + const commandResolvable = await commandIsResolvable(command, { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index 53a86a3455d..e0843ca2e60 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -98,7 +98,7 @@ describe("buildCodexExecArgs", () => { expect(result.fastModeRequested).toBe(true); expect(result.fastModeApplied).toBe(false); expect(result.fastModeIgnoredReason).toContain( - "currently only supported on gpt-5.5, gpt-5.4 or manually configured model IDs", + "currently only supported on gpt-5.6, gpt-5.5, gpt-5.4 or manually configured model IDs", ); expect(result.args).toEqual([ "exec", diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 9f0a7cfdc00..6ec366c6b7b 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -264,10 +264,11 @@ describe("gemini_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); - it("falls back to the CLI lane for remote auto runs", async () => { + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v20.0.0"); await expect( resolveGeminiExecutionEngineForRun({ - config: {}, + config: { agentCommand: "gemini --acp" }, executionTarget: { kind: "remote", transport: "ssh", @@ -287,7 +288,55 @@ describe("gemini_local ACP lane", () => { ).resolves.toMatchObject({ engine: "cli", explicit: false, - fallbackReason: expect.stringContaining("remote environment"), + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v20.0.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v20.0.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { agentCommand: "gemini --acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ + engine: "acp", + explicit: false, }); }); diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index f357933d673..bdedd2df43b 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -183,9 +187,30 @@ function resolveConfigPath(config: Record): string { : process.env.PATH ?? ""; } -async function commandIsResolvable(command: string, pathValue = process.env.PATH ?? ""): Promise { +async function commandIsResolvable( + command: string, + pathValue = process.env.PATH ?? "", + input?: GeminiEngineResolutionInput, +): Promise { const token = firstShellToken(command); if (!token) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + token, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token); return (await findCommandOnPath(token, pathValue)) !== null; } @@ -197,6 +222,12 @@ function resolveGeminiAcpCommand(config: Record): string { return `${geminiCommand} --acp`; } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + async function defaultGeminiAcpFallbackReason( input: GeminiEngineResolutionInput, ): Promise { @@ -204,14 +235,17 @@ async function defaultGeminiAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Gemini ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Gemini ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Gemini ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsGeminiAcpMinimum()) { return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = resolveGeminiAcpCommand(input.config); - if (!(await commandIsResolvable(command, resolveConfigPath(input.config)))) { + if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) { return `Gemini ACP command is not available: ${command}.`; } return null; @@ -244,10 +278,10 @@ export async function testGeminiAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "gemini_acp_remote_target_unsupported", - level: "error", - message: "Gemini ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Gemini runs.", + code: "gemini_acp_remote_target", + level: "info", + message: "Gemini ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -280,7 +314,10 @@ export async function testGeminiAcpEnvironment( }); const command = resolveGeminiAcpCommand(config); - const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config)); + const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config), { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "gemini_acp_command_resolvable" : "gemini_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 0fcbcfffd76..2b307ae5441 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -272,6 +272,9 @@ describe("gemini remote execution", () => { taskKey: null, }, config: { + // Pin the CLI lane: sandbox targets with a runner now default to ACP, + // and this test covers the CLI lane's managed-HOME auth flow. + engine: "cli", command: "gemini", env: { GEMINI_API_KEY: "test-key" }, }, diff --git a/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql new file mode 100644 index 00000000000..00d65045ebe --- /dev/null +++ b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql @@ -0,0 +1,15 @@ +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "kind" text;--> statement-breakpoint +UPDATE "inbox_dismissals" SET "kind" = 'dismiss' WHERE "kind" IS NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET DEFAULT 'dismiss';--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "snoozed_until" timestamp with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_check" CHECK ("kind" IN ('dismiss', 'snooze')); +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_snooze_until_check" CHECK (("kind" = 'dismiss' AND "snoozed_until" IS NULL) OR ("kind" = 'snooze' AND "snoozed_until" IS NOT NULL)); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fe570bd9dc9..a0331a5af0e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1002,6 +1002,13 @@ "when": 1783520000000, "tag": "0144_case_document_annotations", "breakpoints": true + }, + { + "idx": 145, + "version": "7", + "when": 1783641600000, + "tag": "0145_inbox_dismissal_snooze_kind", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/inbox_dismissals.ts b/packages/db/src/schema/inbox_dismissals.ts index 22996a476f3..ff77b45043f 100644 --- a/packages/db/src/schema/inbox_dismissals.ts +++ b/packages/db/src/schema/inbox_dismissals.ts @@ -8,7 +8,9 @@ export const inboxDismissals = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), userId: text("user_id").notNull(), itemKey: text("item_key").notNull(), + kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"), dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(), + snoozedUntil: timestamp("snoozed_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md index e0380e67f31..c1e7f32ee52 100644 --- a/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md @@ -1,6 +1,6 @@ --- name: index-refresh -description: Use when an operation issue is an index refresh — typically the hourly index-refresh routine. Rebuild `wiki/index.md` so each entry has a tight, scannable one-line summary and the catalog tracks the actual contents of `wiki/`. Resolve drift between the index and recent log activity, but do not edit page content. +description: Use when an LLM Wiki operation issue requests an index refresh. Rebuild `wiki/index.md` from the actual wiki tree, reconcile missing/deleted entries, and log counts without editing page bodies. --- # Index Refresh diff --git a/packages/plugins/plugin-llm-wiki/skills/paperclip-distill/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/paperclip-distill/SKILL.md index fa7b8d60d50..8bbebfb188f 100644 --- a/packages/plugins/plugin-llm-wiki/skills/paperclip-distill/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/paperclip-distill/SKILL.md @@ -1,6 +1,6 @@ --- name: paperclip-distill -description: Use when an operation issue is a Paperclip cursor-window, distill, or backfill — `operationType: "distill"` or `"backfill"` and the body references a Paperclip source bundle for a project or root issue. Turn raw Paperclip activity into a wiki-insightful project page, decisions log, and history note. This skill exists specifically to replace the stiff, datestamp-heavy templated output that the deterministic distiller produces. +description: Use when an operation issue is a Paperclip cursor-window, distill, or backfill. Turn source-bundled Paperclip activity into wiki-insightful project standups, durable project pages, decisions, and history without asset dereferencing. --- # Paperclip Distill diff --git a/packages/plugins/plugin-llm-wiki/skills/wiki-ingest/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/wiki-ingest/SKILL.md index 3422af806bd..fdcadfe372d 100644 --- a/packages/plugins/plugin-llm-wiki/skills/wiki-ingest/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/wiki-ingest/SKILL.md @@ -1,6 +1,6 @@ --- name: wiki-ingest -description: Use when an operation issue asks you to ingest a captured source from `raw/` into the LLM Wiki, or when the user explicitly says "ingest ". The issue body will name a file under `raw/` (e.g. `raw/karpathy-llm-wiki.md`) and ask for durable wiki pages. Do not invoke this skill for Paperclip activity bundles — those use `paperclip-distill` instead. +description: Use when an operation issue asks to ingest a captured `raw/` source into the LLM Wiki, or the user says "ingest ". Create durable source, entity, concept, synthesis, index, and log pages; use paperclip-distill for Paperclip bundles. --- # Wiki Ingest diff --git a/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md index acaa02557f5..345ca995f9c 100644 --- a/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md @@ -1,6 +1,6 @@ --- name: wiki-lint -description: Use when an operation issue is a lint or health-check (`operationType: "lint"`) — typically the nightly lint routine or a manual "Run lint" from the UI. Audit the wiki for contradictions, orphans, weak provenance, broken links, and missing concept pages, and return a triage list — do not auto-fix. +description: Use when an LLM Wiki operation issue is a lint or health check. Audit for contradictions, orphans, weak provenance, broken links, missing concept pages, and index/log drift; return triage findings without auto-fixing. --- # Wiki Lint diff --git a/packages/plugins/plugin-llm-wiki/skills/wiki-query/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/wiki-query/SKILL.md index ccb2855a7f2..fe8d9e57bfb 100644 --- a/packages/plugins/plugin-llm-wiki/skills/wiki-query/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/wiki-query/SKILL.md @@ -1,6 +1,6 @@ --- name: wiki-query -description: Use when an operation issue asks you to answer a question from the LLM Wiki — `operationType: "query"` and a question in the issue body. Answer with citations to wiki pages and raw sources, and offer to file durable synthesis back into `wiki/synthesis/` so the work compounds instead of disappearing into a chat thread. +description: Use when an LLM Wiki operation issue asks a question. Answer from wiki pages and raw sources with citations, name gaps plainly, and offer to file durable synthesis when the answer should compound. --- # Wiki Query diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 0c1ad491eb3..c83a49275f3 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -32,6 +32,7 @@ const manifest: PaperclipPluginManifestV1 = { field: "snapshot", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index d8d3502a20c..4e832a7670c 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -48,6 +48,8 @@ function createMockSandbox(overrides: { recover: vi.fn().mockResolvedValue(undefined), resize: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), + archive: vi.fn().mockResolvedValue(undefined), + setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined), createSshAccess: vi.fn().mockResolvedValue({ token: "ssh-token-secret", command: "ssh ssh-token-secret@ssh.app.daytona.io", @@ -133,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => { autoArchiveInterval: 60, autoDeleteInterval: -1, reuseLease: true, + archiveOnRelease: false, }, }); }); @@ -956,6 +959,54 @@ describe("Daytona sandbox provider plugin", () => { expect(ephemeral.delete).toHaveBeenCalledWith(300); }); + it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" }); + mockGet.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).toHaveBeenCalledWith(300); + expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).not.toHaveBeenCalled(); + }); + + it("falls back to delete when archiving an archiveOnRelease lease fails", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" }); + sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported")); + mockGet.mockResolvedValue(sandbox); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).not.toHaveBeenCalled(); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).toHaveBeenCalledWith(300); + expect(warnSpy).toHaveBeenCalled(); + }); + it("falls back to delete when stopping a reusable lease from an error state fails", async () => { process.env.DAYTONA_API_KEY = "host-key"; const errored = createMockSandbox({ id: "sandbox-error", state: "error" }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index c9fb0a565d1..aab0b293d32 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -51,6 +51,7 @@ interface DaytonaDriverConfig { autoArchiveInterval: number | null; autoDeleteInterval: number | null; reuseLease: boolean; + archiveOnRelease: boolean; } type WorkspaceSentinelResult = { @@ -93,6 +94,12 @@ const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days +// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so +// operators can inspect them from the Daytona dashboard, then expired by +// Daytona itself after this interval (counted from the stop that precedes the +// archive) so debugging copies don't accumulate. +const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60; + // Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.) // so a stalled remote or missing credential never consumes the full 900 s adapter // RPC ceiling; callers always see an actionable error within this window. @@ -145,6 +152,7 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES, autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, + archiveOnRelease: raw.archiveOnRelease === true, }; } @@ -434,6 +442,9 @@ function leaseMetadata(input: { target: input.sandbox.target, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, + // Persisted so the release path (which rebuilds config from lease + // metadata) still knows to archive instead of delete. + ...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}), remoteCwd: input.remoteCwd, resumedLease: input.resumedLease, // Record the resources Paperclip attempted to request so future diagnosis @@ -890,7 +901,14 @@ const plugin = definePlugin({ }); return { providerLeaseId: sandbox.id, - metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: false, workspaceSentinel }), + metadata: leaseMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + resumedLease: false, + workspaceSentinel, + }), }; } catch (error) { await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined); @@ -922,7 +940,14 @@ const plugin = definePlugin({ const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); return { providerLeaseId: sandbox.id, - metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: true, workspaceSentinel }), + metadata: leaseMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + resumedLease: true, + workspaceSentinel, + }), }; } catch (error) { await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined); @@ -956,6 +981,21 @@ const plugin = definePlugin({ return; } + if (config.archiveOnRelease) { + try { + if (sandbox.state !== "stopped") { + await sandbox.stop(toTimeoutSeconds(config.timeoutMs)); + } + await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES); + await sandbox.archive(); + return; + } catch (error) { + console.warn( + `Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`, + ); + } + } + await sandbox.delete(toTimeoutSeconds(config.timeoutMs)); }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 587d78d3cbf..801019a47a9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -248,10 +248,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [ "ask_user_questions", "request_confirmation", "request_checkbox_confirmation", + "request_item_verdicts", ] as const; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200; +export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT; export const ISSUE_THREAD_INTERACTION_STATUSES = [ "pending", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e78fccfcbf1..9b90f1bb8ac 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -49,6 +49,21 @@ export { type ResponsibleUserDenialCopy, type ResponsibleUserDenialTone, } from "./responsible-user-denial.js"; +export type { + AttentionDecisionVerb, + AttentionDetailImage, + AttentionFeed, + AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionSubjectKind, + AttentionWorkspaceRef, +} from "./types/attention.js"; + export type { PipelineAutomationRetryBlocker, PipelineAutomationRetryCleanupOptions, @@ -296,6 +311,7 @@ export { type IssueThreadInteractionStatus, type IssueThreadInteractionContinuationPolicy, REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, + REQUEST_ITEM_VERDICTS_ITEM_LIMIT, type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, @@ -770,6 +786,11 @@ export type { RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, + RequestItemVerdictValue, + RequestItemVerdictsItem, + RequestItemVerdictsPayload, + RequestItemVerdictsResultItem, + RequestItemVerdictsResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -781,6 +802,7 @@ export type { AskUserQuestionsInteraction, RequestConfirmationInteraction, RequestCheckboxConfirmationInteraction, + RequestItemVerdictsInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -857,6 +879,7 @@ export type { SidebarBadges, SidebarOrderPreference, InboxDismissal, + InboxDismissalKind, AccessUserProfile, CompanyMemberRecord, CompanyMembersResponse, @@ -1284,11 +1307,17 @@ export { requestCheckboxConfirmationOptionSchema, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema, + requestItemVerdictValueSchema, + requestItemVerdictsItemSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultItemSchema, + requestItemVerdictsResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, @@ -1350,6 +1379,7 @@ export { type RejectIssueThreadInteraction, type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, + type SubmitIssueThreadInteractionVerdicts, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index fea5c7e5924..5251dc0360e 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -3,6 +3,7 @@ import { acceptIssueThreadInteractionSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, } from "./validators/issue.js"; describe("issue thread interaction schemas", () => { @@ -306,4 +307,111 @@ describe("issue thread interaction schemas", () => { selectedOptionIds: ["item-1", "item-1"], })).toThrow("selectedOptionIds must be unique"); }); + + it("parses request_item_verdicts payloads with defaults", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [ + { id: "api", label: "API route", description: "Server submit endpoint" }, + { id: "docs", label: "Docs", previewMarkdown: "Document the route." }, + ], + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_item_verdicts", + continuationPolicy: "wake_assignee", + payload: { + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + }, + }); + }); + + it("accepts request_item_verdicts defer when enabled explicitly", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [{ id: "api", label: "API route" }], + verdicts: ["approve", "reject", "defer"], + requireReasonOn: ["reject", "defer"], + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_item_verdicts", + payload: { + verdicts: ["approve", "reject", "defer"], + requireReasonOn: ["reject", "defer"], + }, + }); + }); + + it("rejects invalid request_item_verdicts item and reason references", () => { + const base = { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review these generated items.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + } as const; + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: [], + }, + })).toThrow(); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: [ + { id: "api", label: "API route" }, + { id: "api", label: "Duplicate" }, + ], + }, + })).toThrow("Item ids must be unique within one item verdict request"); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + items: Array.from({ length: 201 }, (_value, index) => ({ + id: `item-${index}`, + label: `Item ${index}`, + })), + }, + })).toThrow(); + + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + verdicts: ["approve", "reject"], + requireReasonOn: ["defer"], + }, + })).toThrow("requireReasonOn must reference enabled verdicts"); + }); + + it("rejects duplicate request_item_verdicts submit ids", () => { + expect(() => submitIssueThreadInteractionVerdictsSchema.parse({ + verdicts: [ + { id: "api", verdict: "approve" }, + { id: "api", verdict: "reject", reason: "Needs revision" }, + ], + })).toThrow("verdict item ids must be unique"); + }); }); diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts new file mode 100644 index 00000000000..6e01956439c --- /dev/null +++ b/packages/shared/src/types/attention.ts @@ -0,0 +1,177 @@ +import type { InboxDismissalKind } from "./inbox-dismissal.js"; + +export type AttentionSourceKind = + | "approval" + | "issue_thread_interaction" + | "join_request" + | "recovery_action" + | "productivity_review" + | "blocker_attention" + | "review" + | "failed_run" + | "budget_alert" + | "agent_error_alert"; + +export type AttentionSubjectKind = + | "approval" + | "issue" + | "interaction" + | "join_request" + | "recovery_action" + | "run" + | "budget_incident" + | "agent"; + +export type AttentionSeverity = "critical" | "high" | "medium" | "low"; + +export interface AttentionSubject { + kind: AttentionSubjectKind; + id: string; + companyId: string; + title: string | null; + identifier: string | null; + status: string | null; + href: string | null; + metadata?: Record; +} + +export interface AttentionDecisionVerb { + id: string; + label: string; + description: string | null; +} + +export interface AttentionProjectRef { + id: string; + name: string; + urlKey: string; + color: string | null; + icon: string | null; +} + +export interface AttentionWorkspaceRef { + id: string; + name: string; +} + +export interface AttentionDetailImage { + assetId: string; + alt?: string | null; +} + +export interface AttentionItemDismissal { + kind: InboxDismissalKind; + dismissedAt: string; + snoozedUntil: string | null; + isActive: boolean; +} + +export type AttentionItemDetail = + | { + kind: "approval"; + approvalType: string; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "plan_approval"; + issueTitle: string | null; + planTitle: string | null; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "confirmation"; + promptExcerpt: string | null; + isPlanTarget: false; + images: AttentionDetailImage[]; + } + | { + kind: "questions"; + questionCount: number; + firstQuestionText: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "suggested_tasks"; + taskCount: number; + firstTaskTitle: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "checkbox_confirmation"; + optionCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "item_verdicts"; + itemCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "failed_run"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "blocker"; + blockingIssue: { + id: string | null; + identifier: string | null; + title: string | null; + } | null; + images: AttentionDetailImage[]; + } + | { + kind: "budget"; + observedPercent: number; + amountObserved: number; + amountLimit: number; + images: AttentionDetailImage[]; + } + | { + kind: "agent_error"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "generic"; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + }; + +export interface AttentionItem { + id: string; + companyId: string; + sourceKind: AttentionSourceKind; + subject: AttentionSubject; + whyNow: string; + decisionVerbs: AttentionDecisionVerb[]; + inlineResolvable: boolean; + entryRule: string; + exitRule: string; + dedupKey: string; + dismissalKey: string; + dismissal: AttentionItemDismissal | null; + severity: AttentionSeverity; + rank: number; + activityAt: string; + createdAt: string; + updatedAt: string; + relatedIssue: AttentionSubject | null; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; + detail: AttentionItemDetail | null; +} + +export interface AttentionFeed { + companyId: string; + generatedAt: string; + totalCount: number; + countsBySourceKind: Record; + items: AttentionItem[]; +} diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 9e38f2d5585..2a2867ffc1c 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -30,6 +30,12 @@ export interface FakeSandboxEnvironmentConfig { reuseLease: boolean; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; } export interface PluginSandboxEnvironmentConfig { @@ -38,6 +44,12 @@ export interface PluginSandboxEnvironmentConfig { timeoutMs?: number; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; [key: string]: unknown; } diff --git a/packages/shared/src/types/inbox-dismissal.ts b/packages/shared/src/types/inbox-dismissal.ts index 0c76ecc824c..5d2c71ce44a 100644 --- a/packages/shared/src/types/inbox-dismissal.ts +++ b/packages/shared/src/types/inbox-dismissal.ts @@ -1,9 +1,13 @@ +export type InboxDismissalKind = "dismiss" | "snooze"; + export interface InboxDismissal { id: string; companyId: string; userId: string; itemKey: string; + kind: InboxDismissalKind; dismissedAt: Date; + snoozedUntil: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c336dd262ab..e117d04b5bf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,18 @@ export type { Company } from "./company.js"; +export type { + AttentionDecisionVerb, + AttentionDetailImage, + AttentionFeed, + AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionSubjectKind, + AttentionWorkspaceRef, +} from "./attention.js"; export type { Environment, EnvironmentDeleteBlastRadius, @@ -400,6 +414,11 @@ export type { RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, + RequestItemVerdictValue, + RequestItemVerdictsItem, + RequestItemVerdictsPayload, + RequestItemVerdictsResultItem, + RequestItemVerdictsResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -411,6 +430,7 @@ export type { AskUserQuestionsInteraction, RequestConfirmationInteraction, RequestCheckboxConfirmationInteraction, + RequestItemVerdictsInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -558,7 +578,7 @@ export type { UpdateResourceMembership, } from "./resource-memberships.js"; export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js"; -export type { InboxDismissal } from "./inbox-dismissal.js"; +export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js"; export type { AccessUserProfile, CompanyMemberRecord, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index b34581ba74e..655626cfd98 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -57,6 +57,7 @@ export interface InstanceExperimentalSettings { enableCloudSync: boolean; enableExternalObjects: boolean; enableBuiltInAgents: boolean; + enableDecisions: boolean; enableGoalsSidebarLink: boolean; enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; @@ -70,6 +71,16 @@ export interface InstanceExperimentalSettings { * runs actually execute inside the preview. Ignored outside a worktree. */ enableWorktreeRunExecution: boolean; + /** + * Server-managed cutoff recorded when worktree run execution is enabled in + * this instance. Client PATCH payloads must not control this value. + */ + worktreeRunExecutionActivatedAt: string | null; + /** + * Server-managed instance id captured with the cutoff so copied settings rows + * from another instance fail closed. + */ + worktreeRunExecutionActivationInstanceId: string | null; issueGraphLivenessAutoRecoveryLookbackHours: number; } diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f651e482df2..d9c3324a47c 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1082,6 +1082,30 @@ export interface RequestCheckboxConfirmationPayload { target?: RequestConfirmationTarget | null; } +export type RequestItemVerdictValue = "approve" | "reject" | "defer"; + +export interface RequestItemVerdictsItem { + id: string; + label: string; + description?: string | null; + previewMarkdown?: string | null; + href?: string | null; + attachmentId?: string | null; +} + +export interface RequestItemVerdictsPayload { + version: 1; + prompt: string; + detailsMarkdown?: string | null; + items: RequestItemVerdictsItem[]; + verdicts?: RequestItemVerdictValue[]; + requireReasonOn?: RequestItemVerdictValue[]; + reasonLabel?: string | null; + allowBulkApprove?: boolean; + supersedeOnUserComment?: boolean; + target?: RequestConfirmationTarget | null; +} + export interface RequestConfirmationResult { version: 1; outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; @@ -1104,6 +1128,24 @@ export interface RequestCheckboxConfirmationResult extends RequestConfirmationRe selectedOptionIds?: string[]; } +export interface RequestItemVerdictsResultItem { + id: string; + verdict: RequestItemVerdictValue; + reason?: string | null; + resolvedByUserId: string; + resolvedAt: Date | string; + commentId?: string | null; +} + +export interface RequestItemVerdictsResult { + version: 1; + outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled"; + complete: boolean; + items: RequestItemVerdictsResultItem[]; + commentId?: string | null; + staleTarget?: RequestConfirmationTarget | null; +} + export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { id: string; companyId: string; @@ -1145,23 +1187,32 @@ export interface RequestCheckboxConfirmationInteraction extends IssueThreadInter result?: RequestCheckboxConfirmationResult | null; } +export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBase { + kind: "request_item_verdicts"; + payload: RequestItemVerdictsPayload; + result?: RequestItemVerdictsResult | null; +} + export type IssueThreadInteraction = | SuggestTasksInteraction | AskUserQuestionsInteraction | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction; + | RequestCheckboxConfirmationInteraction + | RequestItemVerdictsInteraction; export type IssueThreadInteractionPayload = | SuggestTasksPayload | AskUserQuestionsPayload | RequestConfirmationPayload - | RequestCheckboxConfirmationPayload; + | RequestCheckboxConfirmationPayload + | RequestItemVerdictsPayload; export type IssueThreadInteractionResult = | SuggestTasksResult | AskUserQuestionsResult | RequestConfirmationResult - | RequestCheckboxConfirmationResult; + | RequestCheckboxConfirmationResult + | RequestItemVerdictsResult; export interface IssueAttachment { id: string; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index d614061c4f2..be0fb0051c9 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -164,6 +164,13 @@ export interface PluginEnvironmentDriverDeclaration { * runtime config. Omit to use the standard key for `templateRefKind`. */ templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; + /** + * Config paths (dot notation) that scope where captured templates live for + * this provider, such as an API endpoint. When one of these changes on a + * saved environment, captured templates cannot be re-linked to the updated + * config and a fresh capture is required. + */ + templateIdentityPaths?: string[]; /** Provider supports best-effort deletion/cleanup of captured templates. */ supportsTemplateDelete?: boolean; /** JSON Schema describing the driver's provider-specific configuration. */ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8b14b7e62c7..29a12a77dbd 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -364,11 +364,17 @@ export { requestCheckboxConfirmationOptionSchema, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema, + requestItemVerdictValueSchema, + requestItemVerdictsItemSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultItemSchema, + requestItemVerdictsResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, issueDocumentFormatSchema, @@ -391,6 +397,7 @@ export { type RejectIssueThreadInteraction, type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, + type SubmitIssueThreadInteractionVerdicts, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type IssueDocumentFormat, diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 09b0c344729..6f41db48344 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -28,6 +28,20 @@ describe("instance experimental settings validators", () => { const settings = instanceExperimentalSettingsSchema.parse({}); expect(settings.enableWorktreeRunExecution).toBe(false); + expect(settings.worktreeRunExecutionActivatedAt).toBeNull(); + expect(settings.worktreeRunExecutionActivationInstanceId).toBeNull(); + }); + + it("strips server-managed worktree run execution fields from patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "copied-instance", + }), + ).toEqual({ + enableWorktreeRunExecution: true, + }); }); it("defaults built-in agents off", () => { @@ -46,6 +60,22 @@ describe("instance experimental settings validators", () => { }); }); + it("defaults the decisions sidebar link off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableDecisions).toBe(false); + }); + + it("accepts decisions patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableDecisions: true, + }), + ).toEqual({ + enableDecisions: true, + }); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 612e5d1a19f..aef68079d37 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -51,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), enableBuiltInAgents: z.boolean().default(false), + enableDecisions: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), @@ -58,6 +59,8 @@ export const instanceExperimentalSettingsSchema = z.object({ enableWorkspaceBranchReconcileForward: z.boolean().default(true), enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true), enableWorktreeRunExecution: z.boolean().default(false), + worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null), + worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null), issueGraphLivenessAutoRecoveryLookbackHours: z .number() .int() @@ -66,7 +69,13 @@ export const instanceExperimentalSettingsSchema = z.object({ .default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS), }).strict(); -export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); +export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema + .omit({ + worktreeRunExecutionActivatedAt: true, + worktreeRunExecutionActivationInstanceId: true, + }) + .partial() + .strip(); export const patchInstanceSettingsSchema = z.object({ defaultEnvironmentId: z.string().uuid().nullable().optional(), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a89116a38bb..0726a0cf752 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -28,6 +28,7 @@ import { ISSUE_WATCHDOG_DISCOVERY_KINDS, MODEL_PROFILE_KEYS, REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, + REQUEST_ITEM_VERDICTS_ITEM_LIMIT, } from "../constants.js"; import { multilineTextSchema } from "./text.js"; import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js"; @@ -894,6 +895,121 @@ export const requestCheckboxConfirmationResultSchema = requestConfirmationResult } }); +export const requestItemVerdictValueSchema = z.enum(["approve", "reject", "defer"]); + +export const requestItemVerdictsItemSchema = z.object({ + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), + previewMarkdown: z.string().max(20000).nullable().optional(), + href: requestConfirmationHrefSchema.nullable().optional(), + attachmentId: z.string().uuid().nullable().optional(), +}); + +export const requestItemVerdictsPayloadSchema = z.object({ + version: z.literal(1), + prompt: z.string().trim().min(1).max(1000), + detailsMarkdown: z.string().max(20000).nullable().optional(), + items: z.array(requestItemVerdictsItemSchema) + .min(1) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), + verdicts: z.array(requestItemVerdictValueSchema) + .min(2) + .max(3) + .optional() + .default(["approve", "reject"]), + requireReasonOn: z.array(requestItemVerdictValueSchema) + .max(3) + .optional() + .default(["reject"]), + reasonLabel: z.string().trim().min(1).max(160).nullable().optional(), + allowBulkApprove: z.boolean().optional().default(true), + supersedeOnUserComment: z.boolean().optional(), + target: requestConfirmationTargetSchema.nullable().optional(), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, item] of value.items.entries()) { + if (itemIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Item ids must be unique within one item verdict request", + path: ["items", index, "id"], + }); + } + itemIds.add(item.id); + } + + const verdicts = new Set(); + for (const [index, verdict] of value.verdicts.entries()) { + if (verdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdicts must be unique", + path: ["verdicts", index], + }); + } + verdicts.add(verdict); + } + if (!verdicts.has("approve") || !verdicts.has("reject")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdicts must include approve and reject; defer is optional", + path: ["verdicts"], + }); + } + + const reasonVerdicts = new Set(); + for (const [index, verdict] of value.requireReasonOn.entries()) { + if (reasonVerdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "requireReasonOn must be unique", + path: ["requireReasonOn", index], + }); + continue; + } + reasonVerdicts.add(verdict); + if (!verdicts.has(verdict)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "requireReasonOn must reference enabled verdicts", + path: ["requireReasonOn", index], + }); + } + } +}); + +export const requestItemVerdictsResultItemSchema = z.object({ + id: z.string().trim().min(1).max(120), + verdict: requestItemVerdictValueSchema, + reason: z.string().trim().max(4000).nullable().optional(), + resolvedByUserId: z.string().trim().min(1).max(255), + resolvedAt: z.union([z.string().datetime(), z.date()]), + commentId: z.string().uuid().nullable().optional(), +}); + +export const requestItemVerdictsResultSchema = z.object({ + version: z.literal(1), + outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled"]), + complete: z.boolean(), + items: z.array(requestItemVerdictsResultItemSchema) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), + commentId: z.string().uuid().nullable().optional(), + staleTarget: requestConfirmationTargetSchema.nullable().optional(), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, item] of value.items.entries()) { + if (itemIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "result item ids must be unique", + path: ["items", index, "id"], + }); + } + itemIds.add(item.id); + } +}); + export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("suggest_tasks"), @@ -935,6 +1051,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), payload: requestCheckboxConfirmationPayloadSchema, }), + z.object({ + kind: z.literal("request_item_verdicts"), + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), + payload: requestItemVerdictsPayloadSchema, + }), ]); export type CreateIssueThreadInteraction = z.infer; @@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({ }); export type RespondIssueThreadInteraction = z.infer; +export const submitIssueThreadInteractionVerdictsSchema = z.object({ + verdicts: z.array(z.object({ + id: z.string().trim().min(1).max(120), + verdict: requestItemVerdictValueSchema, + reason: z.string().trim().max(4000).nullable().optional(), + })) + .min(1) + .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT), +}).superRefine((value, ctx) => { + const itemIds = new Set(); + for (const [index, verdict] of value.verdicts.entries()) { + if (itemIds.has(verdict.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "verdict item ids must be unique", + path: ["verdicts", index, "id"], + }); + } + itemIds.add(verdict.id); + } +}); +export type SubmitIssueThreadInteractionVerdicts = z.infer; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 91535b3b888..4637519d372 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -167,6 +167,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ supportsTemplateCapture: z.boolean().optional(), templateRefKind: z.string().min(1).max(100).optional(), templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(), + templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(), supportsTemplateDelete: z.boolean().optional(), configSchema: jsonSchemaSchema, }); diff --git a/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md b/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md index c6786939d70..b5167838920 100644 --- a/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md +++ b/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md @@ -1,6 +1,6 @@ --- name: reflection-coach -description: Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap. +description: Reflect on another agent's recent execution record and propose the smallest durable instruction, skill, or tool-description change. Use for evidence-backed coaching proposals, never hot-swaps. key: paperclipai/bundled/paperclip-operations/reflection-coach recommendedForRoles: - manager diff --git a/packages/skills-catalog/catalog/bundled/product/paperclip-capsules/SKILL.md b/packages/skills-catalog/catalog/bundled/product/paperclip-capsules/SKILL.md index 059eec505f3..f14a7e62ab4 100644 --- a/packages/skills-catalog/catalog/bundled/product/paperclip-capsules/SKILL.md +++ b/packages/skills-catalog/catalog/bundled/product/paperclip-capsules/SKILL.md @@ -1,6 +1,6 @@ --- name: paperclip-capsules -description: Generate, implement, or review Paperclip capsule visuals. Use when the user asks for Paperclip capsule art, capsule banks, agent capsule visuals, heartbeat status capsules, capsule identicons, capsule graphic-generator output, or validation of Paperclip capsule brand usage. +description: Generate, implement, or review Paperclip capsule visuals. Use for capsule art, agent capsules, heartbeat status capsules, identicons, capsule banks, or brand-usage validation. key: paperclipai/bundled/product/paperclip-capsules recommendedForRoles: - designer diff --git a/packages/skills-catalog/catalog/bundled/product/wireframe/SKILL.md b/packages/skills-catalog/catalog/bundled/product/wireframe/SKILL.md index cc8af1fbb4d..8058c4d48ee 100644 --- a/packages/skills-catalog/catalog/bundled/product/wireframe/SKILL.md +++ b/packages/skills-catalog/catalog/bundled/product/wireframe/SKILL.md @@ -1,6 +1,6 @@ --- name: wireframe -description: Produce low-fidelity black-and-white UI wireframes as standalone SVG files, optionally bundled into a single-page HTML viewer and published via the here-now skill. Use when the user asks to "wireframe X", "sketch a screen for", "draft a layout", "low-fi mockup", "rough mock", "make a page to view the wireframes", "build a viewer for these screens", or to "deploy / publish / host the wireframes". Do NOT use when the user wants production UI code, branded designs, hi-fi mockups, or animated/interactive prototypes — use frontend-design or similar instead. +description: Produce low-fidelity black-and-white UI wireframes as SVGs or viewer pages. Use when asked to wireframe, sketch a screen, draft a layout, make a low-fi mockup, or publish wireframes. key: paperclipai/bundled/product/wireframe recommendedForRoles: - designer diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index aa64ff92291..bb0bdfb0eb3 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-07-10T00:27:11.902Z", + "generatedAt": "2026-07-10T13:15:25.878Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -80,7 +80,7 @@ "category": "paperclip-operations", "slug": "reflection-coach", "name": "reflection-coach", - "description": "Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.", + "description": "Reflect on another agent's recent execution record and propose the smallest durable instruction, skill, or tool-description change. Use for evidence-backed coaching proposals, never hot-swaps.", "path": "catalog/bundled/paperclip-operations/reflection-coach", "entrypoint": "SKILL.md", "trustLevel": "markdown_only", @@ -102,11 +102,11 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 11903, - "sha256": "ca167eac8d1e89cadc8009b61a204368507e7edb7f2da0688fea6dba8223e189" + "sizeBytes": 11837, + "sha256": "86d6e382c32c626213b46c010cc7fc1ec460821a8c777cfa5b8e86fa9d6a86b2" } ], - "contentHash": "sha256:20381a898f05ceb668e305708dd33a03ce36aef0e00b923c01ae20ab785f04d2" + "contentHash": "sha256:1c7a82cd9638a1d845b238032da3ff4ad80c5b6a87dca46082f501fa4583db55" }, { "id": "paperclipai:bundled:paperclip-operations:task-planning", @@ -150,7 +150,7 @@ "category": "product", "slug": "paperclip-capsules", "name": "paperclip-capsules", - "description": "Generate, implement, or review Paperclip capsule visuals. Use when the user asks for Paperclip capsule art, capsule banks, agent capsule visuals, heartbeat status capsules, capsule identicons, capsule graphic-generator output, or validation of Paperclip capsule brand usage.", + "description": "Generate, implement, or review Paperclip capsule visuals. Use for capsule art, agent capsules, heartbeat status capsules, identicons, capsule banks, or brand-usage validation.", "path": "catalog/bundled/product/paperclip-capsules", "entrypoint": "SKILL.md", "trustLevel": "markdown_only", @@ -175,8 +175,8 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 5930, - "sha256": "eda40e8f29cae07c2ce8ad1cef3406eae243dbbf52069a85cfb56fe7999a25ff" + "sizeBytes": 5831, + "sha256": "dd659ccb46e105336eb00fbe689b1d5531347fc69493c3156390d40ee413c93e" }, { "path": "references/generator-workflows.md", @@ -203,7 +203,7 @@ "sha256": "be1f2eccf96b4d2deefe45b3bc1dd0a3597278472c2ffedadc43c57161e9103d" } ], - "contentHash": "sha256:e8f502f9556103bd575bb664a9d21b1004869990802e639a188a720012116909" + "contentHash": "sha256:a2b26cf6895affeeb70c85567a061094450a59bd3c4fdcf28ee65858599ac12c" }, { "id": "paperclipai:bundled:product:wireframe", @@ -212,7 +212,7 @@ "category": "product", "slug": "wireframe", "name": "wireframe", - "description": "Produce low-fidelity black-and-white UI wireframes as standalone SVG files, optionally bundled into a single-page HTML viewer and published via the here-now skill. Use when the user asks to \"wireframe X\", \"sketch a screen for\", \"draft a layout\", \"low-fi mockup\", \"rough mock\", \"make a page to view the wireframes\", \"build a viewer for these screens\", or to \"deploy / publish / host the wireframes\". Do NOT use when the user wants production UI code, branded designs, hi-fi mockups, or animated/interactive prototypes — use frontend-design or similar instead.", + "description": "Produce low-fidelity black-and-white UI wireframes as SVGs or viewer pages. Use when asked to wireframe, sketch a screen, draft a layout, make a low-fi mockup, or publish wireframes.", "path": "catalog/bundled/product/wireframe", "entrypoint": "SKILL.md", "trustLevel": "assets", @@ -235,8 +235,8 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 11887, - "sha256": "a910d038d8cdd13615b82f44a2195b781ebccf01c49f9c58babd43b0701fe644" + "sizeBytes": 11509, + "sha256": "6992047cc97b67211cc8c1183718ab9aa26b8afc34c70d54d20558c215daaf48" }, { "path": "assets/site-template.html", @@ -275,7 +275,7 @@ "sha256": "017260d2216859d57e5fa869e0efc6b88c7ed4d8a8a4f5af04fe89a3ef2445bd" } ], - "contentHash": "sha256:0bd9a9fdc656d529e3f97c00cd504dcf72d3a4fecb8b0504ca2fe3e00d63287f" + "contentHash": "sha256:7106d84fc9db2053090f692706e41cfc586e7dd85a44f568e3319c4eefbdde46" }, { "id": "paperclipai:bundled:quality:qa-acceptance", diff --git a/packages/skills-catalog/src/shipped-catalog.test.ts b/packages/skills-catalog/src/shipped-catalog.test.ts index 1a29bbb4739..0dfe0221b44 100644 --- a/packages/skills-catalog/src/shipped-catalog.test.ts +++ b/packages/skills-catalog/src/shipped-catalog.test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.js"; @@ -21,7 +23,68 @@ const EXPECTED_OPTIONAL_KEYS = [ "paperclipai/optional/research/last30days", ]; +const MAX_FRONTMATTER_DESCRIPTION_LENGTH = 300; +const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); +const SKILL_FRONTMATTER_ROOTS = [ + path.join(REPO_ROOT, ".agents"), + path.join(REPO_ROOT, "skills"), + path.join(REPO_ROOT, "packages/adapters"), + path.join(REPO_ROOT, "packages/plugins"), + path.join(REPO_ROOT, "packages/skills-catalog/catalog"), + path.join(REPO_ROOT, "packages/teams-catalog/catalog"), +]; + +function listSkillFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) return listSkillFiles(entryPath); + if (entry.isFile() && entry.name === "SKILL.md") return [entryPath]; + return []; + }); +} + +function readFrontmatterDescription(markdown: string): string | null { + const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + + const lines = match[1]!.split(/\r?\n/); + const descriptionIndex = lines.findIndex((line) => line.startsWith("description:")); + if (descriptionIndex === -1) return null; + + const inlineValue = lines[descriptionIndex]!.slice("description:".length).trim(); + if (/^[>|][+-]?$/.test(inlineValue)) { + const descriptionLines: string[] = []; + for (let index = descriptionIndex + 1; index < lines.length; index += 1) { + const line = lines[index]!; + if (/^[A-Za-z0-9_-]+:/.test(line)) break; + descriptionLines.push(line.trim()); + } + return descriptionLines.join(" ").replace(/\s+/g, " ").trim(); + } + + return inlineValue.replace(/^['"]|['"]$/g, ""); +} + describe("shipped skills catalog", () => { + it("keeps repo and catalog skill descriptions within the prompt budget cap", () => { + const violations: string[] = []; + for (const skillFile of SKILL_FRONTMATTER_ROOTS.flatMap(listSkillFiles)) { + const description = readFrontmatterDescription(readFileSync(skillFile, "utf8")); + if (!description) { + violations.push(`${path.relative(REPO_ROOT, skillFile)} is missing a frontmatter description`); + } else if (description.length > MAX_FRONTMATTER_DESCRIPTION_LENGTH) { + violations.push(`${path.relative(REPO_ROOT, skillFile)} description is ${description.length} chars`); + } + } + for (const skill of catalogSkills) { + if (skill.description.length > MAX_FRONTMATTER_DESCRIPTION_LENGTH) { + violations.push(`${skill.key} generated description is ${skill.description.length} chars`); + } + } + + expect(violations).toEqual([]); + }); + it("ships the expected bundled and optional skill set", () => { const bundledKeys = catalogSkills .filter((skill) => skill.kind === "bundled") diff --git a/scripts/screenshot-verdicts.mjs b/scripts/screenshot-verdicts.mjs new file mode 100644 index 00000000000..85c295b1ccc --- /dev/null +++ b/scripts/screenshot-verdicts.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Screenshot the C3 per-item verdict card stories (PAP-13249) at both +// viewports and both themes against a served storybook-static (see PAP-13249). +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const BASE = "http://localhost:6102/iframe.html"; +const PREFIX = "chat-comments-issue-thread-interactions"; +const OUT_DIR = process.argv[2] || "screenshots/pap-13249"; + +const stories = [ + { id: "item-verdicts-pending", label: "s1-s2-pending" }, + { id: "item-verdicts-partial", label: "s4-partial" }, + { id: "item-verdicts-complete", label: "s5-complete" }, + { id: "item-verdicts-superseded", label: "s6-superseded" }, + { id: "item-verdicts-many-items", label: "s7-many" }, +]; +const viewports = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]; +const themes = ["light", "dark"]; + +await fs.mkdir(path.resolve(OUT_DIR), { recursive: true }); +const executablePath = process.env.CHROME_BIN || undefined; +const browser = await chromium.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--headless=new"], +}); +try { + for (const vp of viewports) { + for (const theme of themes) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + for (const story of stories) { + const url = `${BASE}?id=${PREFIX}--${story.id}&viewMode=story&globals=theme:${theme}`; + await page.goto(url, { waitUntil: "networkidle" }); + await page.waitForTimeout(800); + const out = path.join(OUT_DIR, `${story.label}_${vp.name}_${theme}.png`); + await page.screenshot({ path: out, fullPage: true }); + console.log(`Wrote ${out}`); + } + await ctx.close(); + } + } +} finally { + await browser.close(); +} diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index e82eec5c36e..2db24439dd9 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -48,7 +48,10 @@ describe("adapter model listing", () => { const models = await listAdapterModels("codex_local"); expect(models).toEqual(codexFallbackModels); - expect(models.some((model) => model.id === "gpt-5.5")).toBe(true); + expect(models.some((model) => model.id === "gpt-5.6")).toBe(true); + expect(models.some((model) => model.id === "gpt-5.6-sol")).toBe(true); + expect(models.some((model) => model.id === "gpt-5.6-terra")).toBe(true); + expect(models.some((model) => model.id === "gpt-5.6-luna")).toBe(true); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -155,7 +158,7 @@ describe("adapter model listing", () => { .mockResolvedValueOnce({ ok: true, json: async () => ({ - data: [{ id: "gpt-5.5" }], + data: [{ id: "gpt-5.6-terra" }], }), } as Response); @@ -164,7 +167,8 @@ describe("adapter model listing", () => { expect(fetchSpy).toHaveBeenCalledTimes(2); expect(initial.some((model) => model.id === "gpt-5")).toBe(true); - expect(refreshed.some((model) => model.id === "gpt-5.5")).toBe(true); + expect(refreshed.some((model) => model.id === "gpt-5.6-terra")).toBe(true); + expect(refreshed.some((model) => model.id === "gpt-5.6-luna")).toBe(true); }); it("falls back to static codex models when OpenAI model discovery fails", async () => { diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index dc3e7ce445d..de490e11bad 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -137,7 +137,15 @@ describe("agent test-environment route", () => { mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({ lease: { id: "lease-1", - metadata: { remoteCwd: "/home/user/paperclip-workspace" }, + provider: "daytona", + providerLeaseId: "provider-lease-1", + metadata: { + remoteCwd: "/home/user/paperclip-workspace", + sandboxId: "sandbox-1", + sandboxName: "paperclip-probe", + templateKind: "snapshot", + templateRef: "snapshot-1", + }, }, leaseContext: { executionWorkspaceId: null, @@ -263,6 +271,19 @@ describe("agent test-environment route", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(testEnvironmentSpy).toHaveBeenCalledTimes(1); + // Test leases boot fresh and stay debuggable: never resume a retained + // agent lease, archive (not delete) the sandbox on release. + expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith( + expect.objectContaining({ + applyCustomImageTemplate: true, + environment: expect.objectContaining({ + config: expect.objectContaining({ + reuseLease: false, + archiveOnRelease: true, + }), + }), + }), + ); expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({ executionTarget: expect.objectContaining({ kind: "remote", @@ -271,6 +292,24 @@ describe("agent test-environment route", () => { environmentName: "Sandbox QA", }); expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" }); + expect(res.body.checks).toEqual([ + expect.objectContaining({ + code: "sandbox_test_identity", + level: "info", + message: 'Sandbox test identity for "Sandbox QA".', + detail: expect.stringContaining("paperclipLeaseId=lease-1"), + }), + expect.objectContaining({ + code: "external_test_hello_probe_passed", + level: "info", + message: "OK", + }), + ]); + expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1"); + expect(res.body.checks[0].detail).toContain("provider=daytona"); + expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1"); + expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe"); + expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1"); expect(mockReleaseRunLease).toHaveBeenCalledWith({ environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), lease: expect.objectContaining({ id: "lease-1" }), diff --git a/server/src/__tests__/api-compression.test.ts b/server/src/__tests__/api-compression.test.ts index 1e727caeb3a..babf996b03c 100644 --- a/server/src/__tests__/api-compression.test.ts +++ b/server/src/__tests__/api-compression.test.ts @@ -105,6 +105,27 @@ function buildApp() { res.write(chunk.slice(0, chunk.length / 2)); res.end(chunk.slice(chunk.length / 2)); }); + // Mirrors better-call's setResponse (Better Auth sign-in/sign-up): headers + // are committed with writeHead() first, then the web-stream body arrives as + // Uint8Array chunks. + app.get("/api/auth-bridge", (req, res) => { + const body = JSON.stringify({ + token: "t".repeat(Number(req.query.pad ?? 0)), + user: { name: "Dotta", email: "dotta@example.test" }, + }); + res.setHeader("content-type", "application/json"); + res.setHeader("set-cookie", "workspace.session_token=abc; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax"); + res.writeHead(200); + const bytes = new TextEncoder().encode(body); + res.write(bytes.subarray(0, 16)); + res.write(bytes.subarray(16)); + res.end(); + }); + app.get("/api/uint8-json", (req, res) => { + const body = JSON.stringify(issueListFixture(Number(req.query.count ?? 1))); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(new TextEncoder().encode(body)); + }); return app; } @@ -184,4 +205,37 @@ describe("API compression middleware", () => { expect(res.headers["content-encoding"]).toBeUndefined(); expect(JSON.parse(res.body.toString("utf8"))).toHaveLength(500); }); + + it("delivers small writeHead+Uint8Array auth responses byte-for-byte", async () => { + const res = await requestRaw(buildApp(), "/api/auth-bridge", { + "accept-encoding": "gzip, deflate", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["set-cookie"]).toBeDefined(); + expect(res.headers["content-encoding"]).toBeUndefined(); + expect(JSON.parse(res.body.toString("utf8")).user.email).toBe("dotta@example.test"); + }); + + it("does not drop the connection for large writeHead+Uint8Array auth responses", async () => { + const res = await requestRaw(buildApp(), "/api/auth-bridge?pad=2000", { + "accept-encoding": "gzip, deflate", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-encoding"]).toBeUndefined(); + const parsed = JSON.parse(res.body.toString("utf8")); + expect(parsed.token).toHaveLength(2000); + expect(parsed.user.email).toBe("dotta@example.test"); + }); + + it("compresses large Uint8Array bodies without corrupting them", async () => { + const res = await requestRaw(buildApp(), "/api/uint8-json?count=500", { + "accept-encoding": "gzip", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-encoding"]).toBe("gzip"); + expect(JSON.parse(gunzipSync(res.body).toString("utf8"))).toHaveLength(500); + }); }); diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts new file mode 100644 index 00000000000..694929f7457 --- /dev/null +++ b/server/src/__tests__/attention-service.test.ts @@ -0,0 +1,961 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + approvals, + assets, + budgetIncidents, + budgetPolicies, + companies, + createDb, + documents, + heartbeatRunEvents, + heartbeatRuns, + inboxDismissals, + invites, + issueApprovals, + issueAttachments, + issueDocuments, + issueRecoveryActions, + issueRelations, + issueThreadInteractions, + issues, + joinRequests, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { attentionRoutes } from "../routes/attention.js"; +import { attentionService } from "../services/attention.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres attention service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("attention service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(inboxDismissals); + await db.delete(issueThreadInteractions); + await db.delete(issueApprovals); + await db.delete(issueAttachments); + await db.delete(issueDocuments); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(budgetIncidents); + await db.delete(budgetPolicies); + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(issueRecoveryActions); + await db.delete(issueRelations); + await db.delete(activityLog); + await db.delete(approvals); + await db.delete(issues); + await db.delete(assets); + await db.delete(documents); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany(prefix = "ATN") { + const companyId = randomUUID(); + const workerId = randomUUID(); + const reviewerId = randomUUID(); + const errorAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: `${prefix} Co`, + issuePrefix: prefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: workerId, + companyId, + name: "Worker", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: reviewerId, + companyId, + name: "Reviewer", + role: "qa", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: errorAgentId, + companyId, + name: "Broken Agent", + role: "engineer", + status: "error", + errorReason: "adapter config missing", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + return { companyId, workerId, reviewerId, errorAgentId, prefix }; + } + + async function insertIssue(input: { + companyId: string; + id?: string; + identifier: string; + title: string; + status: string; + priority?: string; + parentId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + originKind?: string; + originId?: string | null; + originFingerprint?: string; + projectId?: string | null; + projectWorkspaceId?: string | null; + executionState?: Record | null; + updatedAt?: Date; + createdAt?: Date; + }) { + const id = input.id ?? randomUUID(); + await db.insert(issues).values({ + id, + companyId: input.companyId, + identifier: input.identifier, + title: input.title, + status: input.status, + priority: input.priority ?? "medium", + parentId: input.parentId ?? null, + projectId: input.projectId ?? null, + projectWorkspaceId: input.projectWorkspaceId ?? null, + assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: input.assigneeUserId ?? null, + originKind: input.originKind ?? "manual", + originId: input.originId ?? null, + originFingerprint: input.originFingerprint ?? "default", + executionState: input.executionState ?? null, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return id; + } + + function pendingUserExecutionState(userId = "board-user") { + return { + status: "pending", + currentStageId: null, + currentStageIndex: null, + currentStageType: "review", + currentParticipant: { type: "user", userId }, + returnAssignee: null, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + monitor: null, + }; + } + + function pendingAgentExecutionState(agentId: string) { + return { + ...pendingUserExecutionState(), + currentParticipant: { type: "agent", agentId }, + }; + } + + it("returns ranked decision-only items for every active source and excludes non-human or transient rows", async () => { + const { companyId, workerId, reviewerId } = await seedCompany("ATN"); + const baseTime = new Date("2026-07-09T12:00:00.000Z"); + const interactionIssueId = await insertIssue({ + companyId, + identifier: "ATN-1", + title: "Needs interaction", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const recoverySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-2", + title: "Needs recovery", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const agentRecoverySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-21", + title: "Agent-owned recovery source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const productivitySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-3", + title: "Needs productivity review source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const agentProductivitySourceIssueId = await insertIssue({ + companyId, + identifier: "ATN-31", + title: "Agent productivity review source", + status: "in_progress", + assigneeAgentId: workerId, + updatedAt: baseTime, + }); + const blockerParentId = await insertIssue({ + companyId, + identifier: "ATN-4", + title: "Blocked parent", + status: "blocked", + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }); + const blockerLeafId = await insertIssue({ + companyId, + identifier: "ATN-5", + title: "Stalled review blocker", + status: "in_review", + assigneeAgentId: reviewerId, + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerLeafId, + relatedIssueId: blockerParentId, + type: "blocks", + }); + const reviewUserIssueId = await insertIssue({ + companyId, + identifier: "ATN-6", + title: "Human review", + status: "in_review", + executionState: pendingUserExecutionState(), + updatedAt: new Date("2026-07-09T12:06:00.000Z"), + }); + await insertIssue({ + companyId, + identifier: "ATN-7", + title: "Agent review excluded", + status: "in_review", + executionState: pendingAgentExecutionState(reviewerId), + updatedAt: new Date("2026-07-09T12:07:00.000Z"), + }); + + const pendingApprovalId = randomUUID(); + await db.insert(approvals).values([ + { + id: pendingApprovalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Designer" }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + { + id: randomUUID(), + companyId, + type: "hire_agent", + status: "revision_requested", + payload: { title: "Revision requested" }, + createdAt: new Date("2026-07-09T12:02:00.000Z"), + updatedAt: new Date("2026-07-09T12:02:00.000Z"), + }, + ]); + + await db.insert(issueThreadInteractions).values([ + { + id: randomUUID(), + companyId, + issueId: interactionIssueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Pick a launch date", + payload: { version: 1, questions: [] }, + createdAt: new Date("2026-07-09T12:03:00.000Z"), + updatedAt: new Date("2026-07-09T12:03:00.000Z"), + }, + { + id: randomUUID(), + companyId, + issueId: interactionIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee", + title: "Already accepted", + payload: { version: 1, prompt: "Already done" }, + createdAt: new Date("2026-07-09T12:03:30.000Z"), + updatedAt: new Date("2026-07-09T12:03:30.000Z"), + }, + ]); + + const inviteId = randomUUID(); + await db.insert(invites).values({ + id: inviteId, + companyId, + tokenHash: `hash-${inviteId}`, + allowedJoinTypes: "both", + expiresAt: new Date("2026-07-10T00:00:00.000Z"), + }); + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "new@paperclip.test", + createdAt: new Date("2026-07-09T12:04:00.000Z"), + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }); + + await db.insert(issueRecoveryActions).values([ + { + id: randomUUID(), + companyId, + sourceIssueId: recoverySourceIssueId, + kind: "missing_disposition", + status: "escalated", + ownerType: "board", + ownerAgentId: null, + ownerUserId: null, + cause: "missing_disposition", + fingerprint: "human-recovery", + evidence: {}, + nextAction: "Choose the final disposition.", + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + { + id: randomUUID(), + companyId, + sourceIssueId: agentRecoverySourceIssueId, + kind: "stranded_assigned_issue", + status: "active", + ownerType: "agent", + ownerAgentId: workerId, + ownerUserId: null, + cause: "stranded", + fingerprint: "agent-recovery", + evidence: {}, + nextAction: "Agent should self-heal.", + createdAt: new Date("2026-07-09T12:05:30.000Z"), + updatedAt: new Date("2026-07-09T12:05:30.000Z"), + }, + ]); + + await insertIssue({ + companyId, + identifier: "ATN-8", + title: "Human productivity review", + status: "todo", + priority: "high", + parentId: productivitySourceIssueId, + assigneeUserId: "board-user", + originKind: "issue_productivity_review", + originId: productivitySourceIssueId, + originFingerprint: `productivity-review:${productivitySourceIssueId}`, + updatedAt: new Date("2026-07-09T12:08:00.000Z"), + }); + await insertIssue({ + companyId, + identifier: "ATN-9", + title: "Agent productivity review excluded", + status: "todo", + priority: "high", + parentId: agentProductivitySourceIssueId, + assigneeAgentId: workerId, + originKind: "issue_productivity_review", + originId: agentProductivitySourceIssueId, + originFingerprint: `productivity-review-agent:${agentProductivitySourceIssueId}`, + updatedAt: new Date("2026-07-09T12:08:30.000Z"), + }); + + const exhaustedRunId = randomUUID(); + const transientRunId = randomUUID(); + await db.insert(heartbeatRuns).values([ + { + id: exhaustedRunId, + companyId, + agentId: workerId, + invocationSource: "automation", + status: "failed", + error: "adapter failed", + errorCode: "adapter_failed", + contextSnapshot: { issueId: reviewUserIssueId }, + scheduledRetryAttempt: 4, + scheduledRetryReason: "transient_failure", + createdAt: new Date("2026-07-09T12:09:00.000Z"), + updatedAt: new Date("2026-07-09T12:09:00.000Z"), + finishedAt: new Date("2026-07-09T12:09:00.000Z"), + }, + { + id: transientRunId, + companyId, + agentId: reviewerId, + invocationSource: "automation", + status: "failed", + error: "will retry", + errorCode: "provider_quota", + contextSnapshot: { issueId: interactionIssueId }, + createdAt: new Date("2026-07-09T12:09:30.000Z"), + updatedAt: new Date("2026-07-09T12:09:30.000Z"), + finishedAt: new Date("2026-07-09T12:09:30.000Z"), + }, + ]); + await db.insert(heartbeatRunEvents).values({ + companyId, + runId: exhaustedRunId, + agentId: workerId, + seq: 1, + eventType: "lifecycle", + message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued", + payload: { retryReason: "transient_failure", maxAttempts: 4 }, + createdAt: new Date("2026-07-09T12:09:01.000Z"), + }); + + const softPolicy85Id = randomUUID(); + const softPolicy84Id = randomUUID(); + const hardPolicyId = randomUUID(); + await db.insert(budgetPolicies).values([ + { + id: softPolicy85Id, + companyId, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: 100, + }, + { + id: softPolicy84Id, + companyId, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "lifetime", + amount: 100, + }, + { + id: hardPolicyId, + companyId, + scopeType: "agent", + scopeId: workerId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: 100, + }, + ]); + await db.insert(budgetIncidents).values([ + { + companyId, + policyId: softPolicy85Id, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-07-01T00:00:00.000Z"), + windowEnd: new Date("2026-08-01T00:00:00.000Z"), + thresholdType: "soft", + amountLimit: 100, + amountObserved: 85, + status: "open", + createdAt: new Date("2026-07-09T12:10:00.000Z"), + updatedAt: new Date("2026-07-09T12:10:00.000Z"), + }, + { + companyId, + policyId: softPolicy84Id, + scopeType: "company", + scopeId: companyId, + metric: "billed_cents", + windowKind: "lifetime", + windowStart: new Date("1970-01-01T00:00:00.000Z"), + windowEnd: new Date("9999-01-01T00:00:00.000Z"), + thresholdType: "soft", + amountLimit: 100, + amountObserved: 84, + status: "open", + createdAt: new Date("2026-07-09T12:10:30.000Z"), + updatedAt: new Date("2026-07-09T12:10:30.000Z"), + }, + { + companyId, + policyId: hardPolicyId, + scopeType: "agent", + scopeId: workerId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-07-01T00:00:00.000Z"), + windowEnd: new Date("2026-08-01T00:00:00.000Z"), + thresholdType: "hard", + amountLimit: 100, + amountObserved: 100, + status: "open", + createdAt: new Date("2026-07-09T12:11:00.000Z"), + updatedAt: new Date("2026-07-09T12:11:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.totalCount).toBe(11); + expect(feed.countsBySourceKind).toMatchObject({ + approval: 1, + issue_thread_interaction: 1, + join_request: 1, + recovery_action: 1, + productivity_review: 1, + blocker_attention: 1, + review: 1, + failed_run: 1, + budget_alert: 2, + agent_error_alert: 1, + }); + expect(feed.items.map((item) => item.sourceKind)).toEqual(expect.arrayContaining([ + "approval", + "issue_thread_interaction", + "join_request", + "recovery_action", + "productivity_review", + "blocker_attention", + "review", + "failed_run", + "budget_alert", + "agent_error_alert", + ])); + for (const item of feed.items) { + expect(item.dedupKey).toBeTruthy(); + expect(item.dismissalKey).toBe(`attention:${item.dedupKey}`); + expect(item.whyNow).toBeTruthy(); + expect(item.entryRule).toBeTruthy(); + expect(item.exitRule).toBeTruthy(); + expect(item.decisionVerbs.length).toBeGreaterThan(0); + expect(item.rank).toBeGreaterThan(0); + } + expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false); + expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false); + expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false); + expect(feed.items.some((item) => + item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota" + )).toBe(false); + expect(feed.items.find((item) => item.sourceKind === "approval")?.detail).toMatchObject({ + kind: "approval", + approvalType: "hire_agent", + summaryExcerpt: "Hire Designer", + }); + expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.detail).toMatchObject({ + kind: "questions", + questionCount: 0, + }); + expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({ + kind: "blocker", + blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" }, + }); + expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({ + kind: "failed_run", + agentName: "Worker", + failureReasonExcerpt: "adapter failed", + }); + expect(feed.items.find((item) => + item.sourceKind === "budget_alert" && item.detail?.kind === "budget" && item.detail.observedPercent === 100 + )).toBeTruthy(); + expect(feed.items.find((item) => item.sourceKind === "agent_error_alert")?.detail).toMatchObject({ + kind: "agent_error", + agentName: "Broken Agent", + failureReasonExcerpt: "adapter config missing", + }); + }); + + it("suppresses failed-run attention after a newer run for the same issue", async () => { + const { companyId, workerId } = await seedCompany("ATN"); + const issueId = await insertIssue({ + companyId, + identifier: "ATN-1", + title: "Recoverable task", + status: "in_progress", + }); + const failedRunId = randomUUID(); + const failedAt = new Date("2026-07-09T12:00:00.000Z"); + + await db.insert(heartbeatRuns).values([ + { + id: failedRunId, + companyId, + agentId: workerId, + invocationSource: "automation", + status: "failed", + error: "adapter failed", + contextSnapshot: { issueId }, + createdAt: failedAt, + updatedAt: failedAt, + finishedAt: failedAt, + }, + { + id: randomUUID(), + companyId, + agentId: workerId, + invocationSource: "automation", + status: "succeeded", + contextSnapshot: { issueId }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + finishedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + ]); + await db.insert(heartbeatRunEvents).values({ + companyId, + runId: failedRunId, + agentId: workerId, + seq: 1, + eventType: "lifecycle", + message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued", + createdAt: new Date("2026-07-09T12:00:01.000Z"), + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.items.filter((item) => item.sourceKind === "failed_run")).toEqual([]); + }); + + it("enriches interaction details with project, workspace, plan metadata, and images", async () => { + const { companyId, workerId } = await seedCompany("ATE"); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const issueId = randomUUID(); + const planDocumentId = randomUUID(); + const planRevisionId = randomUUID(); + const imageAssetIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Attention Project", + status: "in_progress", + color: "#0f766e", + icon: "rocket", + }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Preview workspace", + sourceType: "local_path", + isPrimary: true, + }); + await insertIssue({ + id: issueId, + companyId, + identifier: "ATE-1", + title: "Approve launch plan", + status: "in_progress", + assigneeAgentId: workerId, + projectId, + projectWorkspaceId: workspaceId, + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(documents).values({ + id: planDocumentId, + companyId, + title: "Launch Plan", + format: "markdown", + latestBody: "# Summary\n\nThis plan explains the launch checklist, rollout owner, QA gates, and risk controls for the homepage release.", + latestRevisionId: planRevisionId, + latestRevisionNumber: 2, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId: planDocumentId, + key: "plan", + }); + await db.insert(assets).values([ + { id: imageAssetIds[0], companyId, provider: "local_disk", objectKey: "img-1", contentType: "image/png", byteSize: 10, sha256: "a".repeat(64), originalFilename: "one.png" }, + { id: imageAssetIds[1], companyId, provider: "local_disk", objectKey: "img-2", contentType: "image/jpeg", byteSize: 10, sha256: "b".repeat(64), originalFilename: "two.jpg" }, + { id: imageAssetIds[2], companyId, provider: "local_disk", objectKey: "img-3", contentType: "image/gif", byteSize: 10, sha256: "c".repeat(64), originalFilename: "three.gif" }, + { id: imageAssetIds[3], companyId, provider: "local_disk", objectKey: "img-4", contentType: "image/png", byteSize: 10, sha256: "d".repeat(64), originalFilename: "four.png" }, + ]); + await db.insert(issueAttachments).values(imageAssetIds.map((assetId, index) => ({ + companyId, + issueId, + assetId, + createdAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + updatedAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + }))); + + const planInteractionId = randomUUID(); + const questionsInteractionId = randomUUID(); + const tasksInteractionId = randomUUID(); + const checkboxInteractionId = randomUUID(); + const verdictInteractionId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: planInteractionId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Approve the plan", + payload: { + version: 1, + prompt: "Approve plan?", + acceptLabel: "Approve plan", + rejectLabel: "Request changes", + target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId }, + }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + { + id: questionsInteractionId, + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Questions", + payload: { + version: 1, + questions: [ + { id: "q1", prompt: "Which auth provider should we use?", selectionMode: "single", options: [] }, + { id: "q2", prompt: "Should we add a fallback?", selectionMode: "single", options: [] }, + ], + }, + createdAt: new Date("2026-07-09T12:02:00.000Z"), + updatedAt: new Date("2026-07-09T12:02:00.000Z"), + }, + { + id: tasksInteractionId, + companyId, + issueId, + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Tasks", + payload: { version: 1, tasks: [{ clientKey: "t1", title: "Build API" }, { clientKey: "t2", title: "Wire UI" }] }, + createdAt: new Date("2026-07-09T12:03:00.000Z"), + updatedAt: new Date("2026-07-09T12:03:00.000Z"), + }, + { + id: checkboxInteractionId, + companyId, + issueId, + kind: "request_checkbox_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Checkbox", + payload: { version: 1, prompt: "Select rollout regions", options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }] }, + createdAt: new Date("2026-07-09T12:04:00.000Z"), + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }, + { + id: verdictInteractionId, + companyId, + issueId, + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Verdicts", + payload: { version: 1, prompt: "Approve these screenshots", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }] }, + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction"); + const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item])); + + const planItem = detailsByKind.get("plan_approval"); + expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan"); + expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" }); + expect(planItem?.decisionVerbs).toEqual([ + expect.objectContaining({ id: "accept", label: "Approve plan" }), + expect.objectContaining({ id: "reject", label: "Request changes" }), + ]); + expect(planItem?.project).toMatchObject({ + id: projectId, + name: "Attention Project", + color: "#0f766e", + icon: "rocket", + }); + expect(planItem?.project?.urlKey).toEqual(expect.any(String)); + expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" }); + expect(planItem?.detail).toMatchObject({ + kind: "plan_approval", + issueTitle: "Approve launch plan", + planTitle: "Launch Plan", + summaryExcerpt: expect.stringContaining("launch checklist"), + images: imageAssetIds.slice(0, 3).map((assetId) => ({ assetId, alt: expect.any(String) })), + }); + expect(detailsByKind.get("questions")?.detail).toMatchObject({ + kind: "questions", + questionCount: 2, + firstQuestionText: "Which auth provider should we use?", + }); + expect(detailsByKind.get("suggested_tasks")?.detail).toMatchObject({ + kind: "suggested_tasks", + taskCount: 2, + firstTaskTitle: "Build API", + }); + expect(detailsByKind.get("checkbox_confirmation")?.detail).toMatchObject({ + kind: "checkbox_confirmation", + optionCount: 2, + promptExcerpt: "Select rollout regions", + }); + expect(detailsByKind.get("item_verdicts")?.detail).toMatchObject({ + kind: "item_verdicts", + itemCount: 2, + promptExcerpt: "Approve these screenshots", + }); + }); + + it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => { + const { companyId } = await seedCompany("ATD"); + const approvalId = randomUUID(); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Writer" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(inboxDismissals).values({ + companyId, + userId: "board-user", + itemKey: `attention:approval:${approvalId}`, + dismissedAt: new Date("2026-07-09T13:00:00.000Z"), + }); + + await expect(attentionService(db).list(companyId, { userId: "board-user" })) + .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed + const includeDismissedFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(includeDismissedFeed.totalCount).toBe(2); + expect(includeDismissedFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "dismiss", isActive: true, snoozedUntil: null }); + + await db + .update(approvals) + .set({ updatedAt: new Date("2026-07-09T14:00:00.000Z") }) + .where(eq(approvals.id, approvalId)); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true); + }); + + it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => { + const { companyId } = await seedCompany("ATS"); + const approvalId = randomUUID(); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Researcher" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(inboxDismissals).values({ + companyId, + userId: "board-user", + itemKey: `attention:approval:${approvalId}`, + kind: "snooze", + dismissedAt: new Date("2099-01-01T00:00:00.000Z"), + snoozedUntil: new Date("2099-01-02T00:00:00.000Z"), + }); + + await expect(attentionService(db).list(companyId, { userId: "board-user" })) + .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed + const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" }); + + await db + .update(inboxDismissals) + .set({ snoozedUntil: new Date("2020-01-01T00:00:00.000Z") }) + .where(eq(inboxDismissals.itemKey, `attention:approval:${approvalId}`)); + + const visibleFeed = await attentionService(db).list(companyId, { userId: "board-user" }); + const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`); + expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false }); + expect(visibleApproval).toBeTruthy(); + }); + + it("serves the route for board users and rejects agent callers", async () => { + const { companyId } = await seedCompany("ATR"); + + function app(actor: Record) { + const testApp = express(); + testApp.use(express.json()); + testApp.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + testApp.use("/api", attentionRoutes(db)); + testApp.use(errorHandler); + return testApp; + } + + const board = { + type: "board", + source: "local_implicit", + userId: "board-user", + companyIds: [companyId], + isInstanceAdmin: false, + }; + const agent = { + type: "agent", + source: "agent_key", + companyId, + agentId: randomUUID(), + runId: null, + }; + + await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200); + await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403); + }); +}); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index b42ef25822c..3ee9ce13ce5 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -1,5 +1,8 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { activityLog, @@ -32,6 +35,7 @@ import { builtInAgentService, deriveBuiltInAgentStatus, listBuiltInAgentDefinitions, + readBuiltInTextWithFallback, reconcileBuiltInAgentsOnStartup, validateBuiltInAgentDefinitions, } from "../services/built-in-agents.ts"; @@ -51,6 +55,55 @@ if (!embeddedPostgresSupport.supported) { ); } +describe("built-in agent asset loading", () => { + it("uses the first readable candidate path", () => { + const dir = mkdtempSync(path.join(tmpdir(), "paperclip-built-in-agent-")); + try { + const first = path.join(dir, "missing.md"); + const second = path.join(dir, "asset.md"); + writeFileSync(second, "asset text", "utf8"); + + expect(readBuiltInTextWithFallback(`asset:${randomUUID()}`, [first, second], "fallback text")).toBe("asset text"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back instead of throwing when built-in agent files are missing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const label = `missing:${randomUUID()}`; + + try { + expect(readBuiltInTextWithFallback(label, [path.join(tmpdir(), label, "AGENTS.md")], "fallback text")).toBe( + "fallback text", + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`Built-in agent asset ${label} was not readable`)); + } finally { + warn.mockRestore(); + } + }); + + it("warns about non-missing read errors before falling back", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const dir = mkdtempSync(path.join(tmpdir(), "paperclip-built-in-agent-")); + const label = "unreadable:" + randomUUID(); + + try { + const directoryPath = path.join(dir, "asset.md"); + mkdirSync(directoryPath); + + expect(readBuiltInTextWithFallback(label, [directoryPath], "fallback text")).toBe("fallback text"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("read error on " + directoryPath + ":")); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Built-in agent asset " + label + " was not readable"), + ); + } finally { + warn.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describeEmbeddedPostgres("built-in agents", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 883e4c7f32d..51a8e4691ad 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -14,6 +14,8 @@ import { environmentCustomImageService, } from "../services/environment-custom-images.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + environmentCustomImageTemplateMatchesBaseConfig, fingerprintEnvironmentSandboxProviderConfig, } from "../services/environment-custom-image-runtime.js"; import { @@ -55,6 +57,7 @@ function pluginManifest() { field: "customTemplate", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object" }, }, @@ -436,7 +439,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { provider: "fake-plugin", templateKind: "snapshot", templateRef: "snapshot-active", - sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any), + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), status: "active", }); const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { @@ -448,20 +453,54 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(resolved.config).toMatchObject({ snapshot: "snapshot-active" }); expect(resolved.config).not.toHaveProperty("image"); - // A stored fingerprint that no longer matches the current base config (e.g. the - // config dialog re-saved the environment, or the user tweaked resource/lease - // knobs) must NOT silently discard the captured template. - await db.update(environmentCustomImageTemplates) - .set({ sourceEnvironmentConfigFingerprint: "stale" }) - .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); - const afterConfigChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { - id: environment.id, + // Runtime-only resource/lease edits must NOT silently discard the captured + // template. + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + cpu: 4, + timeoutMs: 600000, + reuseLease: true, + }, + }) + .where(eq(environments.id, environment.id)); + const afterResourceChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterResourceChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterResourceChangeEnvironment.id, driver: "sandbox", - config: environment.config, + config: afterResourceChangeEnvironment.config, }, { heartbeatRunId: randomUUID() }); - expect(afterConfigChange.driver).toBe("sandbox"); - expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" }); - expect(afterConfigChange.config).not.toHaveProperty("image"); + expect(afterResourceChange.driver).toBe("sandbox"); + expect(afterResourceChange.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(afterResourceChange.config).not.toHaveProperty("image"); + + // Changing the base image is a meaningful source-template change. In that + // case, the old capture must not mask the newly saved image. + await db.update(environmentCustomImageTemplates) + .set({ + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + }) + .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + image: "fake:new-base", + }, + }) + .where(eq(environments.id, environment.id)); + const afterImageChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterImageChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterImageChangeEnvironment.id, + driver: "sandbox", + config: afterImageChangeEnvironment.config, + }, { heartbeatRunId: randomUUID() }); + expect(afterImageChange.driver).toBe("sandbox"); + expect(afterImageChange.config).toMatchObject({ image: "fake:new-base" }); + expect(afterImageChange.config).not.toHaveProperty("snapshot"); }); it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => { @@ -562,3 +601,268 @@ describe("fingerprintEnvironmentSandboxProviderConfig", () => { ); }); }); + +describe("environmentCustomImageTemplateMatchesBaseConfig", () => { + it("keeps captures across runtime-only edits but not base image changes", () => { + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + timeoutMs: 300000, + reuseLease: false, + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + timeoutMs: 600000, + reuseLease: true, + cpu: 4, + }, + })).toBe(true); + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + image: "daytonaio/sandbox:0.9.0", + }, + })).toBe(false); + }); + + it("matches configs carrying a secret-ref credential when the capture excluded that path", () => { + // Capture-time fingerprints exclude the provider's secret-ref paths (e.g. + // daytona apiKey). The runtime re-check must exclude the same paths or a + // config with any credential never matches and the template is dropped. + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + apiKey: "raw-api-key-value", + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + "apiKey", + ], + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // A rotated credential still matches — credentials are not part of the + // captured image identity. + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { ...baseConfig, apiKey: "rotated-key" }, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // Without the exclusion the same config fails to match (the pre-fix bug). + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + })).toBe(false); + }); +}); + +describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-reconcile"); + stopDb = started.stop; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(environmentCustomImageSetupSessions); + await db.delete(environmentCustomImageTemplates); + await db.delete(plugins); + await db.delete(environments); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + }); + + async function seed() { + const companyId = randomUUID(); + const environmentId = randomUUID(); + await db.insert(companies).values( + { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` }, + ); + await db.insert(environments).values({ + id: environmentId, + name: `Fake ${environmentId.slice(0, 8)}`, + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:base", + reuseLease: false, + }, + envVars: {}, + }); + await db.insert(plugins).values({ + pluginKey: "paperclip.fake-sandbox-provider", + packageName: "paperclip-plugin-fake-sandbox", + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: pluginManifest(), + status: "ready", + }); + return { companyId, environmentId }; + } + + it("re-links the active template on save when only non-identity fields change", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + const nextConfig = { ...baseConfig, region: "eu-west" }; + + const relinked = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(relinked.action).toBe("relinked"); + if (relinked.action !== "relinked") throw new Error("expected relink"); + expect(relinked.template.sourceEnvironmentConfigFingerprint) + .not.toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // Re-running with the same configs is a no-op: the template already + // matches the new config. + const repeat = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(repeat.action).toBe("none"); + + // The captured template keeps applying at runtime under the new config. + await db.update(environments) + .set({ config: nextConfig }) + .where(eq(environments.id, environmentId)); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environmentId, + driver: "sandbox", + config: nextConfig, + }, { heartbeatRunId: randomUUID() }); + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef }); + expect(resolved.config).not.toHaveProperty("image"); + }); + + it("reports detached on save when a boot-source or provider identity field changes", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + + // Base image change: the user asked for a different base, so the capture + // cannot be re-linked. + const imageChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:other" } }, + }); + expect(imageChange.action).toBe("detached"); + + // Provider-declared identity path change (apiUrl): captured templates do + // not exist on a different provider endpoint. + const endpointChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, apiUrl: "https://other.example" } }, + }); + expect(endpointChange.action).toBe("detached"); + + // Binding-field change counts as a boot-source change too. + const bindingChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, customTemplate: "someone-elses-snapshot" } }, + }); + expect(bindingChange.action).toBe("detached"); + + // Detach never mutates the stored fingerprint; rollback/disable stay + // available and the old config still matches. + const template = await service.getActiveTemplate({ environmentId, provider: "fake-plugin" }); + expect(template?.sourceEnvironmentConfigFingerprint) + .toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // A template that was already detached before the save is left alone. + const alreadyDetached = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated" } }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated", region: "eu" } }, + }); + expect(alreadyDetached.action).toBe("none"); + }); + + it("reports whether the active template matches the saved config in the overview", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + await service.finishSetupSession({ sessionId: started.session.id }); + + const inSync = await service.getOverview({ environmentId }); + expect(inSync.activeTemplateMatchesConfig).toBe(true); + + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } }) + .where(eq(environments.id, environmentId)); + const outOfSync = await service.getOverview({ environmentId }); + expect(outOfSync.activeTemplate).not.toBeNull(); + expect(outOfSync.activeTemplateMatchesConfig).toBe(false); + }); +}); diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index 6577abed163..a627f105cab 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -133,6 +133,49 @@ describe("resolveEnvironmentExecutionTarget", () => { }); }); + it("keeps sandbox targets on callback bridge execution even when lease metadata advertises SSH access", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { + provider: "fake-plugin", + reuseLease: false, + timeoutMs: 30_000, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "claude_local", + environment: { + id: "env-1", + driver: "sandbox", + config: { + provider: "fake-plugin", + }, + }, + leaseId: "lease-1", + leaseMetadata: { + remoteCwd: "/home/sandbox/paperclip-workspace", + sshAccess: { + type: "ssh", + host: "ssh.example.test", + port: 22, + username: "paperclip", + }, + }, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/home/sandbox/paperclip-workspace", + }); + }); + it("resolves SSH execution targets in bridge mode", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "ssh", diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index b9ca635c5f7..9a298832193 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -4,6 +4,14 @@ const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn()); const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn()); const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn()); const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); +const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn()); +const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn()); +const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({ + acquireRunLease: mockRuntimeAcquireRunLease, + getDriver: vi.fn(() => ({ + releaseRunLease: mockRuntimeReleaseRunLease, + })), +}))); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, @@ -15,6 +23,10 @@ vi.mock("../services/plugin-environment-driver.js", () => ({ resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, })); +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: mockEnvironmentRuntimeService, +})); + import { probeEnvironment } from "../services/environment-probe.ts"; describe("probeEnvironment", () => { @@ -24,6 +36,9 @@ describe("probeEnvironment", () => { mockProbePluginSandboxProviderDriver.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null); + mockRuntimeAcquireRunLease.mockReset(); + mockRuntimeReleaseRunLease.mockReset(); + mockEnvironmentRuntimeService.mockClear(); }); it("reports local environments as immediately available", async () => { @@ -163,6 +178,91 @@ describe("probeEnvironment", () => { }); }); + it("boots a fresh runtime lease for saved sandbox probes when requested", async () => { + mockRuntimeAcquireRunLease.mockResolvedValue({ + environment: {}, + leaseContext: { + executionWorkspaceId: null, + executionWorkspaceMode: null, + }, + lease: { + id: "lease-1", + companyId: "company-1", + environmentId: "env-sandbox-plugin", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: null, + status: "active", + leasePolicy: "ephemeral", + provider: "daytona", + providerLeaseId: "sandbox-runtime-1", + acquiredAt: new Date(), + lastUsedAt: new Date(), + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: "pending", + metadata: { + provider: "daytona", + sandboxName: "paperclip-probe", + reuseLease: false, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const environment = { + id: "env-sandbox-plugin", + companyId: "company-1", + name: "Daytona", + description: null, + driver: "sandbox" as const, + status: "active" as const, + config: { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: true, + }, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await probeEnvironment({} as any, environment, { + companyId: "company-1", + pluginWorkerManager: {} as any, + applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, + }); + + expect(result).toMatchObject({ + ok: true, + driver: "sandbox", + summary: "Connected to daytona sandbox paperclip-probe.", + }); + expect(mockProbePluginSandboxProviderDriver).not.toHaveBeenCalled(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: true, + })); + expect(mockRuntimeAcquireRunLease.mock.calls[0]?.[0].environment.config).toMatchObject({ + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: false, + archiveOnRelease: true, + }); + expect(mockRuntimeReleaseRunLease).toHaveBeenCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ id: "lease-1" }), + status: "released", + })); + }); + it("routes plugin environment probes through the plugin worker host", async () => { mockProbePluginEnvironmentDriver.mockResolvedValue({ ok: true, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index c59ecc6f74e..5b5e1672f4f 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1597,6 +1597,7 @@ describe("environment routes", () => { companyId: null, pluginWorkerManager: undefined, applyCustomImageTemplate: false, + acquireSandboxRuntimeLease: false, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1693,9 +1694,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1748,9 +1750,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); }); diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index c501e8e52df..7a4a0e35990 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -1249,4 +1249,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .where(eq(issueRelations.relatedIssueId, blockedIssueId)); expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false); }); + + it("handles an armed cutoff when no liveness findings exist", async () => { + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileIssueGraphLiveness({ + issueCreatedAtGte: new Date(), + }); + + expect(result.findings).toBe(0); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index db068b1d7f6..49e87b2fd5a 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -106,6 +106,10 @@ import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY, SUCCESSFUL_RUN_MISSING_STATE_REASON, } from "../services/recovery/index.ts"; +import { + UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + UNMANAGED_BACKGROUND_TASK_STOP_REASON, +} from "@paperclipai/adapter-utils/server-utils"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -655,6 +659,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null; runErrorCode?: string | null; runError?: string | null; + resultJson?: Record | null; + monitorNextCheckAt?: Date | null; }) { const companyId = randomUUID(); const agentId = randomUUID(); @@ -729,6 +735,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ? null : ("runError" in input ? input.runError : "run failed before issue advanced"), livenessState: input.livenessState ?? null, + resultJson: input.resultJson ?? null, }); await db.insert(issues).values([ @@ -755,6 +762,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { assigneeUserId: input.assignToUser ? "user-1" : null, checkoutRunId: input.status === "in_progress" ? runId : null, executionRunId: null, + monitorNextCheckAt: input.monitorNextCheckAt ?? null, responsibleUserId: "responsible-user", issueNumber: input.activePauseHold ? 2 : 1, identifier: `${issuePrefix}-${input.activePauseHold ? 2 : 1}`, @@ -1554,6 +1562,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(failedRun?.status).toBe("failed"); expect(failedRun?.errorCode).toBe("process_lost"); expect(failedRun?.error).toContain("descendant process group"); + expect(failedRun?.resultJson).toMatchObject({ + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + unmanagedBackgroundTask: { + kind: "orphaned_process_group_cleanup", + stopped: true, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + processPid: orphan.processPid, + processGroupId: orphan.processGroupId, + }, + }); const retryRun = runs.find((row) => row.id !== runId); expect(["queued", "running"]).toContain(retryRun?.status); @@ -5287,6 +5306,155 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(wakeups).toHaveLength(2); }); + + it("does not accept unmanaged local-background wait evidence as a live continuation path", async () => { + const localWaitEvidence = { + summary: "Started a local polling watcher and will check the log later.", + externalWait: { + kind: "local_background", + pid: 12345, + logPath: "run/watch.log", + durable: false, + }, + }; + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + livenessState: "advanced", + resultJson: localWaitEvidence, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + 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)); + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.contextSnapshot as Record | undefined).toMatchObject({ + issueId, + retryReason: "issue_continuation_needed", + retryOfRunId: runId, + source: "issue.productive_terminal_continuation_recovery", + }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); + }); + + it("escalates repeated unmanaged local-background waits instead of retrying forever", async () => { + const localWaitEvidence = { + summary: "Still waiting on the local background watcher.", + externalWait: { + kind: "local_background", + pid: 12345, + logPath: "run/watch.log", + durable: false, + }, + }; + const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + runSource: "issue.productive_terminal_continuation_recovery", + livenessState: "advanced", + resultJson: localWaitEvidence, + }); + 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"); + + await expectSourceScopedStrandedRecoveryAction({ + companyId, + agentId, + issueId, + runId, + previousStatus: "in_progress", + retryReason: "issue_continuation_needed", + }); + + const followupRuns = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(followupRuns).toHaveLength(2); + }); + + it("preserves a persisted issue monitor as the durable external-wait path", async () => { + const { companyId, agentId, issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + livenessState: "advanced", + monitorNextCheckAt: new Date("2026-03-19T01:00:00.000Z"), + resultJson: { + summary: "Waiting for the deploy to settle; monitor is scheduled.", + externalWait: { kind: "issue_monitor", durable: true }, + }, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBe(1); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + expect(issue?.monitorNextCheckAt?.toISOString()).toBe("2026-03-19T01:00:00.000Z"); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + const recoveryIssues = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery"))); + expect(recoveryIssues).toHaveLength(0); + }); + + it("preserves a delegated blocker edge as the durable external-wait path", async () => { + const { companyId, agentId, issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + livenessState: "advanced", + resultJson: { + summary: "Delegated the external account check to a child task.", + externalWait: { kind: "delegated_child", durable: true }, + }, + }); + const blockerIssueId = randomUUID(); + await db.insert(issues).values({ + id: blockerIssueId, + companyId, + parentId: issueId, + title: "Check external account approval", + status: "todo", + priority: "medium", + assigneeUserId: "external-owner", + responsibleUserId: "responsible-user", + issueNumber: 2, + identifier: "PAP-2", + }); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: issueId, + type: "blocks", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBe(1); + + const source = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(source?.status).toBe("in_progress"); + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + }); + it("blocks stranded in-progress work after a productive continuation retry was already used", async () => { const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "in_progress", diff --git a/server/src/__tests__/heartbeat-worktree-suppression.test.ts b/server/src/__tests__/heartbeat-worktree-suppression.test.ts index b740512df94..c02a3397211 100644 --- a/server/src/__tests__/heartbeat-worktree-suppression.test.ts +++ b/server/src/__tests__/heartbeat-worktree-suppression.test.ts @@ -133,6 +133,16 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => { return { companyId, agentId, issueId }; } + async function armWorktreeRunExecution(cutoff: Date) { + await instanceSettingsService(db, { + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "test-worktree", + }, + now: () => cutoff, + }).updateExperimental({ enableWorktreeRunExecution: true }); + } + async function waitForCompletedRun(runId: string, agentId: string) { let latestStatus: string | null = null; let latestLastRunId: string | null = null; @@ -243,6 +253,48 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => { expect(runningCount).toBe(0); }); + it("skips pre-cutoff system wakes but allows user wakes in an armed worktree", async () => { + const { agentId, issueId } = await insertAgentAndIssue(); + await armWorktreeRunExecution(new Date(Date.now() + 1_000)); + const heartbeat = heartbeatService(db, { + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "test-worktree", + }, + }); + + const systemRun = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + payload: { issueId }, + contextSnapshot: { issueId }, + requestedByActorType: "system", + }); + expect(systemRun).toBeNull(); + + const skippedWake = await db + .select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests) + .orderBy(sql`${agentWakeupRequests.createdAt} desc`) + .limit(1) + .then((rows) => rows[0] ?? null); + expect(skippedWake).toMatchObject({ reason: "heartbeat.worktree_execution_cutoff" }); + expect(skippedWake?.payload).toMatchObject({ + heartbeatSkip: { reason: "worktree_execution_cutoff", issueId }, + }); + + const userRun = await heartbeat.wakeup(agentId, { + source: "on_demand", + triggerDetail: "user", + payload: { issueId }, + contextSnapshot: { issueId, skipIssueComment: true }, + requestedByActorType: "user", + requestedByActorId: "operator", + }); + expect(userRun).not.toBeNull(); + await heartbeat.waitForRunExecutionDrain(userRun!.id); + }, 10_000); + it("still creates live-plane assignment runs when suppression is not active", async () => { const { agentId, issueId } = await insertAgentAndIssue(); await db diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts index c6360a219ff..193c6f11a38 100644 --- a/server/src/__tests__/inbox-dismissals.test.ts +++ b/server/src/__tests__/inbox-dismissals.test.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + activityLog, agents, approvals, companies, @@ -14,6 +17,8 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js"; import { inboxDismissalService } from "../services/inbox-dismissals.ts"; import { sidebarBadgeService } from "../services/sidebar-badges.ts"; @@ -43,6 +48,7 @@ describeEmbeddedPostgres("inbox dismissals", () => { await db.delete(inboxDismissals); await db.delete(joinRequests); await db.delete(invites); + await db.delete(activityLog); await db.delete(heartbeatRuns); await db.delete(approvals); await db.delete(agents); @@ -73,9 +79,63 @@ describeEmbeddedPostgres("inbox dismissals", () => { expect(dismissals).toHaveLength(1); expect(dismissals[0]?.itemKey).toBe("approval:approval-1"); + expect(dismissals[0]?.kind).toBe("dismiss"); + expect(dismissals[0]?.snoozedUntil).toBeNull(); expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString()); }); + it("snoozes and restores dismissal records through the route", async () => { + const companyId = randomUUID(); + const userId = "board-user"; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", inboxDismissalRoutes(db)); + app.use(errorHandler); + + await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:old", kind: "snooze", snoozedUntil: "2020-01-01T00:00:00.000Z" }) + .expect(400); + + const snoozedUntil = "2099-01-01T00:00:00.000Z"; + const createRes = await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:approval-1", kind: "snooze", snoozedUntil }) + .expect(201); + + expect(createRes.body).toMatchObject({ + companyId, + userId, + itemKey: "attention:approval:approval-1", + kind: "snooze", + snoozedUntil, + }); + + await request(app) + .delete(`/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent("attention:approval:approval-1")}`) + .expect(204); + + await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]); + }); + it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => { const companyId = randomUUID(); const userId = "board-user"; diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 5148b1abfd7..8c00f7f1f39 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -89,6 +89,9 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -114,6 +117,9 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); mockInstanceSettingsService.update.mockResolvedValue({ @@ -138,6 +144,9 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -168,6 +177,9 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, }); @@ -226,6 +238,9 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); @@ -240,6 +255,28 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }, 10_000); + it("strips server-managed worktree run execution fields before updating experimental settings", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "copied-instance", + }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableWorktreeRunExecution: true, + }); + }); + it("allows local board users to read and update the instance default environment", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 089a2877e81..dbca0fa120b 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { normalizeExperimentalSettings } from "../services/instance-settings.js"; +import { describe, expect, it, vi } from "vitest"; +import type { InstanceExperimentalSettings } from "@paperclipai/shared"; +import { + applyExperimentalSettingsPatch, + normalizeExperimentalSettings, + resolveWorktreeRunExecutionActivationState, +} from "../services/instance-settings.js"; describe("instance settings service", () => { it("ignores retired experimental flags without resetting current settings", () => { @@ -32,6 +37,7 @@ describe("instance settings service", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableBuiltInAgents: true, + enableDecisions: false, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -39,6 +45,8 @@ describe("instance settings service", () => { enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: 48, }); }); @@ -76,6 +84,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableDecisions to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableDecisions).toBe(false); + expect(normalizeExperimentalSettings({}).enableDecisions).toBe(false); + expect( + normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableDecisions, + ).toBe(false); + }); + it("defaults workspace branch repair settings to true for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true); expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true); @@ -115,4 +131,208 @@ describe("instance settings service", () => { expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false); expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false); }); + + it("sets worktree run execution activation fields on a false to true transition", () => { + const activatedAt = new Date("2026-07-10T12:00:00.000Z"); + + const next = applyExperimentalSettingsPatch( + { enableWorktreeRunExecution: false }, + { enableWorktreeRunExecution: true }, + { + now: () => activatedAt, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + + expect(next.enableWorktreeRunExecution).toBe(true); + expect(next.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:00:00.000Z"); + expect(next.worktreeRunExecutionActivationInstanceId).toBe("worktree-instance"); + }); + + it("clears worktree run execution activation fields on a true to false transition", () => { + const next = applyExperimentalSettingsPatch( + { + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "worktree-instance", + }, + { enableWorktreeRunExecution: false }, + { + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + + expect(next.enableWorktreeRunExecution).toBe(false); + expect(next.worktreeRunExecutionActivatedAt).toBeNull(); + expect(next.worktreeRunExecutionActivationInstanceId).toBeNull(); + }); + + it("refreshes the activation cutoff when worktree run execution is re-toggled", () => { + const firstActivation = applyExperimentalSettingsPatch( + { enableWorktreeRunExecution: false }, + { enableWorktreeRunExecution: true }, + { + now: () => new Date("2026-07-10T12:00:00.000Z"), + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + const disabled = applyExperimentalSettingsPatch( + firstActivation, + { enableWorktreeRunExecution: false }, + { + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + + const secondActivation = applyExperimentalSettingsPatch( + disabled, + { enableWorktreeRunExecution: true }, + { + now: () => new Date("2026-07-10T12:05:00.000Z"), + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + + expect(secondActivation.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:05:00.000Z"); + expect(secondActivation.worktreeRunExecutionActivatedAt).not.toBe( + firstActivation.worktreeRunExecutionActivatedAt, + ); + }); + + it("strips client-supplied activation fields before applying experimental patches", () => { + const next = applyExperimentalSettingsPatch( + { enableWorktreeRunExecution: false }, + { + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "copied-instance", + }, + { + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }, + ); + + expect(next.worktreeRunExecutionActivatedAt).toBeNull(); + expect(next.worktreeRunExecutionActivationInstanceId).toBeNull(); + }); + + it("resolves worktree run execution as armed only when the cutoff matches the current instance", async () => { + const experimental = normalizeExperimentalSettings({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "worktree-instance", + }); + + await expect( + resolveWorktreeRunExecutionActivationState({ + getExperimental: async () => experimental, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }), + ).resolves.toEqual({ + armed: true, + cutoff: "2026-07-10T12:00:00.000Z", + activationInstanceId: "worktree-instance", + reason: null, + }); + }); + + it("fails closed when worktree run execution is missing a cutoff", async () => { + const experimental = normalizeExperimentalSettings({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivationInstanceId: "worktree-instance", + }); + + await expect( + resolveWorktreeRunExecutionActivationState({ + getExperimental: async () => experimental, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }), + ).resolves.toMatchObject({ + armed: false, + cutoff: null, + reason: "missing_cutoff", + }); + }); + + it("fails closed when worktree run execution was activated by another instance", async () => { + const experimental = normalizeExperimentalSettings({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "source-instance", + }); + + await expect( + resolveWorktreeRunExecutionActivationState({ + getExperimental: async () => experimental, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "target-instance", + }, + }), + ).resolves.toMatchObject({ + armed: false, + cutoff: null, + activationInstanceId: "source-instance", + reason: "instance_id_mismatch", + }); + }); + + it("fails closed on settings read errors and avoids reads outside worktree runtimes", async () => { + await expect( + resolveWorktreeRunExecutionActivationState({ + getExperimental: async () => { + throw new Error("settings unavailable"); + }, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }), + ).resolves.toMatchObject({ + armed: false, + cutoff: null, + reason: "settings_read_error", + }); + + const getExperimental = vi.fn<() => Promise>(); + await expect( + resolveWorktreeRunExecutionActivationState({ + getExperimental, + runtimeEnv: { + PAPERCLIP_IN_WORKTREE: "false", + PAPERCLIP_INSTANCE_ID: "worktree-instance", + }, + }), + ).resolves.toMatchObject({ + armed: false, + cutoff: null, + reason: "not_worktree_runtime", + }); + expect(getExperimental).not.toHaveBeenCalled(); + }); + }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index c0ab044906d..fb08b98f1c1 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -18,6 +18,7 @@ const mockInteractionService = vi.hoisted(() => ({ rejectSuggestedTasks: vi.fn(), expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(), answerQuestions: vi.fn(), + submitItemVerdicts: vi.fn(), cancelQuestions: vi.fn(), })); @@ -284,6 +285,48 @@ describe.sequential("issue thread interaction routes", () => { updatedAt: "2026-04-20T12:06:00.000Z", resolvedAt: "2026-04-20T12:06:00.000Z", }); + mockInteractionService.submitItemVerdicts.mockResolvedValue({ + interaction: { + id: "interaction-verdicts", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-verdicts", + sourceRunId: "run-verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + }, + result: { + version: 1, + outcome: "resolved", + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + resolvedByUserId: "local-board", + resolvedAt: "2026-04-20T12:06:00.000Z", + }, + ], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:06:00.000Z", + resolvedAt: null, + }, + newlyResolvedItemIds: ["docs"], + }); mockInteractionService.cancelQuestions.mockResolvedValue({ id: "interaction-2", companyId: "company-1", @@ -469,6 +512,68 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("submits item verdicts and emits one continuation wake with resolved item ids", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts") + .send({ + verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }], + }); + + expect(res.status).toBe(200); + expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-verdicts", + { verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] }, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + idempotencyKey: expect.stringMatching( + /^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/, + ), + payload: expect.objectContaining({ + interactionId: "interaction-verdicts", + interactionKind: "request_item_verdicts", + interactionStatus: "pending", + sourceCommentId: "comment-verdicts", + sourceRunId: "run-verdicts", + newlyResolvedItemIds: ["docs"], + itemVerdicts: { + newlyResolvedItemIds: ["docs"], + coalesceWindowMs: 2000, + }, + }), + contextSnapshot: expect.objectContaining({ + interactionId: "interaction-verdicts", + interactionKind: "request_item_verdicts", + interactionStatus: "pending", + newlyResolvedItemIds: ["docs"], + itemVerdicts: { + newlyResolvedItemIds: ["docs"], + coalesceWindowMs: 2000, + }, + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_item_verdicts_submitted", + details: expect.objectContaining({ + interactionKind: "request_item_verdicts", + newlyResolvedItemCount: 1, + newlyResolvedItemIds: ["docs"], + complete: false, + }), + }), + ); + }); + it("cancels question interactions and emits a continuation wake", async () => { const app = await createApp(); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 9463c734e3d..8a957a6b4e6 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -1140,6 +1140,243 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("submits request_item_verdicts partially and completes when all items are resolved", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + { id: "tests", label: "Tests" }, + ], + }, + }, { + userId: "local-board", + }); + + expect(created).toMatchObject({ + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + payload: { + verdicts: ["approve", "reject"], + requireReasonOn: ["reject"], + allowBulkApprove: true, + supersedeOnUserComment: true, + }, + }); + + const first = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }], + }, { + userId: "local-board", + }); + + expect(first.newlyResolvedItemIds).toEqual(["docs"]); + expect(first.interaction).toMatchObject({ + kind: "request_item_verdicts", + status: "pending", + result: { + version: 1, + outcome: "resolved", + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + resolvedByUserId: "local-board", + }, + ], + }, + resolvedAt: null, + }); + + const duplicate = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject" }], + }, { + userId: "local-board", + }); + + expect(duplicate.newlyResolvedItemIds).toEqual([]); + expect(duplicate.interaction).toMatchObject({ + status: "pending", + result: { + complete: false, + items: [ + { + id: "docs", + verdict: "reject", + reason: "Missing examples", + }, + ], + }, + }); + + const completed = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [ + { id: "api", verdict: "approve" }, + { id: "tests", verdict: "reject", reason: "No route coverage" }, + ], + }, { + userId: "local-board", + }); + + expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]); + expect(completed.interaction).toMatchObject({ + kind: "request_item_verdicts", + status: "answered", + result: { + version: 1, + outcome: "resolved", + complete: true, + items: [ + { id: "api", verdict: "approve" }, + { id: "docs", verdict: "reject", reason: "Missing examples" }, + { id: "tests", verdict: "reject", reason: "No route coverage" }, + ], + }, + resolvedByUserId: "local-board", + }); + + const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + userId: "local-board", + }); + expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]); + expect(duplicateAfterComplete.interaction.status).toBe("answered"); + }); + + it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + }, { + userId: "local-board", + }); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "missing", verdict: "approve" }], + }, { + userId: "local-board", + })).rejects.toThrow("Unknown item verdict id: missing"); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "defer" }], + }, { + userId: "local-board", + })).rejects.toThrow("Verdict defer is not enabled"); + + await expect(interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "reject" }], + }, { + userId: "local-board", + })).rejects.toThrow("A reason is required when verdict is reject"); + }); + + it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "request_item_verdicts", + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + complete: false, + commentId, + items: [ + { + id: "api", + verdict: "approve", + resolvedByUserId: "local-board", + }, + ], + }, + }); + }); + it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { const companyId = randomUUID(); const goalId = randomUUID(); @@ -1580,6 +1817,152 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("preserves resolved request_item_verdicts items when the watched issue document revision changes", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const nextRevisionId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Document target verdicts", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "v1", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "v1", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_item_verdicts", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Review generated artifacts.", + items: [ + { id: "api", label: "API route" }, + { id: "docs", label: "Docs" }, + ], + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + userId: "local-board", + }); + + await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "api", verdict: "approve" }], + }, { + userId: "local-board", + }); + + await db.insert(documentRevisions).values({ + id: nextRevisionId, + companyId, + documentId, + revisionNumber: 2, + title: "Plan", + format: "markdown", + body: "v2", + }); + await db.update(documents).set({ + latestBody: "v2", + latestRevisionId: nextRevisionId, + latestRevisionNumber: 2, + }); + + const stale = await interactionsSvc.submitItemVerdicts({ + id: issueId, + companyId, + }, created.id, { + verdicts: [{ id: "docs", verdict: "approve" }], + }, { + userId: "local-board", + }); + + expect(stale.newlyResolvedItemIds).toEqual([]); + expect(stale.interaction).toMatchObject({ + id: created.id, + status: "expired", + payload: { + target: { + type: "issue_document", + key: "plan", + revisionId: nextRevisionId, + revisionNumber: 2, + }, + }, + result: { + version: 1, + outcome: "stale_target", + complete: false, + staleTarget: { + type: "issue_document", + key: "plan", + revisionId, + }, + items: [ + { + id: "api", + verdict: "approve", + resolvedByUserId: "local-board", + }, + ], + }, + }); + }); + describe("workspace_finalize accept gate", () => { type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation"; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 5eb8c8e87e7..d7433023072 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -15,6 +15,7 @@ const apiPrefixes: Record = { "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", + "attention.ts": "/api", "approvals.ts": "/api", "assets.ts": "/api", "auth.ts": "/api/auth", diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index df10c1a3a2c..420d8fddc6e 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -68,10 +68,13 @@ describe("paperclip skill utils", () => { const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md"); const skillBody = await fs.readFile(skillPath, "utf8"); const normalizedSkillBody = skillBody.replace(/\s+/g, " "); + const normalizedLowerSkillBody = normalizedSkillBody.toLowerCase(); expect(skillBody).toContain("name: create-issue-interaction-ui"); - expect(skillBody).toContain("Developer/maintainer skill"); - expect(normalizedSkillBody).toContain("Do NOT install this on production Paperclip agents"); + expect(normalizedLowerSkillBody).toContain("developer/maintainer skill"); + expect(normalizedLowerSkillBody).toContain( + "not the operational agents that run inside a deployed paperclip company", + ); expect(skillBody).toContain("packages/shared/src/constants.ts"); expect(skillBody).toContain("server/src/services/issue-thread-interactions.ts"); expect(skillBody).toContain("ui/src/components/IssueThreadInteractionCard.tsx"); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index cf8e0eebbd1..d423f73dae9 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -88,6 +88,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); async function seedFixture(opts?: { + runtimeEnv?: Record; wakeup?: ( agentId: string, wakeupOpts: { @@ -147,6 +148,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); const svc = routineService(db, { + runtimeEnv: opts?.runtimeEnv, heartbeat: { wakeup: async (wakeupAgentId, wakeupOpts) => { wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts }); @@ -204,6 +206,18 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups }; } + async function armWorktreeExecution(cutoff: Date, instanceId = "worktree-routines-test") { + await db.insert(instanceSettings).values({ + singletonKey: "default", + general: {}, + experimental: { + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: cutoff.toISOString(), + worktreeRunExecutionActivationInstanceId: instanceId, + }, + }); + } + it("filters listed routines by project", async () => { const { companyId, agentId, projectId, routine, svc } = await seedFixture(); const otherProjectId = randomUUID(); @@ -1746,6 +1760,80 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(run.status).toBe("issue_created"); }); + it("records suppressed automatic runs when worktree execution is disabled while allowing manual runs", async () => { + const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "yes", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" }; + const { companyId, routine, svc } = await seedFixture({ runtimeEnv }); + const { trigger: scheduleTrigger } = await svc.createTrigger( + routine.id, + { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, + {}, + ); + const { trigger: webhookTrigger } = await svc.createTrigger( + routine.id, + { kind: "webhook", signingMode: "none" }, + {}, + ); + const pastDue = new Date("2020-01-01T00:00:00.000Z"); + await db.update(routineTriggers).set({ nextRunAt: pastDue }).where(eq(routineTriggers.id, scheduleTrigger.id)); + + expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 0 }); + const webhookRun = await svc.firePublicTrigger(webhookTrigger.publicId!, { payload: { event: "created" } }); + expect(webhookRun).toMatchObject({ source: "webhook", status: "skipped", failureReason: "worktree_execution_cutoff" }); + + const manualRun = await svc.runRoutine(routine.id, { source: "manual" }); + expect(manualRun.status).toBe("issue_created"); + + const automatedRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, routine.id)); + expect(automatedRuns.filter((run) => run.failureReason === "worktree_execution_cutoff")).toHaveLength(2); + expect(automatedRuns.filter((run) => run.linkedIssueId)).toHaveLength(1); + const scheduleAfter = await db.select().from(routineTriggers).where(eq(routineTriggers.id, scheduleTrigger.id)).then((rows) => rows[0]); + expect(scheduleAfter!.nextRunAt!.getTime()).toBeGreaterThan(pastDue.getTime()); + expect((await db.select().from(issues).where(eq(issues.companyId, companyId))).filter((issue) => issue.originKind === "routine_execution")).toHaveLength(1); + }); + + it("dispatches only post-cutoff scheduled routines in an armed worktree", async () => { + const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" }; + const { companyId, agentId, projectId, routine: oldRoutine, svc } = await seedFixture({ runtimeEnv }); + const cutoff = new Date("2025-01-01T00:00:00.000Z"); + await armWorktreeExecution(cutoff); + const newRoutine = await svc.create(companyId, { + projectId, + goalId: null, + parentIssueId: null, + title: "new routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, {}); + await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, oldRoutine.id)); + await db.update(routines).set({ createdAt: new Date("2025-01-01T00:00:01.000Z") }).where(eq(routines.id, newRoutine.id)); + const { trigger: oldTrigger } = await svc.createTrigger(oldRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {}); + const { trigger: newTrigger } = await svc.createTrigger(newRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {}); + await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, oldTrigger.id)); + await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, newTrigger.id)); + + expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 1 }); + const oldRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, oldRoutine.id)); + expect(oldRuns).toMatchObject([{ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null }]); + const newRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, newRoutine.id)); + expect(newRuns).toMatchObject([{ status: "issue_created" }]); + }); + + it("applies the armed cutoff to webhook dispatch but not manual API runs", async () => { + const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" }; + const { routine, svc } = await seedFixture({ runtimeEnv }); + await armWorktreeExecution(new Date("2025-01-01T00:00:00.000Z")); + await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, routine.id)); + const { trigger } = await svc.createTrigger(routine.id, { kind: "webhook", signingMode: "none" }, {}); + + const webhookRun = await svc.firePublicTrigger(trigger.publicId!, { payload: { event: "created" } }); + expect(webhookRun).toMatchObject({ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null }); + expect((await svc.runRoutine(routine.id, { source: "api" })).status).toBe("issue_created"); + }); + it("suppresses scheduled ticks while the routine project is paused, then resumes when unpaused", async () => { const { companyId, projectId, routine, svc } = await seedFixture(); const { trigger } = await svc.createTrigger( diff --git a/server/src/__tests__/run-liveness.test.ts b/server/src/__tests__/run-liveness.test.ts index 2feeb076682..92c5560d954 100644 --- a/server/src/__tests__/run-liveness.test.ts +++ b/server/src/__tests__/run-liveness.test.ts @@ -195,6 +195,27 @@ describe("run liveness classifier", () => { expect(classification.nextAction).toBe("deploy to production and verify live traffic."); }); + + it("uses killed background-task evidence instead of a generic failed-run reason", () => { + const classification = classifyRunLiveness({ + ...baseInput, + runStatus: "failed", + errorCode: "process_lost", + resultJson: { + stopReason: "unmanaged_background_task_stopped", + unmanagedBackgroundTask: { + kind: "orphaned_process_group_cleanup", + stopped: true, + stopReason: "unmanaged_background_task_stopped", + reason: "unmanaged background task stopped; no durable live path", + }, + }, + }); + + expect(classification.livenessState).toBe("failed"); + expect(classification.livenessReason).toBe("unmanaged background task stopped; no durable live path"); + }); + it("marks unclear useful output as unknown actionability", () => { const classification = classifyRunLiveness({ ...baseInput, diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts index e8138f85b11..325d45ff638 100644 --- a/server/src/__tests__/task-watchdogs-scheduler.test.ts +++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts @@ -663,4 +663,16 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog"))); expect(watchdogIssues).toHaveLength(1); }); + + it("handles an armed cutoff when no watchdogs are active", async () => { + const companyId = await seedCompany(); + const { service } = createService(); + + const result = await service.reconcileTaskWatchdogs({ + companyId, + issueCreatedAtGte: new Date(), + }); + + expect(result).toMatchObject({ checked: 0, triggered: 0 }); + }); }); diff --git a/server/src/__tests__/ui-branding.test.ts b/server/src/__tests__/ui-branding.test.ts index c649d5c95ed..12e46effcb4 100644 --- a/server/src/__tests__/ui-branding.test.ts +++ b/server/src/__tests__/ui-branding.test.ts @@ -65,6 +65,30 @@ describe("ui branding", () => { expect(meta).toContain('name="paperclip-worktree-color"'); }); + it("surfaces the runtime instance id so the UI can fail closed on copied rows", () => { + const branding = getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + PAPERCLIP_INSTANCE_ID: "inst-abc123", + }); + expect(branding.instanceId).toBe("inst-abc123"); + + const meta = renderRuntimeBrandingMeta(branding); + expect(meta).toContain('name="paperclip-instance-id"'); + expect(meta).toContain('content="inst-abc123"'); + }); + + it("omits the instance-id meta when the runtime id is unset", () => { + const branding = getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + expect(branding.instanceId).toBeNull(); + expect(renderRuntimeBrandingMeta(branding)).not.toContain('name="paperclip-instance-id"'); + }); + it("rewrites the favicon and runtime branding blocks for worktree instances only", () => { const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true", diff --git a/server/src/__tests__/version.test.ts b/server/src/__tests__/version.test.ts index 9d8f9d8b1f4..cd660a39905 100644 --- a/server/src/__tests__/version.test.ts +++ b/server/src/__tests__/version.test.ts @@ -42,19 +42,98 @@ describe("resolveServerVersion", () => { it("falls back to package version without throwing when git is unavailable", () => { const debugLog = vi.fn(); + const cause = new Error("spawn git ENOENT"); + const err = Object.assign(new Error("fatal: not a git repository"), { + code: 128, + stderr: Buffer.from("fatal: not a git repository\n"), + stdout: "", + cause, + }); expect( resolveServerVersion({ packageVersion: "2026.706.0", gitDescribeCommand: () => { - throw new Error("fatal: not a git repository"); + throw err; }, debugLog, }), ).toBe("2026.706.0"); expect(debugLog).toHaveBeenCalledWith( - expect.objectContaining({ reason: "git_describe_unavailable" }), + expect.objectContaining({ + err: expect.objectContaining({ + cause: expect.objectContaining({ message: "spawn git ENOENT" }), + code: 128, + message: "fatal: not a git repository", + stderr: "fatal: not a git repository\n", + stdout: "", + stack: expect.any(String), + }), + reason: "git_describe_unavailable", + }), "falling back to package version for server version", ); }); + + it("skips git metadata probing for packaged installs under node_modules", () => { + const debugLog = vi.fn(); + + expect( + resolveServerVersion({ + packageVersion: "2026.707.0-canary.12", + debugLog, + packageRoot: "/tmp/npm/_npx/example/node_modules/@paperclipai/server", + }), + ).toBe("2026.707.0-canary.12"); + + expect(debugLog).toHaveBeenCalledWith( + { reason: "packaged_install" }, + "falling back to package version for server version", + ); + }); + + it("uses git metadata for source checkouts whose path contains node_modules", () => { + const debugLog = vi.fn(); + const gitDescribeCommand = vi.fn(() => "v2026.626.0-58-g518fc71ce\n"); + + expect( + resolveServerVersion({ + packageVersion: "2026.707.0-canary.12", + debugLog, + gitDescribeCommand, + packageRoot: "/tmp/node_modules/source/paperclip/server", + pathExists: (path) => path === "/tmp/node_modules/source/paperclip/.git", + realpath: (path) => path, + }), + ).toBe("2026.626.0+58.git.518fc71ce"); + + expect(gitDescribeCommand).toHaveBeenCalledOnce(); + expect(debugLog).not.toHaveBeenCalled(); + }); + + it("keeps fallback diagnostics quiet by default", () => { + const previousDebugFlag = process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION; + delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION; + const consoleDebug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + try { + expect( + resolveServerVersion({ + packageVersion: "2026.706.0", + gitDescribeCommand: () => { + throw new Error("fatal: not a git repository"); + }, + }), + ).toBe("2026.706.0"); + + expect(consoleDebug).not.toHaveBeenCalled(); + } finally { + consoleDebug.mockRestore(); + if (previousDebugFlag === undefined) { + delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION; + } else { + process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION = previousDebugFlag; + } + } + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 75e0f1fe243..9cb4ad9f9da 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,6 +33,7 @@ import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { attentionRoutes } from "./routes/attention.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; @@ -253,6 +254,7 @@ export async function createApp( api.use(costRoutes(db, { pluginWorkerManager: workerManager })); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(attentionRoutes(db)); api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); diff --git a/server/src/index.ts b/server/src/index.ts index e547bd9d973..8d81e21adaa 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -48,6 +48,7 @@ import { reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; +import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js"; import { parseAdapterRegistryEnv, reconcileAdapterAvailability, @@ -838,6 +839,16 @@ export async function startServer(): Promise { drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown; const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager }); const routines = routineService(db as any, { pluginWorkerManager }); + const worktreeRunExecutionActivation = await resolveWorktreeRunExecutionActivationState({ + getExperimental: () => instanceSettingsService(db).getExperimental(), + }); + logger.info( + { + state: worktreeRunExecutionActivation.armed ? "armed" : "disarmed", + cutoff: worktreeRunExecutionActivation.cutoff, + }, + "worktree run-execution cutoff state", + ); const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression(); // Reap orphaned runs before timer ticks start so wakeups cannot coalesce diff --git a/server/src/middleware/api-compression.ts b/server/src/middleware/api-compression.ts index 58dfeb33c22..afe16389366 100644 --- a/server/src/middleware/api-compression.ts +++ b/server/src/middleware/api-compression.ts @@ -75,6 +75,10 @@ function shouldPassthroughWrite(res: Parameters[1]): boolean { const contentType = res.getHeader("Content-Type"); const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity"; return ( + // writeHead() may already have committed the response head (better-call + // does this before streaming the body); headers can no longer change, so + // buffering for compression would only risk corrupting the stream. + res.headersSent || alreadyEncoded || !statusAllowsBody(res.statusCode) || shouldSkipForCacheControl(res.getHeader("Cache-Control")) || @@ -97,6 +101,15 @@ function weakenStrongEtag(res: Parameters[1]): void { res.setHeader("ETag", weaken(String(etag))); } +function toBodyBuffer(chunk: unknown, encoding: BufferEncoding | undefined): Buffer { + if (Buffer.isBuffer(chunk)) return chunk; + // Handlers bridged from web Response streams (e.g. Better Auth via + // better-call) write Uint8Array chunks; String(chunk) would serialize them + // as comma-separated byte values and corrupt the body. + if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + return Buffer.from(String(chunk), encoding); +} + function normalizeEndArgs(args: unknown[]): { chunk: unknown; encoding: BufferEncoding | undefined; @@ -160,7 +173,7 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand return originalWrite(chunk as never, encodingOrCallback as never, callback as never); } if (chunk !== undefined) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)); + chunks.push(toBodyBuffer(chunk, typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)); } const writeCallback = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; if (writeCallback) writeCallbacks.push(() => writeCallback(null)); @@ -171,13 +184,14 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand restore(); const { chunk, encoding, callback } = normalizeEndArgs(args); if (chunk !== undefined) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encoding)); + chunks.push(toBodyBuffer(chunk, encoding)); } const body = Buffer.concat(chunks); const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity"; const shouldCompress = !passthrough && + !res.headersSent && !alreadyEncoded && statusAllowsBody(res.statusCode) && body.length >= thresholdBytes && @@ -204,7 +218,14 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand originalEnd(compressed, callback); for (const writeCallback of writeCallbacks) writeCallback(); } catch (error) { - res.destroy(error instanceof Error ? error : new Error(String(error))); + // Compression is best-effort: never turn a healthy response into a + // dropped connection. Send the original body if the head allows it. + try { + originalEnd(body, callback); + for (const writeCallback of writeCallbacks) writeCallback(); + } catch { + res.destroy(error instanceof Error ? error : new Error(String(error))); + } } })(); return res; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ec7faa32ea1..8de49215b63 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -83,7 +83,11 @@ import { import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; -import { instanceSettingsService } from "../services/instance-settings.js"; +import { + instanceSettingsService, + isTruthyRuntimeEnvValue, + resolveWorktreeRunExecutionActivationState, +} from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; @@ -259,6 +263,7 @@ export function agentRoutes( executionTarget: AdapterExecutionTarget | null; environmentName: string | null; fallbackChecks: AdapterEnvironmentCheck[]; + sandboxIdentityCheck?: AdapterEnvironmentCheck | null; release: (status?: "released" | "failed") => Promise; }> { const noopRelease = async () => {}; @@ -358,11 +363,26 @@ export function agentRoutes( // run id to heartbeat_runs.id, and we don't want to manufacture a fake // run row. Cleanup goes through the driver's `releaseRunLease` directly // (by lease record), since the batch helper queries by heartbeatRunId. + // + // Sandbox tests boot a fresh throwaway sandbox (never resume a retained + // agent lease) and archive it on release instead of deleting it, so the + // operator can inspect the exact sandbox from the provider dashboard while + // provider-side expiry reaps it later. + const testEnvironment = environment.driver === "sandbox" + ? { + ...environment, + config: { + ...(environment.config ?? {}), + reuseLease: false, + archiveOnRelease: true, + }, + } + : environment; let leaseRecord: Awaited>; try { leaseRecord = await environmentRuntime.acquireRunLease({ companyId: input.companyId, - environment, + environment: testEnvironment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, @@ -394,7 +414,7 @@ export function agentRoutes( try { if (driver) { await driver.releaseRunLease({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, status, }); @@ -413,7 +433,7 @@ export function agentRoutes( let realizedCwd: string | null = null; try { const realized = await environmentRuntime.realizeWorkspace({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, // No host workspace to copy for a Test invocation; sandbox/plugin // realize implementations use the lease metadata's remoteCwd to @@ -454,9 +474,9 @@ export function agentRoutes( companyId: input.companyId, adapterType: input.adapterType, environment: { - id: environment.id, - driver: environment.driver, - config: environment.config ?? null, + id: testEnvironment.id, + driver: testEnvironment.driver, + config: testEnvironment.config ?? null, }, leaseId: leaseRecord.lease.id, leaseMetadata: leaseMetadataForTarget, @@ -501,10 +521,75 @@ export function agentRoutes( executionTarget: target, environmentName: environment.name, fallbackChecks: [], + sandboxIdentityCheck: buildSandboxIdentityCheck({ + environmentName: environment.name, + lease: leaseRecord.lease, + }), release: releaseLease, }; } + function readMetadataString(metadata: Record, keys: string[]): string | null { + for (const key of keys) { + const value = metadata[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; + } + + function buildSandboxIdentityCheck(input: { + environmentName: string; + lease: { + id: string; + provider?: string | null; + providerLeaseId?: string | null; + metadata?: Record | null; + }; + }): AdapterEnvironmentCheck { + const metadata = input.lease.metadata ?? {}; + const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]); + const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]); + const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]); + const snapshotRef = readMetadataString(metadata, [ + "snapshot", + "snapshotId", + "snapshotID", + "snapshotRef", + "snapshot_ref", + "templateRef", + "template_ref", + "templateId", + "templateID", + "image", + "imageId", + "imageID", + "imageRef", + "image_ref", + ]); + const templateKind = readMetadataString(metadata, [ + "templateKind", + "template_kind", + "templateRefKind", + "template_ref_kind", + ]); + const detailParts = [ + `paperclipLeaseId=${input.lease.id}`, + input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null, + provider ? `provider=${provider}` : null, + sandboxId ? `sandboxId=${sandboxId}` : null, + sandboxName ? `sandboxName=${sandboxName}` : null, + snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null, + ].filter((part): part is string => Boolean(part)); + + return { + code: "sandbox_test_identity", + level: "info", + message: `Sandbox test identity for "${input.environmentName}".`, + detail: detailParts.join("; "), + hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed sandbox snapshots.", + }; + } + async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, @@ -1686,7 +1771,7 @@ export function agentRoutes( { adapterType: type }, ); - const { executionTarget, environmentName, fallbackChecks, release } = + const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, adapterType: type, @@ -1724,7 +1809,10 @@ export function agentRoutes( }); if (result.status === "fail") releaseStatus = "failed"; - res.json(result); + res.json({ + ...result, + checks: sandboxIdentityCheck ? [sandboxIdentityCheck, ...result.checks] : result.checks, + }); } catch (err) { releaseStatus = "failed"; throw err; @@ -2042,14 +2130,23 @@ export function agentRoutes( includeRoutineExecutions: true, limit: ISSUE_LIST_DEFAULT_LIMIT, }); - const issueIds = rows.map((issue) => issue.id); + const worktreeActivation = await resolveWorktreeRunExecutionActivationState({ + getExperimental: () => instanceSettingsService(db).getExperimental(), + }); + const isWorktreeRuntime = isTruthyRuntimeEnvValue(process.env.PAPERCLIP_IN_WORKTREE); + const eligibleRows = !isWorktreeRuntime + ? rows + : worktreeActivation.armed + ? rows.filter((issue) => new Date(issue.createdAt) >= new Date(worktreeActivation.cutoff)) + : []; + const issueIds = eligibleRows.map((issue) => issue.id); const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([ issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds), recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds), ]); res.json( - rows.map((issue) => ({ + eligibleRows.map((issue) => ({ id: issue.id, identifier: issue.identifier, title: issue.title, diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts new file mode 100644 index 00000000000..a8ef20f0e09 --- /dev/null +++ b/server/src/routes/attention.ts @@ -0,0 +1,28 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { attentionService } from "../services/attention.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; + +export function attentionRoutes(db: Db) { + const router = Router(); + const svc = attentionService(db); + + router.get("/companies/:companyId/attention", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + assertBoard(req); + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + + const includeDismissed = req.query.includeDismissed === "true"; + const feed = await svc.list(companyId, { + userId: req.actor.userId, + includeDismissed, + }); + res.json(feed); + }); + + return router; +} diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 6ea542fdcde..9ecb9c2a90d 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -777,12 +777,24 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } + let customImageReconciliation: Awaited< + ReturnType + > = { action: "none" }; if (patch.config !== undefined || patch.driver !== undefined) { await secrets.syncSecretRefsForTarget( companyIdForSecrets!, { targetType: "environment", targetId: environment.id }, await collectEnvironmentSecretRefs({ db, environment }), ); + try { + customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({ + environmentId: environment.id, + previous: existing, + next: environment, + }); + } catch { + // Reconciliation is best-effort; a failure must not fail the save. + } } if (patch.envVars !== undefined) { await secrets.syncEnvBindingsForTarget( @@ -797,7 +809,9 @@ export function environmentRoutes( entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); - res.json(environment); + res.json(customImageReconciliation.action === "none" + ? environment + : { ...environment, customImageReconciliation }); }); router.delete("/environments/:id", async (req, res) => { @@ -871,6 +885,8 @@ export function environmentRoutes( assertCanAccessInstanceEnvironments(req); const actor = getActorInfo(req); const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false }); + const companyIdForProbe = companyIdForSecrets + ?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null); if (!companyIdForSecrets) { const secretRefs = await collectEnvironmentSecretRefs({ db, environment }); if (secretRefs.length > 0) { @@ -880,9 +896,10 @@ export function environmentRoutes( } } const probe = await probeEnvironment(db, environment, { - companyId: companyIdForSecrets, + companyId: companyIdForProbe, pluginWorkerManager: options.pluginWorkerManager, applyCustomImageTemplate: environment.driver === "sandbox", + acquireSandboxRuntimeLease: environment.driver === "sandbox", }); await logInstanceEnvironmentActivity({ actor, diff --git a/server/src/routes/inbox-dismissals.ts b/server/src/routes/inbox-dismissals.ts index 1b51633e56f..ceae79c19c4 100644 --- a/server/src/routes/inbox-dismissals.ts +++ b/server/src/routes/inbox-dismissals.ts @@ -1,14 +1,50 @@ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { inboxDismissalService, logActivity } from "../services/index.js"; +const ITEM_KEY_RE = /^(approval|join|run|attention):.+$/; + const inboxDismissalSchema = z.object({ - itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).default("dismiss"), + snoozedUntil: z.string().trim().optional(), +}).superRefine((value, ctx) => { + if (value.kind === "dismiss") { + if (value.snoozedUntil != null) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" }); + } + return; + } + + if (!value.snoozedUntil) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Snooze requires snoozedUntil" }); + return; + } + const timestamp = new Date(value.snoozedUntil).getTime(); + if (!Number.isFinite(timestamp)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be an ISO timestamp" }); + return; + } + if (timestamp <= Date.now()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be in the future" }); + } }); +function requireBoardUser(req: Request, res: Response) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return null; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + export function inboxDismissalRoutes(db: Db) { const router = Router(); const svc = inboxDismissalService(db); @@ -16,15 +52,10 @@ export function inboxDismissalRoutes(db: Db) { router.get("/companies/:companyId/inbox-dismissals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } - const dismissals = await svc.list(companyId, req.actor.userId); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const dismissals = await svc.list(companyId, userId); res.json(dismissals); }); @@ -34,16 +65,12 @@ export function inboxDismissalRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } + const userId = requireBoardUser(req, res); + if (!userId) return; - const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date()); + const dismissal = req.body.kind === "snooze" + ? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil)) + : await svc.dismiss(companyId, userId, req.body.itemKey, new Date()); const actor = getActorInfo(req); await logActivity(db, { companyId, @@ -51,13 +78,15 @@ export function inboxDismissalRoutes(db: Db) { actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: "inbox.dismissed", + action: dismissal.kind === "snooze" ? "inbox.snoozed" : "inbox.dismissed", entityType: "company", entityId: companyId, details: { - userId: req.actor.userId, + userId, itemKey: dismissal.itemKey, + kind: dismissal.kind, dismissedAt: dismissal.dismissedAt, + snoozedUntil: dismissal.snoozedUntil, }, }); @@ -65,5 +94,42 @@ export function inboxDismissalRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/inbox-dismissals/:itemKey", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const itemKey = req.params.itemKey as string; + if (!ITEM_KEY_RE.test(itemKey)) { + res.status(400).json({ error: "Unsupported inbox item key" }); + return; + } + + const restored = await svc.restore(companyId, userId, itemKey); + if (restored) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "inbox.restored", + entityType: "company", + entityId: companyId, + details: { + userId, + itemKey: restored.itemKey, + kind: restored.kind, + dismissedAt: restored.dismissedAt, + snoozedUntil: restored.snoozedUntil, + }, + }); + } + + res.status(204).send(); + }); + return router; } diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index c637677c9f1..aef7f589013 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,6 +15,7 @@ export { secretRoutes } from "./secrets.js"; export { costRoutes } from "./costs.js"; export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; +export { attentionRoutes } from "./attention.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { sidebarPreferenceRoutes } from "./sidebar-preferences.js"; export { resourceMembershipRoutes } from "./resource-memberships.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6dc7ab4e3f5..391c5a25b33 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -53,6 +53,7 @@ import { rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, updateIssueWorkProductSchema, updateDocumentAnnotationThreadSchema, upsertIssueDocumentSchema, @@ -1766,6 +1767,18 @@ function isAssigneeSelfCommentOnTerminalIssue(input: { return input.actorId === input.assigneeAgentId; } +const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000; + +function buildRequestItemVerdictsWakeIdempotencyKey(args: { + issueId: string; + interactionId: string; + at?: Date; +}) { + const now = args.at ?? new Date(); + const bucket = Math.floor(now.getTime() / REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS); + return `request_item_verdicts:${args.issueId}:${args.interactionId}:${bucket}`; +} + function queueResolvedInteractionContinuationWakeup(input: { heartbeat: ReturnType; issue: { id: string; assigneeAgentId: string | null; status: string }; @@ -1783,6 +1796,8 @@ function queueResolvedInteractionContinuationWakeup(input: { source: string; forceFreshSession?: boolean; workspaceRefreshReason?: string | null; + newlyResolvedItemIds?: string[]; + idempotencyKey?: string | null; }) { if ( input.interaction.continuationPolicy !== "wake_assignee" @@ -1800,6 +1815,13 @@ function queueResolvedInteractionContinuationWakeup(input: { const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id); const interactionResult = readConfirmationResultForWake(input.interaction.result); const checkboxSelection = readCheckboxSelectionForWake(input.interaction); + const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? []; + const itemVerdicts = newlyResolvedItemIds.length > 0 + ? { + newlyResolvedItemIds, + coalesceWindowMs: REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS, + } + : null; const planReviewInteraction = planTarget && input.interaction.kind === "request_confirmation" ? { @@ -1824,8 +1846,10 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), ...(checkboxSelection ? { checkboxSelection } : {}), + ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}), mutation: "interaction", }, + idempotencyKey: input.idempotencyKey ?? null, requestedByActorType: input.actor.actorType, requestedByActorId: input.actor.actorId, contextSnapshot: { @@ -1838,6 +1862,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), ...(checkboxSelection ? { checkboxSelection } : {}), + ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}), wakeReason: "issue_commented", source: input.source, ...(forceFreshSession ? { forceFreshSession: true } : {}), @@ -9181,6 +9206,76 @@ export function issueRoutes( }, ); + router.post( + "/issues/:id/interactions/:interactionId/verdicts", + validate(submitIssueThreadInteractionVerdictsSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; + assertBoard(req); + + const actor = getActorInfo(req); + const { interaction, newlyResolvedItemIds } = await issueThreadInteractionService(db).submitItemVerdicts( + issue, + interactionId, + req.body, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_item_verdicts_submitted", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0, + newlyResolvedItemCount: newlyResolvedItemIds.length, + newlyResolvedItemIds, + complete: + interaction.kind === "request_item_verdicts" + ? (interaction.result?.complete ?? false) + : false, + }, + }); + + if (newlyResolvedItemIds.length > 0) { + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.verdicts", + newlyResolvedItemIds, + idempotencyKey: buildRequestItemVerdictsWakeIdempotencyKey({ + issueId: issue.id, + interactionId: interaction.id, + }), + }); + } + + res.json(interaction); + }, + ); + router.post( "/issues/:id/interactions/:interactionId/cancel", validate(cancelIssueThreadInteractionSchema), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5f897a14f89..1c485cda35c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -111,6 +111,7 @@ import { acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, // Auth / profile updateCurrentUserProfileSchema, // Company portability (legacy routes) @@ -2813,6 +2814,15 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/attention", + tags: ["inbox"], + summary: "List decision-only attention feed items", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "get", path: "/api/sidebar-preferences/me", @@ -2866,14 +2876,25 @@ registry.registerPath({ method: "post", path: "/api/companies/{companyId}/inbox-dismissals", tags: ["inbox"], - summary: "Create an inbox dismissal", + summary: "Create an inbox dismissal or snooze", request: { params: z.object({ companyId: z.string() }), body: jsonBody(z.object({ - itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).optional(), + snoozedUntil: z.string().datetime().optional(), })), }, - responses: { 200: r.ok(), 401: r.unauthorized }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/inbox-dismissals/{itemKey}", + tags: ["inbox"], + summary: "Restore an inbox dismissal or snooze", + request: { params: z.object({ companyId: z.string(), itemKey: z.string() }) }, + responses: { 204: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); // ─── Instance settings ──────────────────────────────────────────────────────── @@ -3433,6 +3454,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/verdicts", + tags: ["issues"], + summary: "Submit item verdicts on an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(submitIssueThreadInteractionVerdictsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/issues/{id}/children", diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 8b4d1f22852..770bbc28479 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -9,11 +9,19 @@ import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-d import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( - dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>, + dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>, ): Map { - return new Map( - dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]), - ); + const now = Date.now(); + const entries: Array<[string, number]> = []; + for (const dismissal of dismissals) { + if (dismissal.kind === "snooze") { + const snoozedUntil = dismissal.snoozedUntil ? new Date(dismissal.snoozedUntil).getTime() : 0; + if (Number.isFinite(snoozedUntil) && snoozedUntil > now) entries.push([dismissal.itemKey, Number.MAX_SAFE_INTEGER]); + continue; + } + entries.push([dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]); + } + return new Map(entries); } export function sidebarBadgeRoutes(db: Db) { @@ -59,7 +67,12 @@ export function sidebarBadgeRoutes(db: Db) { const dismissedAtByKey = req.actor.type === "board" && req.actor.userId ? await db - .select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt }) + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) .from(inboxDismissals) .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId))) .then(buildDismissedAtByKey) diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts new file mode 100644 index 00000000000..203a263cf89 --- /dev/null +++ b/server/src/services/attention.ts @@ -0,0 +1,1253 @@ +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + approvals, + assets, + companies, + heartbeatRunEvents, + heartbeatRuns, + inboxDismissals, + invites, + issueApprovals, + issueAttachments, + issueDocuments, + issueRecoveryActions, + issueRelations, + issueThreadInteractions, + issues, + joinRequests, + documents, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { deriveProjectUrlKey } from "@paperclipai/shared"; +import type { + AttentionDecisionVerb, + AttentionFeed, + AttentionDetailImage, + AttentionItem, + AttentionItemDetail, + AttentionProjectRef, + AttentionSeverity, + AttentionSourceKind, + AttentionSubject, + AttentionWorkspaceRef, +} from "@paperclipai/shared"; +import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; +import { budgetService } from "./budgets.js"; +import { issueService } from "./issues.js"; +import { parseIssueExecutionState } from "./issue-execution-policy.js"; + +const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [ + "approval", + "issue_thread_interaction", + "join_request", + "recovery_action", + "productivity_review", + "blocker_attention", + "review", + "failed_run", + "budget_alert", + "agent_error_alert", +]; + +const SEVERITY_RANK: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +const SOURCE_RANK: Record = { + failed_run: 0, + recovery_action: 1, + blocker_attention: 2, + budget_alert: 3, + agent_error_alert: 4, + approval: 5, + issue_thread_interaction: 6, + review: 7, + productivity_review: 8, + join_request: 9, +}; + +const PENDING_INTERACTION_STATUSES = ["pending"] as const; +const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const; +const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; +const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const; +const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const; +const DETAIL_EXCERPT_LENGTH = 160; +const DETAIL_IMAGE_LIMIT = 3; + +type IssueSummaryRow = { + id: string; + companyId: string; + identifier: string | null; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + createdAt: Date; + updatedAt: Date; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; +}; + +type IssueSubjectRow = Omit; + +type DismissalState = { + kind: "dismiss" | "snooze"; + dismissedAt: Date; + snoozedUntil: Date | null; +}; + +type PlanDocumentSummary = { + title: string | null; + body: string; +}; + +type BlockingIssueSummary = { + id: string | null; + identifier: string | null; + title: string | null; +}; + +type AttentionListOptions = { + userId?: string | null; + includeDismissed?: boolean; +}; + +function emptyCounts(): Record { + return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record; +} + +function toIso(value: Date | string | null | undefined): string { + if (!value) return new Date(0).toISOString(); + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function timestamp(value: Date | string | null | undefined): number { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function activeDismissalState( + dismissalByKey: ReadonlyMap, + dismissalKey: string, + activityAt: string, + now: number, +) { + const dismissal = dismissalByKey.get(dismissalKey); + if (!dismissal) return null; + + const dismissedAt = toIso(dismissal.dismissedAt); + const snoozedUntil = dismissal.snoozedUntil ? toIso(dismissal.snoozedUntil) : null; + const isActive = dismissal.kind === "snooze" + ? dismissal.snoozedUntil != null && timestamp(dismissal.snoozedUntil) > now + : timestamp(dismissal.dismissedAt) >= timestamp(activityAt); + + return { + kind: dismissal.kind, + dismissedAt, + snoozedUntil, + isActive, + }; +} + +function stripMarkdown(value: string) { + return value + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/[>*_~#-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function excerpt(value: unknown, maxLength = DETAIL_EXCERPT_LENGTH) { + if (typeof value !== "string") return null; + const cleaned = stripMarkdown(value); + if (!cleaned) return null; + if (cleaned.length <= maxLength) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} + +function readRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isPlanDocumentTarget(payload: Record) { + const target = readRecord(payload.target); + return target.type === "issue_document" && target.key === "plan"; +} + +function issueContext(issue: IssueSummaryRow | null | undefined) { + return { + project: issue?.project ?? null, + workspace: issue?.workspace ?? null, + }; +} + +function issueImages(imageMap: ReadonlyMap, issueId: string | null | undefined) { + return issueId ? imageMap.get(issueId) ?? [] : []; +} + +function genericDetail(summary: unknown, images: AttentionDetailImage[]): AttentionItemDetail { + return { kind: "generic", summaryExcerpt: excerpt(summary), images }; +} + +function approvalDetail(type: string, payload: Record): AttentionItemDetail { + return { + kind: "approval", + approvalType: type, + summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction), + images: [], + }; +} + +function interactionDetail(input: { + kind: string; + payload: Record; + issue: IssueSummaryRow | null; + planDocument: PlanDocumentSummary | null; + images: AttentionDetailImage[]; +}): AttentionItemDetail { + if (input.kind === "request_confirmation" && isPlanDocumentTarget(input.payload)) { + return { + kind: "plan_approval", + issueTitle: input.issue?.title ?? null, + planTitle: input.planDocument?.title ?? "Plan", + summaryExcerpt: excerpt(input.planDocument?.body ?? input.payload.detailsMarkdown ?? input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "ask_user_questions") { + const questions = readArray(input.payload.questions).map(readRecord); + return { + kind: "questions", + questionCount: questions.length, + firstQuestionText: readString(questions[0]?.prompt), + images: input.images, + }; + } + + if (input.kind === "suggest_tasks") { + const tasks = readArray(input.payload.tasks).map(readRecord); + return { + kind: "suggested_tasks", + taskCount: tasks.length, + firstTaskTitle: readString(tasks[0]?.title), + images: input.images, + }; + } + + if (input.kind === "request_checkbox_confirmation") { + return { + kind: "checkbox_confirmation", + optionCount: readArray(input.payload.options).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "request_item_verdicts") { + return { + kind: "item_verdicts", + itemCount: readArray(input.payload.items).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + return { + kind: "confirmation", + promptExcerpt: excerpt(input.payload.prompt ?? input.payload.detailsMarkdown), + isPlanTarget: false, + images: input.images, + }; +} + +function issueHref(prefix: string, issue: Pick) { + return `/${prefix}/issues/${issue.identifier ?? issue.id}`; +} + +function issueSubject(prefix: string, issue: IssueSubjectRow): AttentionSubject { + return { + kind: "issue", + id: issue.id, + companyId: issue.companyId, + title: issue.title, + identifier: issue.identifier, + status: issue.status, + href: issueHref(prefix, issue), + metadata: { + priority: issue.priority, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + }, + }; +} + +function itemId(sourceKind: AttentionSourceKind, dedupKey: string) { + return `${sourceKind}:${dedupKey}`; +} + +function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb[] { + return verbs; +} + +type CreateAttentionItemInput = Omit & { + project?: AttentionProjectRef | null; + workspace?: AttentionWorkspaceRef | null; + detail?: AttentionItemDetail | null; +}; + +function createItem(input: CreateAttentionItemInput): AttentionItem { + return { + ...input, + id: itemId(input.sourceKind, input.dedupKey), + dismissalKey: `attention:${input.dedupKey}`, + dismissal: null, + project: input.project ?? null, + workspace: input.workspace ?? null, + detail: input.detail ?? null, + rank: 0, + }; +} + +function compareAttentionItems(left: AttentionItem, right: AttentionItem) { + const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt); + if (timeDiff !== 0) return timeDiff; + const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; + if (severityDiff !== 0) return severityDiff; + const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind]; + if (sourceDiff !== 0) return sourceDiff; + return left.dedupKey.localeCompare(right.dedupKey); +} + +function betterDuplicate(left: AttentionItem, right: AttentionItem) { + return compareAttentionItems(left, right) <= 0 ? left : right; +} + +function approvalTitle(type: string, payload: Record) { + const title = typeof payload.title === "string" ? payload.title.trim() : ""; + if (title) return title; + const summary = typeof payload.summary === "string" ? payload.summary.trim() : ""; + if (summary) return summary; + return type.replaceAll("_", " "); +} + +function interactionLabel(kind: string) { + switch (kind) { + case "request_confirmation": + return "Confirmation requested"; + case "request_checkbox_confirmation": + return "Selection confirmation requested"; + case "ask_user_questions": + return "Questions need answers"; + case "suggest_tasks": + return "Suggested tasks need a decision"; + case "request_item_verdicts": + return "Item verdicts need a decision"; + default: + return "Interaction needs a decision"; + } +} + +function interactionVerbs(kind: string, payload: Record) { + if (kind === "ask_user_questions") { + return decisionVerbs({ + id: "respond", + label: "Respond", + description: "Submit answers to the pending questions.", + }); + } + if (kind === "request_confirmation") { + const acceptLabel = typeof payload.acceptLabel === "string" && payload.acceptLabel.trim() + ? payload.acceptLabel.trim() + : "Confirm"; + const rejectLabel = typeof payload.rejectLabel === "string" && payload.rejectLabel.trim() + ? payload.rejectLabel.trim() + : "Decline"; + return decisionVerbs( + { + id: "accept", + label: acceptLabel, + description: "Accept the pending confirmation.", + }, + { + id: "reject", + label: rejectLabel, + description: "Decline the pending confirmation.", + }, + ); + } + return decisionVerbs( + { + id: "accept", + label: "Accept", + description: "Accept the pending interaction.", + }, + { + id: "reject", + label: "Reject", + description: "Reject the pending interaction and provide a reason when required.", + }, + ); +} + +function budgetObservedPercent(amountObserved: number, amountLimit: number) { + return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0; +} + +async function companyPrefix(db: Db, companyId: string) { + const row = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + return row?.issuePrefix ?? "PAP"; +} + +async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) { + if (!userId) return new Map(); + const rows = await db + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) + .from(inboxDismissals) + .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId))); + return new Map(rows.map((row) => [row.itemKey, { + kind: row.kind, + dismissedAt: row.dismissedAt, + snoozedUntil: row.snoozedUntil, + }])); +} + +async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + projectId: projects.id, + projectName: projects.name, + projectColor: projects.color, + projectIcon: projects.icon, + workspaceId: projectWorkspaces.id, + workspaceName: projectWorkspaces.name, + }) + .from(issues) + .leftJoin(projects, and(eq(issues.projectId, projects.id), eq(projects.companyId, companyId))) + .leftJoin(projectWorkspaces, and( + eq(issues.projectWorkspaceId, projectWorkspaces.id), + eq(projectWorkspaces.companyId, companyId), + )) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt))); + return new Map(rows.map((row) => [row.id, { + id: row.id, + companyId: row.companyId, + identifier: row.identifier, + title: row.title, + status: row.status, + priority: row.priority, + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + project: row.projectId && row.projectName ? { + id: row.projectId, + name: row.projectName, + urlKey: deriveProjectUrlKey(row.projectName, row.projectId), + color: row.projectColor, + icon: row.projectIcon, + } : null, + workspace: row.workspaceId && row.workspaceName ? { + id: row.workspaceId, + name: row.workspaceName, + } : null, + }])); +} + +async function issueImageMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueAttachments.issueId, + assetId: issueAttachments.assetId, + originalFilename: assets.originalFilename, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where(and( + eq(issueAttachments.companyId, companyId), + eq(assets.companyId, companyId), + inArray(issueAttachments.issueId, ids), + sql`${assets.contentType} like 'image/%'`, + )) + .orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id)); + + const map = new Map(); + for (const row of rows) { + const images = map.get(row.issueId) ?? []; + if (images.length >= DETAIL_IMAGE_LIMIT) continue; + images.push({ assetId: row.assetId, alt: row.originalFilename ?? null }); + map.set(row.issueId, images); + } + return map; +} + +async function planDocumentMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueDocuments.issueId, + title: documents.title, + body: documents.latestBody, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, companyId), + eq(documents.companyId, companyId), + eq(issueDocuments.key, "plan"), + inArray(issueDocuments.issueId, ids), + )); + return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }])); +} + +async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array) { + const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + blockedIssueId: issueRelations.relatedIssueId, + id: issues.id, + identifier: issues.identifier, + title: issues.title, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where(and( + eq(issueRelations.companyId, companyId), + eq(issues.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, ids), + isNull(issues.hiddenAt), + )) + .orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id)); + const map = new Map(); + for (const row of rows) { + if (!map.has(row.blockedIssueId)) { + map.set(row.blockedIssueId, { id: row.id, identifier: row.identifier, title: row.title }); + } + } + return map; +} + +function readRunIssueId(contextSnapshot: Record | null) { + const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId; + return typeof issueId === "string" && issueId.length > 0 ? issueId : null; +} + +export function attentionService(db: Db) { + return { + list: async (companyId: string, options: AttentionListOptions = {}): Promise => { + const prefix = await companyPrefix(db, companyId); + const dismissals = await dismissalByKey(db, companyId, options.userId); + const includeDismissed = options.includeDismissed === true; + const now = Date.now(); + const collected: AttentionItem[] = []; + + const add = (item: AttentionItem) => { + const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now); + if (!includeDismissed && dismissal?.isActive) return; + collected.push({ ...item, dismissal }); + }; + + const pendingApprovals = await db + .select({ + id: approvals.id, + type: approvals.type, + status: approvals.status, + requestedByAgentId: approvals.requestedByAgentId, + requestedByUserId: approvals.requestedByUserId, + payload: approvals.payload, + createdAt: approvals.createdAt, + updatedAt: approvals.updatedAt, + }) + .from(approvals) + .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"))) + .orderBy(desc(approvals.updatedAt), desc(approvals.id)); + + for (const approval of pendingApprovals) { + const dedupKey = `approval:${approval.id}`; + const title = approvalTitle(approval.type, approval.payload); + add(createItem({ + companyId, + sourceKind: "approval", + subject: { + kind: "approval", + id: approval.id, + companyId, + title, + identifier: null, + status: approval.status, + href: `/${prefix}/approvals/${approval.id}`, + metadata: { + type: approval.type, + requestedByAgentId: approval.requestedByAgentId, + requestedByUserId: approval.requestedByUserId, + }, + }, + whyNow: "Approval is pending a board decision.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve the request." }, + { id: "reject", label: "Reject", description: "Reject the request." }, + { id: "request_revision", label: "Request revision", description: "Send the request back for changes." }, + ), + inlineResolvable: approval.type !== "request_board_approval", + entryRule: "approvals.status = 'pending'", + exitRule: "Approval leaves pending status.", + dedupKey, + severity: "medium", + activityAt: toIso(approval.updatedAt), + createdAt: toIso(approval.createdAt), + updatedAt: toIso(approval.updatedAt), + relatedIssue: null, + detail: approvalDetail(approval.type, approval.payload), + })); + } + + const interactionRows = await db + .select({ + id: issueThreadInteractions.id, + issueId: issueThreadInteractions.issueId, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + title: issueThreadInteractions.title, + summary: issueThreadInteractions.summary, + payload: issueThreadInteractions.payload, + createdAt: issueThreadInteractions.createdAt, + updatedAt: issueThreadInteractions.updatedAt, + }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, companyId), + inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), + )) + .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); + const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId)); + + for (const interaction of interactionRows) { + const issue = interactionIssueMap.get(interaction.issueId) ?? null; + const payload = readRecord(interaction.payload); + const detail = interactionDetail({ + kind: interaction.kind, + payload, + issue, + planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null, + images: issueImages(interactionImageMap, interaction.issueId), + }); + const isPlanTarget = detail.kind === "plan_approval"; + const dedupKey = `interaction:${interaction.id}`; + add(createItem({ + companyId, + sourceKind: "issue_thread_interaction", + subject: { + kind: "interaction", + id: interaction.id, + companyId, + title: isPlanTarget && issue ? `Plan approval - ${issue.title}` : interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind), + identifier: null, + status: interaction.status, + href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null, + metadata: { + kind: interaction.kind, + issueId: interaction.issueId, + isPlanTarget, + targetDocumentKey: isPlanTarget ? "plan" : null, + }, + }, + whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`, + decisionVerbs: interactionVerbs(interaction.kind, payload), + inlineResolvable: true, + entryRule: "issue_thread_interactions.status = 'pending'", + exitRule: "Interaction resolves, expires, fails, or is cancelled.", + dedupKey, + severity: "medium", + activityAt: toIso(interaction.updatedAt), + createdAt: toIso(interaction.createdAt), + updatedAt: toIso(interaction.updatedAt), + relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail, + })); + } + + const pendingJoins = await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + agentName: joinRequests.agentName, + adapterType: joinRequests.adapterType, + createdAt: joinRequests.createdAt, + updatedAt: joinRequests.updatedAt, + }) + .from(joinRequests) + .innerJoin(invites, eq(joinRequests.inviteId, invites.id)) + .where(and( + eq(joinRequests.companyId, companyId), + eq(invites.companyId, companyId), + eq(joinRequests.status, "pending_approval"), + )) + .orderBy(desc(joinRequests.updatedAt), desc(joinRequests.id)); + + for (const join of pendingJoins) { + const label = join.requestType === "agent" + ? join.agentName ?? "Agent join request" + : join.requestEmailSnapshot ?? join.requestingUserId ?? "Human join request"; + const dedupKey = `join:${join.id}`; + add(createItem({ + companyId, + sourceKind: "join_request", + subject: { + kind: "join_request", + id: join.id, + companyId, + title: label, + identifier: null, + status: join.status, + href: `/${prefix}/settings/access`, + metadata: { + requestType: join.requestType, + requestingUserId: join.requestingUserId, + adapterType: join.adapterType, + }, + }, + whyNow: "Join request is pending approval.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve this join request." }, + { id: "reject", label: "Reject", description: "Reject this join request." }, + ), + inlineResolvable: true, + entryRule: "join_requests.status = 'pending_approval'", + exitRule: "Join request is approved or rejected.", + dedupKey, + severity: "medium", + activityAt: toIso(join.updatedAt), + createdAt: toIso(join.createdAt), + updatedAt: toIso(join.updatedAt), + relatedIssue: null, + detail: genericDetail(label, []), + })); + } + + const recoveryRows = await db + .select() + .from(issueRecoveryActions) + .where(and( + eq(issueRecoveryActions.companyId, companyId), + inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]), + inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]), + )) + .orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)); + const recoveryIssueMap = await issueSummaryMap( + db, + companyId, + recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]), + ); + const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId)); + + for (const recovery of recoveryRows) { + const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; + const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null; + const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`; + add(createItem({ + companyId, + sourceKind: "recovery_action", + subject: { + kind: "recovery_action", + id: recovery.id, + companyId, + title: recovery.nextAction, + identifier: null, + status: recovery.status, + href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null, + metadata: { + kind: recovery.kind, + cause: recovery.cause, + ownerType: recovery.ownerType, + ownerUserId: recovery.ownerUserId, + sourceIssueId: recovery.sourceIssueId, + recoveryIssueId: recovery.recoveryIssueId, + }, + }, + whyNow: recovery.status === "escalated" + ? "Recovery action escalated to a human owner." + : "Recovery action is assigned to a human owner.", + decisionVerbs: decisionVerbs( + { id: "resolve", label: "Resolve", description: "Record the recovery outcome." }, + { id: "reassign", label: "Reassign", description: "Move the recovery to another owner." }, + { id: "cancel", label: "Cancel", description: "Cancel the recovery action." }, + ), + inlineResolvable: false, + entryRule: "issue_recovery_actions.status in ('active','escalated') and owner_type in ('user','board')", + exitRule: "Recovery action resolves, is cancelled, or moves back to an agent/system owner.", + dedupKey, + severity: recovery.status === "escalated" ? "high" : "medium", + activityAt: toIso(recovery.updatedAt), + createdAt: toIso(recovery.createdAt), + updatedAt: toIso(recovery.updatedAt), + relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(sourceIssue), + detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)), + })); + } + + const productivityRows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + originId: issues.originId, + originFingerprint: issues.originFingerprint, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), + isNull(issues.hiddenAt), + isNotNull(issues.assigneeUserId), + notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]), + )) + .orderBy(desc(issues.updatedAt), desc(issues.id)); + const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId)); + const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id)); + const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id)); + + for (const review of productivityRows) { + const reviewIssue = productivityReviewMap.get(review.id); + if (!reviewIssue) continue; + const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null; + const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`; + add(createItem({ + companyId, + sourceKind: "productivity_review", + subject: issueSubject(prefix, reviewIssue), + whyNow: "Productivity review is awaiting a human decision.", + decisionVerbs: decisionVerbs( + { id: "resolve", label: "Resolve", description: "Record a productivity review outcome." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." }, + { id: "reassign", label: "Reassign", description: "Move the review to another owner." }, + ), + inlineResolvable: false, + entryRule: "Open issue_productivity_review issue assigned to a user.", + exitRule: "Review issue is done/cancelled or no longer assigned to a user.", + dedupKey, + severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium", + activityAt: toIso(review.updatedAt), + createdAt: toIso(review.createdAt), + updatedAt: toIso(review.updatedAt), + relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(reviewIssue), + detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)), + })); + } + + const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); + const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); + for (const issue of blockedIssues as Array) { + const blockerAttention = issue.blockerAttention; + if (blockerAttention?.state !== "stalled") continue; + const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; + const summarizedIssue = issueSummary ?? issue; + const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id; + const blockingIssue = blockingIssues.get(issue.id) ?? { id: null, identifier: sample, title: null }; + const dedupKey = `blocker:${issue.id}:${sample}`; + add(createItem({ + companyId, + sourceKind: "blocker_attention", + subject: issueSubject(prefix, summarizedIssue), + whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", + decisionVerbs: decisionVerbs( + { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, + { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." }, + { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." }, + ), + inlineResolvable: false, + entryRule: "blocked issue has blockerAttention.state = 'stalled'", + exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.", + dedupKey, + severity: "high", + activityAt: toIso(issue.updatedAt), + createdAt: toIso(issue.createdAt), + updatedAt: toIso(issue.updatedAt), + relatedIssue: null, + ...issueContext(issueSummary), + detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) }, + })); + } + + const reviewRows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + executionState: issues.executionState, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt))) + .orderBy(desc(issues.updatedAt), desc(issues.id)); + const reviewIssueIds = reviewRows.map((row) => row.id); + const pendingReviewApprovalRows = reviewIssueIds.length === 0 + ? [] + : await db + .select({ issueId: issueApprovals.issueId, approvalId: approvals.id }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where(and( + eq(issueApprovals.companyId, companyId), + eq(approvals.companyId, companyId), + inArray(issueApprovals.issueId, reviewIssueIds), + eq(approvals.status, "pending"), + )); + const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId])); + const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds); + const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds); + + for (const review of reviewRows) { + const state = parseIssueExecutionState(review.executionState); + const currentParticipant = state?.status === "pending" ? state.currentParticipant : null; + const hasHumanParticipant = currentParticipant?.type === "user"; + const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null; + if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue; + const issue = reviewIssueMap.get(review.id); + if (!issue) continue; + const dedupKey = `review:${review.id}`; + add(createItem({ + companyId, + sourceKind: "review", + subject: issueSubject(prefix, issue), + whyNow: pendingApprovalId + ? "Issue is in review with a linked pending approval." + : hasHumanParticipant + ? "Issue is in review and the current execution participant is a user." + : "Issue is in review and assigned to a user.", + decisionVerbs: decisionVerbs( + { id: "approve", label: "Approve", description: "Approve the review and advance the issue." }, + { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." }, + ), + inlineResolvable: false, + entryRule: "issues.status = 'in_review' and human reviewer, user assignee, or linked pending approval exists.", + exitRule: "Issue leaves in_review or the human review path resolves.", + dedupKey, + severity: "medium", + activityAt: toIso(review.updatedAt), + createdAt: toIso(review.createdAt), + updatedAt: toIso(review.updatedAt), + relatedIssue: null, + ...issueContext(issue), + detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)), + })); + } + + const exhaustedRunRows = await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + agentName: agents.name, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + createdAt: heartbeatRuns.createdAt, + updatedAt: heartbeatRuns.updatedAt, + finishedAt: heartbeatRuns.finishedAt, + exhaustionMessage: heartbeatRunEvents.message, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .innerJoin(heartbeatRunEvents, eq(heartbeatRunEvents.runId, heartbeatRuns.id)) + .where(and( + eq(heartbeatRuns.companyId, companyId), + eq(agents.companyId, companyId), + notInArray(agents.status, ["terminated"]), + inArray(heartbeatRuns.status, [...FAILED_RUN_STATUSES]), + eq(heartbeatRunEvents.companyId, companyId), + eq(heartbeatRunEvents.eventType, "lifecycle"), + sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, + )) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRunEvents.id)); + + const latestExhaustedByRunId = new Map(); + for (const row of exhaustedRunRows) { + if (!latestExhaustedByRunId.has(row.id)) latestExhaustedByRunId.set(row.id, row); + } + const failedRows = [...latestExhaustedByRunId.values()]; + const failedIssueIds = failedRows.map((row) => readRunIssueId(row.contextSnapshot)); + const failedIssueMap = await issueSummaryMap( + db, + companyId, + failedIssueIds, + ); + const failedImageMap = await issueImageMap(db, companyId, failedIssueIds); + const failedAgentIds = [...new Set(failedRows.map((row) => row.agentId))]; + const oldestFailedRunCreatedAt = failedRows.reduce((oldest, row) => { + if (!oldest || row.createdAt < oldest) return row.createdAt; + return oldest; + }, null); + const latestRunCreatedAtByKey = new Map(); + if (oldestFailedRunCreatedAt && failedAgentIds.length > 0) { + const newerRuns = await db + .select({ + agentId: heartbeatRuns.agentId, + createdAt: heartbeatRuns.createdAt, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.agentId, failedAgentIds), + gt(heartbeatRuns.createdAt, oldestFailedRunCreatedAt), + )); + for (const newerRun of newerRuns) { + const newerRunKey = `${newerRun.agentId}:${readRunIssueId(newerRun.contextSnapshot) ?? ""}`; + const latestCreatedAt = latestRunCreatedAtByKey.get(newerRunKey); + if (!latestCreatedAt || newerRun.createdAt > latestCreatedAt) { + latestRunCreatedAtByKey.set(newerRunKey, newerRun.createdAt); + } + } + } + for (const run of failedRows) { + const issueId = readRunIssueId(run.contextSnapshot); + const runKey = `${run.agentId}:${issueId ?? ""}`; + const hasNewerRun = (latestRunCreatedAtByKey.get(runKey)?.getTime() ?? 0) > run.createdAt.getTime(); + if (hasNewerRun) continue; + + const issue = issueId ? failedIssueMap.get(issueId) ?? null : null; + const dedupKey = `run:${run.id}`; + add(createItem({ + companyId, + sourceKind: "failed_run", + subject: { + kind: "run", + id: run.id, + companyId, + title: `${run.agentName} run ${run.status}`, + identifier: null, + status: run.status, + href: `/${prefix}/agents/${run.agentId}/runs/${run.id}`, + metadata: { + agentId: run.agentId, + agentName: run.agentName, + issueId, + errorCode: run.errorCode, + error: run.error, + retryExhaustedReason: run.exhaustionMessage, + }, + }, + whyNow: "Run failed after automatic retries were exhausted.", + decisionVerbs: decisionVerbs( + { id: "retry", label: "Retry", description: "Retry the failed run or issue." }, + { id: "reassign", label: "Reassign", description: "Move the work to another owner." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this failed-run attention row." }, + ), + inlineResolvable: true, + entryRule: "latest failed/timed_out run has a Bounded retry exhausted lifecycle event.", + exitRule: "A newer run exists for the same issue/agent pair or the row is dismissed.", + dedupKey, + severity: "high", + activityAt: toIso(run.finishedAt ?? run.updatedAt ?? run.createdAt), + createdAt: toIso(run.createdAt), + updatedAt: toIso(run.updatedAt), + relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail: { + kind: "failed_run", + agentName: run.agentName, + failureReasonExcerpt: excerpt(run.error ?? run.exhaustionMessage ?? run.errorCode), + images: issueImages(failedImageMap, issueId), + }, + })); + } + + const budgetOverview = await budgetService(db).overview(companyId); + for (const incident of budgetOverview.activeIncidents) { + const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit); + if (incident.thresholdType !== "hard" && observedPercent < 85) continue; + const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`; + add(createItem({ + companyId, + sourceKind: "budget_alert", + subject: { + kind: "budget_incident", + id: incident.id, + companyId, + title: `${incident.scopeName} budget ${incident.thresholdType === "hard" ? "hard stop" : "warning"}`, + identifier: null, + status: incident.status, + href: `/${prefix}/costs`, + metadata: { + policyId: incident.policyId, + scopeType: incident.scopeType, + scopeId: incident.scopeId, + thresholdType: incident.thresholdType, + amountObserved: incident.amountObserved, + amountLimit: incident.amountLimit, + observedPercent, + approvalId: incident.approvalId, + approvalStatus: incident.approvalStatus, + }, + }, + whyNow: incident.thresholdType === "hard" + ? "Budget hard stop was reached." + : "Budget crossed the 85% warning threshold.", + decisionVerbs: decisionVerbs( + { id: "raise_budget_and_resume", label: "Raise budget", description: "Raise the budget and resume paused work." }, + { id: "keep_paused", label: "Keep paused", description: "Dismiss or keep the budget stop in place." }, + ), + inlineResolvable: true, + entryRule: "open budget incident is hard, or soft with observed spend >= 85% of limit.", + exitRule: "Budget incident is resolved or dismissed.", + dedupKey, + severity: incident.thresholdType === "hard" ? "high" : "medium", + activityAt: toIso(incident.updatedAt), + createdAt: toIso(incident.createdAt), + updatedAt: toIso(incident.updatedAt), + relatedIssue: null, + detail: { + kind: "budget", + observedPercent, + amountObserved: incident.amountObserved, + amountLimit: incident.amountLimit, + images: [], + }, + })); + } + + const erroredAgents = await db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + role: agents.role, + status: agents.status, + errorReason: agents.errorReason, + createdAt: agents.createdAt, + updatedAt: agents.updatedAt, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.status, "error"))) + .orderBy(desc(agents.updatedAt), desc(agents.id)); + + for (const agent of erroredAgents) { + const dedupKey = `agent_error:${agent.id}`; + add(createItem({ + companyId, + sourceKind: "agent_error_alert", + subject: { + kind: "agent", + id: agent.id, + companyId, + title: agent.name, + identifier: null, + status: agent.status, + href: `/${prefix}/agents/${agent.id}`, + metadata: { role: agent.role, errorReason: agent.errorReason }, + }, + whyNow: "Agent is in error status and needs operator action or dismissal.", + decisionVerbs: decisionVerbs( + { id: "inspect", label: "Inspect", description: "Inspect the agent error." }, + { id: "dismiss", label: "Dismiss", description: "Dismiss this alert." }, + ), + inlineResolvable: true, + entryRule: "agents.status = 'error'", + exitRule: "Agent leaves error status or the row is dismissed.", + dedupKey, + severity: "high", + activityAt: toIso(agent.updatedAt), + createdAt: toIso(agent.createdAt), + updatedAt: toIso(agent.updatedAt), + relatedIssue: null, + detail: { + kind: "agent_error", + agentName: agent.name, + failureReasonExcerpt: excerpt(agent.errorReason), + images: [], + }, + })); + } + + const deduped = new Map(); + for (const item of collected) { + const current = deduped.get(item.dedupKey); + deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item); + } + + const items = [...deduped.values()] + .sort(compareAttentionItems) + .map((item, index) => ({ ...item, rank: index + 1 })); + const countsBySourceKind = emptyCounts(); + for (const item of items) countsBySourceKind[item.sourceKind] += 1; + + return { + companyId, + generatedAt: new Date().toISOString(), + totalCount: items.length, + countsBySourceKind, + items, + }; + }, + }; +} diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index e7191f3817a..ae3179457ca 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; @@ -138,20 +139,112 @@ export interface RequiredBuiltInAgent { const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/; -const BUILT_INS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../built-ins/agents"); +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const BUILT_INS_DIR = path.resolve(moduleDir, "../built-ins/agents"); +const SOURCE_BUILT_INS_DIR = path.resolve(moduleDir, "../../src/built-ins/agents"); -function readBuiltInText(relativePath: string) { - return readFileSync(path.join(BUILT_INS_DIR, relativePath), "utf8"); +const FALLBACK_REFLECTION_COACH_INSTRUCTIONS = [ + "# Reflection Coach", + "", + "You are Paperclip's built-in Reflection Coach.", + "Review recent agent execution records, identify evidence-backed improvement patterns, and propose the smallest durable instruction, skill, or tool-description change.", + "Do not apply changes in the same run. Present a reviewable diff and wait for the required Paperclip issue-thread approval before any follow-up applies it.", + "", +].join("\n"); + +const FALLBACK_REFLECTION_COACH_ROUTINE = [ + "Review recent agent work for coaching opportunities.", + "", + "Select recent target agents, inspect their work history and current instructions, then propose small, review-gated improvements that would prevent repeated misses.", + "", +].join("\n"); + +const FALLBACK_REFLECTION_COACH_SKILL = [ + "---", + "name: reflection-coach", + "description: Reflect on another agent's recent execution record and propose the smallest review-gated improvement.", + "key: paperclipai/bundled/paperclip-operations/reflection-coach", + "---", + "", + "# Reflection Coach", + "", + "Review another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable improvement as a reviewable diff. Do not hot-swap instructions or skills in the same run.", + "", +].join("\n"); + +const warnedBuiltInTextFallbacks = new Set(); +const warnedBuiltInTextReadErrors = new Set(); + +function resolvePackageRoot(packageName: string) { + try { + return path.dirname(require.resolve(`${packageName}/package.json`)); + } catch { + return null; + } } -const REFLECTION_COACH_INSTRUCTIONS = readBuiltInText("reflection-coach/AGENTS.md"); -const REFLECTION_COACH_ROUTINE = readBuiltInText("reflection-coach/routines/recent-agent-reflection.md"); -const REFLECTION_COACH_SKILL = readFileSync( - path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../../packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md", - ), - "utf8", +export function readBuiltInTextWithFallback( + label: string, + candidatePaths: string[], + fallbackText: string, +) { + const attemptedPaths = candidatePaths.filter((candidatePath) => candidatePath.trim().length > 0); + for (const candidatePath of attemptedPaths) { + try { + return readFileSync(candidatePath, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + const codeLabel = code || String(error); + const warningKey = [label, candidatePath, codeLabel].join(":"); + if (!warnedBuiltInTextReadErrors.has(warningKey)) { + warnedBuiltInTextReadErrors.add(warningKey); + console.warn( + "[paperclip] Built-in agent asset " + label + " read error on " + candidatePath + ": " + codeLabel, + ); + } + } + // Try every known runtime/source path before falling back to compiled text. + } + } + + if (!warnedBuiltInTextFallbacks.has(label)) { + warnedBuiltInTextFallbacks.add(label); + console.warn( + `[paperclip] Built-in agent asset ${label} was not readable; using bundled fallback text. ` + + `Checked: ${attemptedPaths.join(", ")}`, + ); + } + return fallbackText; +} + +function readBuiltInText(relativePath: string, fallbackText: string) { + return readBuiltInTextWithFallback( + relativePath, + [path.join(BUILT_INS_DIR, relativePath), path.join(SOURCE_BUILT_INS_DIR, relativePath)], + fallbackText, + ); +} + +const skillsCatalogRoot = resolvePackageRoot("@paperclipai/skills-catalog"); +const REFLECTION_COACH_INSTRUCTIONS = readBuiltInText("reflection-coach/AGENTS.md", FALLBACK_REFLECTION_COACH_INSTRUCTIONS); +const REFLECTION_COACH_ROUTINE = readBuiltInText( + "reflection-coach/routines/recent-agent-reflection.md", + FALLBACK_REFLECTION_COACH_ROUTINE, +); +const REFLECTION_COACH_SKILL = readBuiltInTextWithFallback( + "reflection-coach/SKILL.md", + [ + path.resolve( + moduleDir, + "../../../packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md", + ), + ...(skillsCatalogRoot + ? [path.join(skillsCatalogRoot, "catalog/bundled/paperclip-operations/reflection-coach/SKILL.md")] + : []), + ], + FALLBACK_REFLECTION_COACH_SKILL, ); const DEFINITIONS = validateBuiltInAgentDefinitions([ diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 0fe977fca5b..054224deb5a 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -76,6 +76,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).strict(); const pluginSandboxProviderKeySchema = z.string() @@ -91,6 +92,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).catchall(z.unknown()); const pluginEnvironmentConfigSchema = z.object({ @@ -633,6 +635,10 @@ export async function resolveEnvironmentDriverConfigForRuntime( environmentId, baseConfig: parsed.config, runtimeConfig, + // Match the capture-time fingerprint exclusions: secret-ref paths + // are excluded when the template's source fingerprint is computed, + // so they must be excluded when re-checking it here. + secretRefExcludePaths: collectSecretRefPaths(schema), }) : runtimeConfig, }; diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 9c56321cd43..254169290c8 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -8,11 +8,24 @@ import { type EnvironmentCustomImageTemplateKind, type SandboxEnvironmentConfig, } from "@paperclipai/shared"; -import { writeConfigValueAtPath } from "./json-schema-secret-refs.js"; +import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-secret-refs.js"; type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; +export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ + "timeoutMs", + "reuseLease", + "streamRunLogs", + "archiveOnRelease", + "cpu", + "memory", + "disk", + "gpu", + "autoStopInterval", + "autoArchiveInterval", + "autoDeleteInterval", +]; export interface EnvironmentCustomImageRuntimeConfigBinding { field: string; @@ -111,6 +124,101 @@ export function applyCustomImageTemplateToSandboxConfig( return next as SandboxEnvironmentConfig; } +export function environmentCustomImageTemplateMatchesBaseConfig(input: { + template: EnvironmentCustomImageTemplate; + baseConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; +}): boolean { + const expectedFingerprint = input.template.sourceEnvironmentConfigFingerprint; + if (!expectedFingerprint) return true; + // Capture-time fingerprints exclude both runtime-only fields and the + // provider's secret-ref paths (see finishSetupSession); the runtime match + // must exclude the same set or configs that carry a secret ref can never + // match and the active template gets silently dropped. + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + const normalizedFingerprint = fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + if (normalizedFingerprint === expectedFingerprint) return true; + // Backward compatibility for templates captured before runtime-only fields + // were excluded from the source fingerprint (secret-ref paths have always + // been excluded at capture time). + return fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: secretRefExcludePaths, + }) === expectedFingerprint; +} + +// Standard boot-source fields shared across sandbox providers. A change to any +// of these means the user asked for a different base, so a captured template +// no longer reflects the saved config and cannot simply be re-linked. +export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS = [ + "snapshot", + "image", + "template", +] as const; + +export type EnvironmentCustomImageConfigChangeKind = "none" | "relinkable" | "breaking"; + +/** + * Classifies a saved-config change relative to an active captured template. + * + * - `none`: the template either already matched the new config, or was already + * detached before this change; nothing to reconcile. + * - `relinkable`: only fields that cannot affect the captured template's + * contents or reachability changed (for example a region hint), so the + * template's source fingerprint can be re-stamped to the new config. + * - `breaking`: a boot-source field or a provider-declared template identity + * path changed; the captured template no longer corresponds to the config + * and a fresh capture is required. + */ +export function classifyEnvironmentCustomImageConfigChange(input: { + template: EnvironmentCustomImageTemplate; + previousConfig: SandboxEnvironmentConfig; + nextConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; + templateIdentityPaths?: Iterable; +}): EnvironmentCustomImageConfigChangeKind { + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.previousConfig, + secretRefExcludePaths, + })) { + return "none"; + } + if (environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.nextConfig, + secretRefExcludePaths, + })) { + return "none"; + } + const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({ + templateKind: input.template.templateKind, + metadata: input.template.metadata, + }); + const breakingPaths = new Set([ + "provider", + binding.field, + ...binding.unsetFields, + ...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS, + ...(input.templateIdentityPaths ?? []), + ]); + const previous = input.previousConfig as Record; + const next = input.nextConfig as Record; + for (const path of breakingPaths) { + const before = readConfigValueAtPath(previous, path); + const after = readConfigValueAtPath(next, path); + if (stableStringify(before ?? null) !== stableStringify(after ?? null)) { + return "breaking"; + } + } + return "relinkable"; +} + export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { return { id: row.id, @@ -138,6 +246,7 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( environmentId: string; baseConfig: SandboxEnvironmentConfig; runtimeConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; now?: Date; }, ): Promise { @@ -155,15 +264,19 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( const active = environmentCustomImageTemplateFromRow(row); if (!active.templateRef) return input.runtimeConfig; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: active, + baseConfig: input.baseConfig, + secretRefExcludePaths: input.secretRefExcludePaths, + })) { + return input.runtimeConfig; + } // An active template is an explicit, environment+provider-scoped artifact: the // captured snapshot/image fully replaces the base image at create time, so it is - // applied whenever present. We deliberately do not gate on a base-config - // fingerprint — the config dialog re-saves the environment right after capture, - // and ordinary resource/lifecycle tweaks (cpu/memory/lease knobs that don't - // affect image identity and are dropped for snapshot creation) would otherwise - // silently discard the captured setup state and provider metadata. Re-running - // setup supersedes the template when the user wants a fresh capture. + // applied whenever the image/template-defining parts of the base config still + // match. Runtime-only knobs such as lease reuse, timeouts, and resource hints + // are excluded from the fingerprint so those edits do not discard the capture. const now = input.now ?? new Date(); await db .update(environmentCustomImageTemplates) diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index 1cef716cf5f..f8a8449f2f9 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -38,9 +38,12 @@ import { } from "./plugin-environment-driver.js"; import { environmentService } from "./environments.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + classifyEnvironmentCustomImageConfigChange, fingerprintEnvironmentSandboxProviderConfig, ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY, defaultEnvironmentCustomImageRuntimeConfigBinding, + environmentCustomImageTemplateMatchesBaseConfig, normalizeEnvironmentCustomImageRuntimeConfigBinding, environmentCustomImageTemplateFromRow, readEnvironmentCustomImageTemplateKind as readTemplateKind, @@ -57,10 +60,22 @@ type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * Whether the active template's captured fingerprint still matches the + * environment's saved config. `false` means runs silently fall back to the + * base image until the template is re-captured. `null` when unknown (no + * active template, or the config could not be evaluated). + */ + activeTemplateMatchesConfig: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "none" } + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + export interface EnvironmentCustomImageSetupSessionResult { session: EnvironmentCustomImageSetupSession; connectionPayload: PluginEnvironmentInteractiveSetupConnectionPayload | null; @@ -595,17 +610,44 @@ export function environmentCustomImageService( } } + async function templateMatchesEnvironmentConfig( + environment: Environment, + template: EnvironmentCustomImageTemplate, + ): Promise { + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "sandbox") return false; + if (parsed.config.provider !== template.provider) return false; + return environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: parsed.config, + secretRefExcludePaths: parsed.config.provider === "fake" + ? [] + : await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + }); + } catch { + return null; + } + } + return { getOverview: async (input: { environmentId: string; }): Promise => { - await requireEnvironment(input.environmentId); + const environment = await requireEnvironment(input.environmentId); const [activeTemplate, activeSession, latestSession] = await Promise.all([ resolveActiveTemplate(db, input), getActiveSetupSession(input), getLatestSetupSession(input), ]); - return { activeTemplate, activeSession, latestSession }; + return { + activeTemplate, + activeTemplateMatchesConfig: activeTemplate + ? await templateMatchesEnvironmentConfig(environment, activeTemplate) + : null, + activeSession, + latestSession, + }; }, getActiveTemplate: async (input: { @@ -763,7 +805,10 @@ export function environmentCustomImageService( const parsed = parseEnvironmentDriverConfig(environment); const baseFingerprint = parsed.driver === "sandbox" ? fingerprintEnvironmentSandboxProviderConfig(parsed.config, { - excludePaths: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + ], }) : null; const provider = await resolveSetupProvider({ @@ -876,6 +921,84 @@ export function environmentCustomImageService( return await cancelSession(session, input.reason ?? "cancelled", "cancelled"); }, + /** + * Keeps the active captured template consistent with a just-saved config + * change. Changes that cannot affect the captured contents (for example a + * region hint) re-stamp the template's source fingerprint so it keeps + * applying; boot-source or provider-identity changes report `detached` so + * callers can tell the user a fresh capture is required. Never throws for + * unparseable configs; the save itself must not fail on reconciliation. + */ + reconcileActiveTemplateForConfigChange: async (input: { + environmentId: string; + previous: Pick; + next: Pick; + now?: Date; + }): Promise => { + let previousParsed; + let nextParsed; + try { + previousParsed = parseEnvironmentDriverConfig(input.previous); + nextParsed = parseEnvironmentDriverConfig(input.next); + } catch { + return { action: "none" }; + } + if (previousParsed.driver !== "sandbox") return { action: "none" }; + const template = await resolveActiveTemplate(db, { + environmentId: input.environmentId, + provider: previousParsed.config.provider, + }); + if (!template?.templateRef) return { action: "none" }; + const secretRefExcludePaths = previousParsed.config.provider === "fake" + ? [] + : [...await resolveSandboxProviderSecretRefPaths(db, previousParsed.config.provider)]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: previousParsed.config, + secretRefExcludePaths, + })) { + // Already detached before this save; leave it alone. + return { action: "none" }; + } + if (nextParsed.driver !== "sandbox" || nextParsed.config.provider !== template.provider) { + return { action: "detached", template }; + } + const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: template.provider, + }); + if (!resolvedDriver) { + // Without driver metadata the change cannot be classified safely. + return { action: "detached", template }; + } + const changeKind = classifyEnvironmentCustomImageConfigChange({ + template, + previousConfig: previousParsed.config, + nextConfig: nextParsed.config, + secretRefExcludePaths, + templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [], + }); + if (changeKind === "none") return { action: "none" }; + if (changeKind === "breaking") return { action: "detached", template }; + const now = input.now ?? new Date(); + const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(nextParsed.config, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + const row = await db + .update(environmentCustomImageTemplates) + .set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now }) + .where(eq(environmentCustomImageTemplates.id, template.id)) + .returning() + .then((rows) => rows[0] ?? null); + return { + action: "relinked", + template: row ? environmentCustomImageTemplateFromRow(row) : template, + }; + }, + rollbackTemplate: async (input: { environmentId: string; now?: Date; diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index eaac5b22e8f..ee1bce5d4c4 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -10,6 +10,7 @@ import os from "node:os"; import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js"; import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { environmentRuntimeService } from "./environment-runtime.js"; export async function probeEnvironment( db: Db, @@ -19,11 +20,14 @@ export async function probeEnvironment( pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig; applyCustomImageTemplate?: boolean; + acquireSandboxRuntimeLease?: boolean; } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; const parsed = options.resolvedConfig ?? ( - resolvedCompanyId || options.applyCustomImageTemplate === true + options.acquireSandboxRuntimeLease === true + ? parseEnvironmentDriverConfig(environment) + : resolvedCompanyId || options.applyCustomImageTemplate === true ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, { applyCustomImageTemplate: options.applyCustomImageTemplate === true, }) @@ -43,6 +47,98 @@ export async function probeEnvironment( } if (parsed.driver === "sandbox") { + if (options.acquireSandboxRuntimeLease) { + if (!resolvedCompanyId) { + return { + ok: false, + driver: "sandbox", + summary: "Sandbox environment probe requires a companyId context.", + details: { + provider: parsed.config.provider, + }, + }; + } + + const runtime = environmentRuntimeService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); + const probeEnvironmentConfig = { + ...environment, + config: { + ...(environment.config ?? {}), + // Test probes should prove a fresh provider boot, not resume a retained + // agent lease and report success without provider-side activity. + reuseLease: false, + // Keep the probe sandbox inspectable in the provider dashboard + // (archived, provider-side expiry) instead of deleting it the moment + // the probe finishes. + archiveOnRelease: true, + }, + }; + let leaseRecord: Awaited> | null = null; + let releaseStatus: "released" | "failed" = "released"; + try { + leaseRecord = await runtime.acquireRunLease({ + companyId: resolvedCompanyId, + environment: probeEnvironmentConfig, + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: options.applyCustomImageTemplate === true, + }); + const metadata = leaseRecord.lease.metadata ?? {}; + const provider = leaseRecord.lease.provider ?? parsed.config.provider; + const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0 + ? metadata.sandboxName.trim() + : null; + return { + ok: true, + driver: "sandbox", + summary: sandboxName + ? `Connected to ${provider} sandbox ${sandboxName}.` + : `Connected to ${provider} sandbox environment.`, + details: { + provider, + providerLeaseId: leaseRecord.lease.providerLeaseId, + leaseId: leaseRecord.lease.id, + leasePolicy: leaseRecord.lease.leasePolicy, + metadata, + }, + }; + } catch (error) { + releaseStatus = "failed"; + return { + ok: false, + driver: "sandbox", + summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`, + details: { + provider: parsed.config.provider, + error: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + if (leaseRecord) { + const driver = runtime.getDriver(environment.driver); + try { + await driver?.releaseRunLease({ + environment: probeEnvironmentConfig, + lease: leaseRecord.lease, + status: releaseStatus, + }); + } catch (releaseError) { + // Cleanup failures must not mask the connection result shown to + // the operator, but a leaked sandbox should still be traceable. + // eslint-disable-next-line no-console + console.warn( + `[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, + ); + } + } + } + } + if (!isBuiltinSandboxProvider(parsed.config.provider)) { if (!options.pluginWorkerManager) { return { diff --git a/server/src/services/heartbeat-stop-metadata.ts b/server/src/services/heartbeat-stop-metadata.ts index 268a3722fc7..ef89175c29f 100644 --- a/server/src/services/heartbeat-stop-metadata.ts +++ b/server/src/services/heartbeat-stop-metadata.ts @@ -9,6 +9,7 @@ export type HeartbeatRunStopReason = | "paused" | "max_turns_exhausted" | "process_lost" + | "unmanaged_background_task_stopped" | "adapter_failed"; export interface HeartbeatRunTimeoutPolicy { @@ -88,6 +89,7 @@ export function inferHeartbeatRunStopReason(input: { const maxTurnStopReason = normalizeMaxTurnStopReason(input.errorCode); if (maxTurnStopReason) return maxTurnStopReason; if (input.outcome === "timed_out") return "timeout"; + if (input.outcome === "failed" && input.errorCode === "unmanaged_background_task_stopped") return "unmanaged_background_task_stopped"; if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost"; if (input.outcome === "cancelled") { const message = (input.errorMessage ?? "").toLowerCase(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c1b45e3b77d..7d6992f8530 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -161,7 +161,10 @@ import { WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, } from "./execution-workspace-policy.js"; -import { instanceSettingsService } from "./instance-settings.js"; +import { + instanceSettingsService, + resolveWorktreeRunExecutionActivation, +} from "./instance-settings.js"; import { evaluateExecutionAllowlist, isExecutionForcedToKubernetes, @@ -215,6 +218,8 @@ import { } from "@paperclipai/adapter-utils"; import { readPaperclipSkillSyncPreference, + UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + UNMANAGED_BACKGROUND_TASK_STOP_REASON, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; @@ -3946,6 +3951,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ "interactionStatus", "continuationPolicy", "checkboxSelection", + "itemVerdicts", + "newlyResolvedItemIds", ] as const; function isInteractionResolutionWakePayload(payload: Record | null | undefined) { @@ -5016,30 +5023,45 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) // Only worktree runtimes ever read the setting; a short TTL keeps the hot-path // suppression checks off the DB, and a read failure falls back to prior/default // (fail closed to suppression). - let cachedWorktreeRunExecutionOverride: { value: boolean; at: number } = { value: false, at: 0 }; + let cachedWorktreeRunExecutionOverride: { allowed: boolean; cutoff: Date | null; at: number } = { + allowed: false, + cutoff: null, + at: 0, + }; const WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS = 3_000; - const resolveWorktreeRunExecutionOverride = async (): Promise => { - if (!inWorktreeRuntime) return false; + const resolveWorktreeRunExecutionOverride = async () => { + if (!inWorktreeRuntime) return { allowed: false, cutoff: null }; const now = Date.now(); if (now - cachedWorktreeRunExecutionOverride.at < WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS) { - return cachedWorktreeRunExecutionOverride.value; + return cachedWorktreeRunExecutionOverride; } try { - const experimental = await instanceSettings.getExperimental(); + const activation = resolveWorktreeRunExecutionActivation( + await instanceSettings.getExperimental(), + runtimeEnv.PAPERCLIP_INSTANCE_ID?.trim() || null, + ); + const cutoff = activation.armed ? new Date(activation.cutoff) : null; cachedWorktreeRunExecutionOverride = { - value: experimental.enableWorktreeRunExecution === true, + allowed: Boolean(activation.armed && cutoff && !Number.isNaN(cutoff.getTime())), + cutoff: cutoff && !Number.isNaN(cutoff.getTime()) ? cutoff : null, at: now, }; } catch { // Keep the prior (default-false) value so a settings read failure fails // closed to the safe suppressed state. } - return cachedWorktreeRunExecutionOverride.value; + return cachedWorktreeRunExecutionOverride; }; - const getSchedulingSuppression = async () => - resolveHeartbeatSchedulingSuppression(runtimeEnv, { - allowWorktreeRunExecution: await resolveWorktreeRunExecutionOverride(), + const getSchedulingSuppression = async () => { + const override = await resolveWorktreeRunExecutionOverride(); + return resolveHeartbeatSchedulingSuppression(runtimeEnv, { + allowWorktreeRunExecution: override.allowed, }); + }; + const getWorktreeExecutionCutoff = async () => { + const override = await resolveWorktreeRunExecutionOverride(); + return override.allowed ? override.cutoff : null; + }; const runLogStore = getRunLogStore(); const secretsSvc = secretService(db); @@ -7392,9 +7414,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return `[${label}](/${prefix}/issues/${label})`; } + function hasUnmanagedBackgroundTaskEvidence(resultJson: Record | null | undefined) { + const evidence = parseObject(resultJson?.unmanagedBackgroundTask); + return evidence.stopped === true && + (evidence.stopReason === UNMANAGED_BACKGROUND_TASK_STOP_REASON || + evidence.reason === UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON); + } + + function withUnmanagedBackgroundTaskStopReason(resultJson: Record | null | undefined) { + return { + ...(resultJson ?? {}), + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + }; + } + async function buildDetectedSuccessfulRunProgressSummary(run: typeof heartbeatRuns.$inferSelect) { const resultJson = parseObject(run.resultJson); const candidates = [ + hasUnmanagedBackgroundTaskEvidence(resultJson) ? UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON : null, readNonEmptyString(run.nextAction) ? `Next action noted: ${readNonEmptyString(run.nextAction)}` : null, readNonEmptyString(run.livenessReason), readNonEmptyString(resultJson.summary), @@ -7458,6 +7495,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, executionState: issues.executionState, + monitorNextCheckAt: issues.monitorNextCheckAt, projectId: issues.projectId, }) .from(issues) @@ -7634,6 +7672,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) hasActiveExecutionPath: Boolean(activeExecutionPath), hasQueuedWake: Boolean(queuedWake), hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval), + hasPersistedMonitor: Boolean(issue?.monitorNextCheckAt), hasExplicitBlockerPath: Boolean(explicitBlocker), hasOpenRecoveryIssue: Boolean(openRecoveryIssue), hasPauseHold: Boolean(pauseHold), @@ -7644,6 +7683,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (decision.kind !== "enqueue" || !issue) return; + if (hasUnmanagedBackgroundTaskEvidence(parseObject(run.resultJson))) { + await db + .update(heartbeatRuns) + .set({ + livenessReason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + resultJson: withUnmanagedBackgroundTaskStopReason(parseObject(run.resultJson)), + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)); + } + const handoffRun = await enqueueWakeup(run.agentId, { source: "automation", triggerDetail: "system", @@ -9432,6 +9482,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function promoteDueScheduledRetries(now = new Date()) { + const cutoff = await getWorktreeExecutionCutoff(); const dueRuns = await db .select() .from(heartbeatRuns) @@ -9439,6 +9490,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) and( eq(heartbeatRuns.status, "scheduled_retry"), lte(heartbeatRuns.scheduledRetryAt, now), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, ), ) .orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) @@ -10622,20 +10674,39 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const shouldRetry = tracksLocalChild && (!!run.processPid || !!run.processGroupId) && (run.processLossRetryCount ?? 0) < 1; const baseMessage = buildProcessLossMessage(run, descendantOnlyCleanup ? { descendantOnly: true } : undefined); + const unmanagedBackgroundTaskEvidence = descendantOnlyCleanup + ? { + kind: "orphaned_process_group_cleanup", + stopped: true, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + processPid: run.processPid ?? null, + processGroupId: run.processGroupId ?? null, + } + : null; let finalizedRun = await setRunStatus(run.id, "failed", { error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, errorCode: "process_lost", finishedAt: now, - resultJson: mergeRunStopMetadataForAgent( - { adapterType, adapterConfig }, - "failed", - { - resultJson: parseObject(run.resultJson), - errorCode: "process_lost", - errorMessage: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, - }, - ), + resultJson: (() => { + const result = mergeRunStopMetadataForAgent( + { adapterType, adapterConfig }, + "failed", + { + resultJson: parseObject(run.resultJson), + errorCode: "process_lost", + errorMessage: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, + }, + ); + return unmanagedBackgroundTaskEvidence + ? { + ...result, + stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON, + unmanagedBackgroundTask: unmanagedBackgroundTaskEvidence, + } + : result; + })(), }); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: now, @@ -10696,6 +10767,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) async function resumeQueuedRuns() { if ((await getSchedulingSuppression()).suppressed) return; + const cutoff = await getWorktreeExecutionCutoff(); const queuedRuns = await db .select({ agentId: heartbeatRuns.agentId }) @@ -10704,6 +10776,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and( eq(heartbeatRuns.status, "queued"), eq(companies.status, "active"), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, )); const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))]; @@ -10713,7 +10786,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function reconcileStrandedAssignedIssues() { - return recovery.reconcileStrandedAssignedIssues(); + return recovery.reconcileStrandedAssignedIssues({ issueCreatedAtGte: await getWorktreeExecutionCutoff() }); } async function sweepStaleIssueLocks() { @@ -10734,15 +10807,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string }) { - return recovery.scanSilentActiveRuns(opts); + return recovery.scanSilentActiveRuns({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() }); } async function reconcileProductivityReviews(opts?: { now?: Date; companyId?: string }) { - return productivityReviews.reconcileProductivityReviews(opts); + return productivityReviews.reconcileProductivityReviews({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() }); } async function reconcileTaskWatchdogs(opts?: { companyId?: string | null; runId?: string | null }) { - return taskWatchdogs.reconcileTaskWatchdogs(opts); + return taskWatchdogs.reconcileTaskWatchdogs({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() }); } async function buildRunOutputSilence( @@ -10764,7 +10837,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) force?: boolean; lookbackHours?: number; }) { - return recovery.reconcileIssueGraphLiveness(opts); + return recovery.reconcileIssueGraphLiveness({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() }); } async function updateRuntimeState( @@ -10824,6 +10897,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) async function startNextQueuedRunForAgent(agentId: string) { if ((await getSchedulingSuppression()).suppressed) return []; + const cutoff = await getWorktreeExecutionCutoff(); return withAgentStartLock(agentId, async () => { const agent = await getAgent(agentId); @@ -10843,7 +10917,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const queuedRuns = await db .select() .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "queued"))) + .where(and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "queued"), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, + )) .orderBy(asc(heartbeatRuns.createdAt)); if (queuedRuns.length === 0) return []; @@ -12306,19 +12384,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); + outputSeq += 1; + const chunkSeq = outputSeq; let appendedBytes = 0; if (handle) { appendedBytes = await runLogStore.append(handle, { stream, chunk: sanitizedChunk, ts, + seq: chunkSeq, }); persistedLogBytes += appendedBytes; } - outputSeq += 1; outputProgressState.pending = { at: new Date(ts), - seq: outputSeq, + seq: chunkSeq, stream, bytes: persistedLogBytes, }; @@ -12359,6 +12439,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: run.agentId, issueId, ts, + seq: chunkSeq, stream, chunk: payloadChunk, truncated: payloadChunk.length !== sanitizedChunk.length, @@ -13845,9 +13926,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .limit(1) .then((rows) => rows[0] ?? null); - const issueHasScheduledMonitor = - issue.monitorNextCheckAt instanceof Date && - issue.monitorNextCheckAt.getTime() > Date.now(); + const issueHasPersistedMonitor = Boolean(issue.monitorNextCheckAt); + const findExplicitBlockerPath = () => + tx + .select({ id: issueRelations.issueId }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, issue.companyId), + eq(issueRelations.relatedIssueId, issue.id), + eq(issueRelations.type, "blocks"), + eq(issues.companyId, issue.companyId), + notInArray(issues.status, ["done", "cancelled"]), + isNull(issues.hiddenAt), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); const executionState = parseIssueExecutionState(issue.executionState); const currentParticipant = executionState?.status === "pending" ? executionState.currentParticipant @@ -13867,7 +13963,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if ( options.suppressImmediateRecovery || existingReviewParticipantExecutionPath || - issueHasScheduledMonitor || + issueHasPersistedMonitor || await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc) ) { return { kind: "released" as const }; @@ -13986,7 +14082,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } const existingExecutionPath = await findExistingExecutionPath(); - if (existingExecutionPath) { + if (existingExecutionPath || issueHasPersistedMonitor || await findExplicitBlockerPath()) { return { kind: "released" as const }; } @@ -14237,6 +14333,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return null; } + const worktreeExecutionCutoff = opts.requestedByActorType === "user" + ? null + : await getWorktreeExecutionCutoff(); + const company = await db .select({ status: companies.status }) .from(companies) @@ -14302,11 +14402,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ? or(eq(issues.id, issueId), eq(issues.identifier, issueId.toUpperCase())) : eq(issues.identifier, issueId.toUpperCase()); const resolvedIssue = await db - .select({ id: issues.id, projectId: issues.projectId }) + .select({ id: issues.id, projectId: issues.projectId, createdAt: issues.createdAt }) .from(issues) .where(and(eq(issues.companyId, agent.companyId), idMatch)) .then((rows) => rows[0] ?? null); if (resolvedIssue) { + if (worktreeExecutionCutoff && resolvedIssue.createdAt < worktreeExecutionCutoff) { + await writeSkippedHeartbeatRequest("heartbeat.worktree_execution_cutoff", { + reason: "worktree_execution_cutoff", + cutoff: worktreeExecutionCutoff.toISOString(), + issueId: resolvedIssue.id, + }); + return null; + } projectId = resolvedIssue.projectId ?? null; // Canonicalize context to the UUID so downstream lookups always use UUID if (resolvedIssue.id !== issueId) { @@ -14481,6 +14589,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, executionRunId: issues.executionRunId, executionAgentNameKey: issues.executionAgentNameKey, + createdAt: issues.createdAt, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) @@ -14503,6 +14612,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { kind: "skipped" as const }; } + if (worktreeExecutionCutoff && issue.createdAt < worktreeExecutionCutoff) { + await tx.insert(agentWakeupRequests).values({ + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "heartbeat.worktree_execution_cutoff", + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: "worktree_execution_cutoff", + cutoff: worktreeExecutionCutoff.toISOString(), + issueId: issue.id, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + const cancelStaleScheduledRetry = async (scheduledRun: typeof heartbeatRuns.$inferSelect) => { const issueCancelled = issue.status === "cancelled"; if ( @@ -15885,6 +16018,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) skipped: 0, }; } + const cutoff = await getWorktreeExecutionCutoff(); const allAgents = await db .select({ ...getTableColumns(agents) }) @@ -15902,6 +16036,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const policy = parseHeartbeatPolicy(agent); if (!policy.enabled || policy.intervalSec <= 0) continue; + if (cutoff) { + const eligibleIssue = await db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, agent.companyId), + eq(issues.assigneeAgentId, agent.id), + inArray(issues.status, ["todo", "in_progress"]), + gte(issues.createdAt, cutoff), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!eligibleIssue) continue; + } + checked += 1; const baseline = new Date(agent.lastHeartbeatAt ?? agent.createdAt).getTime(); const elapsedMs = now.getTime() - baseline; diff --git a/server/src/services/inbox-dismissals.ts b/server/src/services/inbox-dismissals.ts index 68032c69dc1..7b940f95e76 100644 --- a/server/src/services/inbox-dismissals.ts +++ b/server/src/services/inbox-dismissals.ts @@ -1,8 +1,42 @@ import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { inboxDismissals } from "@paperclipai/db"; +import type { InboxDismissalKind } from "@paperclipai/shared"; export function inboxDismissalService(db: Db) { + async function upsert( + companyId: string, + userId: string, + itemKey: string, + input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null }, + ) { + const now = new Date(); + const dismissedAt = input.dismissedAt ?? now; + const snoozedUntil = input.kind === "snooze" ? input.snoozedUntil ?? null : null; + const [row] = await db + .insert(inboxDismissals) + .values({ + companyId, + userId, + itemKey, + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey], + set: { + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }, + }) + .returning(); + return row; + } + return { list: async (companyId: string, userId: string) => db @@ -16,26 +50,26 @@ export function inboxDismissalService(db: Db) { userId: string, itemKey: string, dismissedAt: Date = new Date(), - ) => { - const now = new Date(); + ) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt }), + + snooze: async ( + companyId: string, + userId: string, + itemKey: string, + snoozedUntil: Date, + dismissedAt: Date = new Date(), + ) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil }), + + restore: async (companyId: string, userId: string, itemKey: string) => { const [row] = await db - .insert(inboxDismissals) - .values({ - companyId, - userId, - itemKey, - dismissedAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey], - set: { - dismissedAt, - updatedAt: now, - }, - }) + .delete(inboxDismissals) + .where(and( + eq(inboxDismissals.companyId, companyId), + eq(inboxDismissals.userId, userId), + eq(inboxDismissals.itemKey, itemKey), + )) .returning(); - return row; + return row ?? null; }, }; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 43524a5642d..a1e15e48e40 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -58,6 +58,7 @@ export { export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js"; +export { attentionService } from "./attention.js"; export type { WorkTimelineActor, WorkTimelineEdge, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 01e33d11635..4f0cdb3419f 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -18,6 +18,164 @@ import { eq } from "drizzle-orm"; const DEFAULT_SINGLETON_KEY = "default"; const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip(); const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip(); +const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]); + +interface InstanceSettingsServiceOptions { + runtimeEnv?: Record; + now?: () => Date; +} + +type WorktreeRunExecutionSuppressedReason = + | "not_worktree_runtime" + | "flag_disabled" + | "missing_cutoff" + | "missing_instance_id" + | "instance_id_mismatch" + | "settings_read_error"; + +export type WorktreeRunExecutionActivationState = + | { + armed: true; + cutoff: string; + activationInstanceId: string; + reason: null; + } + | { + armed: false; + cutoff: null; + activationInstanceId: string | null; + reason: WorktreeRunExecutionSuppressedReason; + }; + +export function isTruthyRuntimeEnvValue(value: string | undefined) { + return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase()); +} + +function getRuntimeInstanceId(env: Record) { + const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim(); + return instanceId ? instanceId : null; +} + +function stripServerManagedExperimentalPatchFields( + patch: PatchInstanceExperimentalSettings | Record, +): PatchInstanceExperimentalSettings { + const { + worktreeRunExecutionActivatedAt: _ignoredActivatedAt, + worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId, + ...patchable + } = patch as Record; + return patchable as PatchInstanceExperimentalSettings; +} + +export function applyExperimentalSettingsPatch( + current: unknown, + patch: PatchInstanceExperimentalSettings | Record, + options: InstanceSettingsServiceOptions = {}, +): InstanceExperimentalSettings { + const previousExperimental = normalizeExperimentalSettings(current); + const patchable = stripServerManagedExperimentalPatchFields(patch); + const nextExperimental = normalizeExperimentalSettings({ + ...previousExperimental, + ...patchable, + }); + const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call( + patchable, + "enableWorktreeRunExecution", + ); + + if (!hasWorktreeRunExecutionPatch) { + return nextExperimental; + } + + if (nextExperimental.enableWorktreeRunExecution !== true) { + return { + ...nextExperimental, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, + }; + } + + if (previousExperimental.enableWorktreeRunExecution === true) { + return nextExperimental; + } + + const runtimeEnv = options.runtimeEnv ?? process.env; + if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) { + return nextExperimental; + } + + return { + ...nextExperimental, + worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(), + worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv), + }; +} + +function suppressWorktreeRunExecution( + reason: WorktreeRunExecutionSuppressedReason, + activationInstanceId: string | null = null, +): WorktreeRunExecutionActivationState { + return { + armed: false, + cutoff: null, + activationInstanceId, + reason, + }; +} + +export function resolveWorktreeRunExecutionActivation( + experimental: InstanceExperimentalSettings, + currentInstanceId: string | null | undefined, +): WorktreeRunExecutionActivationState { + if (experimental.enableWorktreeRunExecution !== true) { + return suppressWorktreeRunExecution( + "flag_disabled", + experimental.worktreeRunExecutionActivationInstanceId, + ); + } + if (!experimental.worktreeRunExecutionActivatedAt) { + return suppressWorktreeRunExecution( + "missing_cutoff", + experimental.worktreeRunExecutionActivationInstanceId, + ); + } + if (!currentInstanceId) { + return suppressWorktreeRunExecution( + "missing_instance_id", + experimental.worktreeRunExecutionActivationInstanceId, + ); + } + if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) { + return suppressWorktreeRunExecution( + "instance_id_mismatch", + experimental.worktreeRunExecutionActivationInstanceId, + ); + } + return { + armed: true, + cutoff: experimental.worktreeRunExecutionActivatedAt, + activationInstanceId: currentInstanceId, + reason: null, + }; +} + +export async function resolveWorktreeRunExecutionActivationState(options: { + getExperimental: () => Promise; + runtimeEnv?: Record; +}): Promise { + const runtimeEnv = options.runtimeEnv ?? process.env; + if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) { + return suppressWorktreeRunExecution("not_worktree_runtime"); + } + try { + return resolveWorktreeRunExecutionActivation( + await options.getExperimental(), + getRuntimeInstanceId(runtimeEnv), + ); + } catch { + return suppressWorktreeRunExecution("settings_read_error"); + } +} function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {}); @@ -56,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, + enableDecisions: parsed.data.enableDecisions ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, @@ -63,6 +222,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true, enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true, enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false, + worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null, + worktreeRunExecutionActivationInstanceId: + parsed.data.worktreeRunExecutionActivationInstanceId ?? null, issueGraphLivenessAutoRecoveryLookbackHours: parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -81,6 +243,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCloudSync: false, enableExternalObjects: false, enableBuiltInAgents: false, + enableDecisions: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -88,6 +251,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, issueGraphLivenessAutoRecoveryLookbackHours: DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; @@ -104,7 +269,7 @@ function toInstanceSettings(row: typeof instanceSettings.$inferSelect): Instance } as InstanceSettings; } -export function instanceSettingsService(db: Db) { +export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) { async function getOrCreateRow() { const existing = await db .select() @@ -192,10 +357,7 @@ export function instanceSettingsService(db: Db) { updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise => { const current = await getOrCreateRow(); - const nextExperimental = normalizeExperimentalSettings({ - ...normalizeExperimentalSettings(current.experimental), - ...patch, - }); + const nextExperimental = applyExperimentalSettingsPatch(current.experimental, patch, options); const now = new Date(); const [updated] = await db .update(instanceSettings) diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 62550db0dd9..5a2a3ae19da 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -21,10 +21,14 @@ import type { RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, RequestConfirmationTarget, + RequestItemVerdictsInteraction, + RequestItemVerdictsResult, + RequestItemVerdictsResultItem, RejectIssueThreadInteraction, RespondIssueThreadInteraction, SuggestTasksInteraction, SuggestTasksResultCreatedTask, + SubmitIssueThreadInteractionVerdicts, } from "@paperclipai/shared"; import { acceptIssueThreadInteractionSchema, @@ -37,8 +41,11 @@ import { requestCheckboxConfirmationResultSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, + requestItemVerdictsPayloadSchema, + requestItemVerdictsResultSchema, suggestTasksPayloadSchema, suggestTasksResultSchema, + submitIssueThreadInteractionVerdictsSchema, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { getTelemetryClient } from "../telemetry.js"; @@ -85,19 +92,32 @@ type RequestConfirmationLikeInteraction = | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; -const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ +const TARGET_BOUND_INTERACTION_KINDS = [ ...REQUEST_CONFIRMATION_INTERACTION_KINDS, + "request_item_verdicts", +] as const; +type TargetBoundInteractionKind = (typeof TARGET_BOUND_INTERACTION_KINDS)[number]; +type TargetBoundInteraction = + | RequestConfirmationLikeInteraction + | RequestItemVerdictsInteraction; + +const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ + ...TARGET_BOUND_INTERACTION_KINDS, "ask_user_questions", ] as const; type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number]; type UserCommentSupersedableInteraction = - | RequestConfirmationLikeInteraction + | TargetBoundInteraction | AskUserQuestionsInteraction; function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind { return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind); } +function isTargetBoundInteractionKind(kind: string): kind is TargetBoundInteractionKind { + return (TARGET_BOUND_INTERACTION_KINDS as readonly string[]).includes(kind); +} + function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind { return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind); } @@ -167,6 +187,13 @@ function hydrateInteraction( payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload), result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null, } satisfies RequestCheckboxConfirmationInteraction; + case "request_item_verdicts": + return { + ...base, + kind: "request_item_verdicts", + payload: requestItemVerdictsPayloadSchema.parse(row.payload), + result: row.result ? requestItemVerdictsResultSchema.parse(row.result) : null, + } satisfies RequestItemVerdictsInteraction; default: throw unprocessable(`Unknown interaction kind: ${row.kind}`); } @@ -227,6 +254,14 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, }, }; + case "request_item_verdicts": + return { + ...input, + payload: { + ...input.payload, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + }, + }; default: return input; } @@ -243,6 +278,17 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI } as const; } + if (row.kind === "request_item_verdicts") { + const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction; + return { + version: 1, + outcome: "superseded_by_comment", + complete: false, + items: interaction.result?.items ?? [], + commentId, + } satisfies RequestItemVerdictsResult; + } + return { version: 1, outcome: "superseded_by_comment", @@ -250,6 +296,28 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI } as const; } +function buildStaleTargetResult( + row: IssueThreadInteractionRow, + staleTarget: RequestConfirmationTarget | null, +) { + if (row.kind === "request_item_verdicts") { + const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction; + return { + version: 1, + outcome: "stale_target", + complete: false, + items: interaction.result?.items ?? [], + staleTarget, + } satisfies RequestItemVerdictsResult; + } + + return { + version: 1, + outcome: "stale_target", + staleTarget, + } as const; +} + function resolveActorKind(interaction: Pick) { if (interaction.resolvedByAgentId) return "agent"; if (interaction.resolvedByUserId) return "user"; @@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick option.id); } +function resolveRequestItemVerdictSubmissions(args: { + interaction: RequestItemVerdictsInteraction; + input: SubmitIssueThreadInteractionVerdicts; + actor: InteractionActor; + now: Date; +}) { + if (!args.actor.userId) { + throw unprocessable("request_item_verdicts submissions require a user actor"); + } + + const existingItems = args.interaction.result?.items ?? []; + const existingById = new Map(existingItems.map((item) => [item.id, item] as const)); + const payloadItemIds = new Set(args.interaction.payload.items.map((item) => item.id)); + const enabledVerdicts = new Set(args.interaction.payload.verdicts ?? ["approve", "reject"]); + const requireReasonOn = new Set(args.interaction.payload.requireReasonOn ?? ["reject"]); + const newlyResolvedById = new Map(); + const newlyResolvedItemIds: string[] = []; + + for (const submitted of args.input.verdicts) { + if (!payloadItemIds.has(submitted.id)) { + throw unprocessable(`Unknown item verdict id: ${submitted.id}`); + } + if (existingById.has(submitted.id)) { + continue; + } + if (!enabledVerdicts.has(submitted.verdict)) { + throw unprocessable(`Verdict ${submitted.verdict} is not enabled for this item verdict request`); + } + + const reason = submitted.reason?.trim() ?? ""; + if (requireReasonOn.has(submitted.verdict) && reason.length === 0) { + throw unprocessable(`A reason is required when verdict is ${submitted.verdict}`); + } + + if (newlyResolvedById.has(submitted.id)) { + continue; + } + newlyResolvedById.set(submitted.id, { + id: submitted.id, + verdict: submitted.verdict, + ...(reason ? { reason } : {}), + resolvedByUserId: args.actor.userId, + resolvedAt: args.now, + }); + newlyResolvedItemIds.push(submitted.id); + } + + const nextById = new Map(existingItems.map((item) => [item.id, item])); + for (const [id, item] of newlyResolvedById) { + nextById.set(id, item); + } + const items = args.interaction.payload.items + .map((item) => nextById.get(item.id)) + .filter((item): item is RequestItemVerdictsResultItem => Boolean(item)); + + return { + items, + complete: items.length === args.interaction.payload.items.length, + newlyResolvedItemIds, + }; +} + function normalizeQuestionAnswers(args: { questions: AskUserQuestionsInteraction["payload"]["questions"]; answers: RespondIssueThreadInteraction["answers"]; @@ -653,8 +801,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { row: IssueThreadInteractionRow; actor: InteractionActor; }): Promise { - if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null; - const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction; + if (!isTargetBoundInteractionKind(args.row.kind) || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as TargetBoundInteraction; const target = interaction.payload.target ?? null; if (!target) return null; if (target.type !== "issue_document") return null; @@ -686,9 +834,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { } : interaction.payload, result: { - version: 1, - outcome: "stale_target", - staleTarget: target, + ...buildStaleTargetResult(args.row, target), }, resolvedByAgentId: args.actor.agentId ?? null, resolvedByUserId: args.actor.userId ?? null, @@ -1002,7 +1148,11 @@ export function issueThreadInteractionService(db: Db) { } } - if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") { + if ( + data.kind === "request_confirmation" + || data.kind === "request_checkbox_confirmation" + || data.kind === "request_item_verdicts" + ) { await assertRequestConfirmationTargetIsCurrent(db, { companyId: issue.companyId, issueId: issue.id, @@ -1274,6 +1424,106 @@ export function issueThreadInteractionService(db: Db) { } }, + submitItemVerdicts: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: SubmitIssueThreadInteractionVerdicts, + actor: InteractionActor, + ): Promise<{ interaction: IssueThreadInteraction; newlyResolvedItemIds: string[] }> => { + const data = submitIssueThreadInteractionVerdictsSchema.parse(input); + const submission = await db.transaction(async (tx) => { + const current = await tx + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .for("update") + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "request_item_verdicts") { + throw unprocessable("Only request_item_verdicts interactions can receive item verdicts"); + } + + const interaction = hydrateInteraction(current) as RequestItemVerdictsInteraction; + if (current.status !== "pending") { + if (current.status === "answered") { + const resolvedIds = new Set(interaction.result?.items.map((item) => item.id) ?? []); + const payloadIds = new Set(interaction.payload.items.map((item) => item.id)); + for (const submitted of data.verdicts) { + if (!payloadIds.has(submitted.id)) { + throw unprocessable(`Unknown item verdict id: ${submitted.id}`); + } + if (!resolvedIds.has(submitted.id)) { + throw conflict("Interaction has already been resolved"); + } + } + return { interaction, newlyResolvedItemIds: [], resolved: false }; + } + throw conflict("Interaction has already been resolved"); + } + + const expired = await expireStaleRequestConfirmationTarget(tx, { + row: current, + actor, + }); + if (expired) { + return { interaction: expired, newlyResolvedItemIds: [], resolved: false }; + } + + const now = new Date(); + const { items, complete, newlyResolvedItemIds } = resolveRequestItemVerdictSubmissions({ + interaction, + input: data, + actor, + now, + }); + if (newlyResolvedItemIds.length === 0) { + return { interaction, newlyResolvedItemIds: [], resolved: false }; + } + + const result = { + version: 1, + outcome: "resolved", + complete, + items, + } satisfies RequestItemVerdictsResult; + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + status: complete ? "answered" : "pending", + result, + resolvedByAgentId: complete ? actor.agentId ?? null : null, + resolvedByUserId: complete ? actor.userId ?? null : null, + resolvedAt: complete ? now : null, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(tx, issue.id); + return { + interaction: hydrateInteraction(updated), + newlyResolvedItemIds, + resolved: complete, + }; + }); + + if (submission.resolved) { + await emitInteractionResolvedTelemetry(db, submission.interaction); + } + return submission; + }, + rejectSuggestedTasks: async ( issue: { id: string; companyId: string }, interactionId: string, @@ -1447,6 +1697,8 @@ export function issueThreadInteractionService(db: Db) { const confirmationRowIds = commentRows .filter((row) => isRequestConfirmationLikeKind(row.kind)) .map((row) => row.id); + const itemVerdictRows = commentRows + .filter((row) => row.kind === "request_item_verdicts"); if (questionRowIds.length > 0) { const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions"); @@ -1489,6 +1741,25 @@ export function issueThreadInteractionService(db: Db) { .returning(); expired.push(...updatedRows.map(hydrateInteraction)); } + + for (const row of itemVerdictRows) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(row, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(hydrateInteraction(updated)); + } } if (expired.length > 0) { @@ -1509,12 +1780,12 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...TARGET_BOUND_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const staleRows = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + const interaction = hydrateInteraction(row) as TargetBoundInteraction; const target = interaction.payload.target; if (!target || target.type !== "issue_document") return false; const targetIssueId = target.issueId ?? issue.id; @@ -1533,7 +1804,7 @@ export function issueThreadInteractionService(db: Db) { const now = new Date(); const expired: IssueThreadInteraction[] = []; for (const row of staleRows) { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + const interaction = hydrateInteraction(row) as TargetBoundInteraction; const target = interaction.payload.target ?? null; const currentTarget = buildIssueDocumentTargetFromDocument({ issueId: issue.id, @@ -1549,11 +1820,7 @@ export function issueThreadInteractionService(db: Db) { target: currentTarget, } : interaction.payload, - result: { - version: 1, - outcome: "stale_target", - staleTarget: target, - }, + result: buildStaleTargetResult(row, target), resolvedByAgentId: actor.agentId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: now, diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts index 45962040baa..cae4e37668d 100644 --- a/server/src/services/productivity-review.ts +++ b/server/src/services/productivity-review.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { clampIssueRequestDepth } from "@paperclipai/shared"; import { @@ -763,6 +763,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque now?: Date; companyId?: string; thresholds?: Partial; + issueCreatedAtGte?: Date | null; }) { const now = opts?.now ?? new Date(); const thresholds = buildThresholds(opts?.thresholds); @@ -777,6 +778,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque inArray(issues.status, ["todo", "in_progress"]), sql`${issues.assigneeAgentId} is not null`, sql`${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`, + opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined, ), ) .orderBy(asc(issues.updatedAt), asc(issues.id)) diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 2ef1c1d051f..6ad4b557de8 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -699,6 +699,27 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .then((rows) => Boolean(rows[0])); } + async function hasPersistedDurableWaitPath(issue: typeof issues.$inferSelect) { + if (issue.monitorNextCheckAt) return true; + + return db + .select({ id: issueRelations.issueId }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, issue.companyId), + eq(issueRelations.relatedIssueId, issue.id), + eq(issueRelations.type, "blocks"), + eq(issues.companyId, issue.companyId), + notInArray(issues.status, ["done", "cancelled"]), + isNull(issues.hiddenAt), + ), + ) + .limit(1) + .then((rows) => Boolean(rows[0])); + } + async function hasQueuedIssueWake(companyId: string, issueId: string, agentId?: string | null) { return db .select({ id: agentWakeupRequests.id }) @@ -1988,10 +2009,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return { kind: "created" as const, evaluationIssueId: evaluation.id }; } - async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string }) { + async function scanSilentActiveRuns(opts?: { now?: Date; companyId?: string; issueCreatedAtGte?: Date | null }) { const now = opts?.now ?? new Date(); const suspicionBefore = new Date(now.getTime() - ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS); - const candidates = await db + let candidates = await db .select() .from(heartbeatRuns) .where( @@ -2004,6 +2025,27 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .orderBy(asc(heartbeatRuns.createdAt)) .limit(100); + if (opts?.issueCreatedAtGte) { + const issueIds = [...new Set(candidates.flatMap((run) => { + const context = parseObject(run.contextSnapshot); + const issueId = context.issueId ?? context.taskId; + return typeof issueId === "string" && issueId.length > 0 ? [issueId] : []; + }))]; + const eligibleIssueIds = new Set( + issueIds.length > 0 + ? (await db.select({ id: issues.id }).from(issues).where(and( + inArray(issues.id, issueIds), + gte(issues.createdAt, opts.issueCreatedAtGte), + ))).map((issue) => issue.id) + : [], + ); + candidates = candidates.filter((run) => { + const context = parseObject(run.contextSnapshot); + const issueId = context.issueId ?? context.taskId; + return typeof issueId === "string" && eligibleIssueIds.has(issueId); + }); + } + const result = { scanned: candidates.length, created: 0, @@ -2934,7 +2976,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return updated; } - async function reconcileStrandedAssignedIssues() { + async function reconcileStrandedAssignedIssues(opts?: { issueCreatedAtGte?: Date | null }) { const candidates = await db .select() .from(issues) @@ -2946,6 +2988,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sql`${issues.assigneeAgentId} is not null`, eq(issues.status, "in_review"), ), + opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined, ), ); @@ -3011,6 +3054,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } const latestRun = await getLatestIssueRun(issue.companyId, issue.id); + if (latestRun?.status === "succeeded" && await hasPersistedDurableWaitPath(issue)) { + result.skipped += 1; + continue; + } if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) { const updated = await escalateStrandedRecoveryIssueInPlace({ issue, @@ -4463,8 +4510,25 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) runId?: string | null; force?: boolean; lookbackHours?: number; + issueCreatedAtGte?: Date | null; }) { - const findings = await collectIssueGraphLivenessFindings(); + let findings = await collectIssueGraphLivenessFindings(); + if (opts?.issueCreatedAtGte) { + const findingIssueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))]; + const eligibleIssueIds = new Set( + findingIssueIds.length === 0 + ? [] + : (await db + .select({ id: issues.id }) + .from(issues) + .where(and( + inArray(issues.id, findingIssueIds), + gte(issues.createdAt, opts.issueCreatedAtGte), + ))) + .map((issue) => issue.id), + ); + findings = findings.filter((finding) => eligibleIssueIds.has(finding.recoveryIssueId)); + } const experimentalSettings = await instanceSettings.getExperimental(); const autoRecoveryEnabled = asBoolean( experimentalSettings.enableIssueGraphLivenessAutoRecovery, diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 184930b1f76..25a99cbb21c 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -12,6 +12,7 @@ import { isSuccessfulRunHandoffRequiredNoticeBody, noticeMetadataReferencesRecoveryAction, } from "./successful-run-handoff.js"; +import { UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON } from "@paperclipai/adapter-utils/server-utils"; const run = { id: "run-1", @@ -49,6 +50,7 @@ function decide(overrides: Partial hasActiveExecutionPath: false, hasQueuedWake: false, hasPendingInteractionOrApproval: false, + hasPersistedMonitor: false, hasExplicitBlockerPath: false, hasOpenRecoveryIssue: false, hasPauseHold: false, @@ -114,17 +116,36 @@ describe("successful run handoff decision", () => { kind: "skip", reason: "pending interaction or approval owns the next action", }); + expect(decide({ hasPersistedMonitor: true })).toEqual({ + kind: "skip", + reason: "persisted issue monitor owns the next action", + }); expect(decide({ hasActiveExecutionPath: true })).toEqual({ kind: "skip", reason: "issue already has an active execution path", }); }); + it("does not treat killed background-task evidence as a missing live path when a durable monitor owns the wait", () => { + expect(decide({ + detectedProgressSummary: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, + livenessState: "needs_followup", + hasPersistedMonitor: true, + })).toEqual({ + kind: "skip", + reason: "persisted issue monitor owns the next action", + }); + }); + it("does not queue when another wake or dependency path already owns the next action", () => { expect(decide({ hasQueuedWake: true })).toEqual({ kind: "skip", reason: "issue already has a queued or deferred wake", }); + expect(decide({ hasPersistedMonitor: true })).toEqual({ + kind: "skip", + reason: "persisted issue monitor owns the next action", + }); expect(decide({ hasExplicitBlockerPath: true })).toEqual({ kind: "skip", reason: "explicit blocker path owns the next action", diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 5ac73473f5e..b0f0b94dbdc 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -347,6 +347,7 @@ export function decideSuccessfulRunHandoff(input: { hasActiveExecutionPath: boolean; hasQueuedWake: boolean; hasPendingInteractionOrApproval: boolean; + hasPersistedMonitor: boolean; hasExplicitBlockerPath: boolean; hasOpenRecoveryIssue: boolean; hasPauseHold: boolean; @@ -388,6 +389,7 @@ export function decideSuccessfulRunHandoff(input: { if (input.hasPendingInteractionOrApproval) { return { kind: "skip", reason: "pending interaction or approval owns the next action" }; } + if (input.hasPersistedMonitor) return { kind: "skip", reason: "persisted issue monitor owns the next action" }; if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" }; if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" }; if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" }; diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 0d21e4b026d..bdc124c195e 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -65,6 +65,12 @@ import { secretService } from "./secrets.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import { parseCron, validateCron } from "./cron.js"; import { heartbeatService } from "./heartbeat.js"; +import { + instanceSettingsService, + isTruthyRuntimeEnvValue, + resolveWorktreeRunExecutionActivationState, + type WorktreeRunExecutionActivationState, +} from "./instance-settings.js"; import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js"; import { logActivity } from "./activity-log.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -592,10 +598,13 @@ export function routineService( deps: { heartbeat?: IssueAssignmentWakeupDeps; pluginWorkerManager?: PluginWorkerManager; + runtimeEnv?: Record; } = {}, ) { const issueSvc = issueService(db); const secretsSvc = secretService(db); + const instanceSettings = instanceSettingsService(db); + const runtimeEnv = deps.runtimeEnv ?? process.env; const heartbeat = deps.heartbeat ?? heartbeatService(db, { pluginWorkerManager: deps.pluginWorkerManager, }); @@ -1155,14 +1164,32 @@ export function routineService( } } - // Records a skipped scheduled firing without creating an execution issue. Used when the - // routine's project is paused: the tick is still claimed/advanced upstream (no backfill), - // and run history + trigger audit reflect the pause-specific skip. - async function recordSuppressedScheduleRun(input: { + async function getAutomaticRoutineDispatchEligibility( + routine: typeof routines.$inferSelect, + activation?: WorktreeRunExecutionActivationState, + ) { + if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) return { eligible: true }; + + const resolvedActivation = activation ?? await resolveWorktreeRunExecutionActivationState({ + getExperimental: instanceSettings.getExperimental, + runtimeEnv, + }); + if (!resolvedActivation.armed) return { eligible: false }; + + const cutoff = new Date(resolvedActivation.cutoff); + if (Number.isNaN(cutoff.getTime()) || routine.createdAt < cutoff) return { eligible: false }; + return { eligible: true }; + } + + // Records an automatic firing that was claimed but intentionally not dispatched. The + // scheduler advances its tick before calling this helper, so suppressed work is never + // replayed after a setting or project state changes. + async function recordSuppressedAutomaticRun(input: { routine: typeof routines.$inferSelect; trigger: typeof routineTriggers.$inferSelect; + source: "schedule" | "webhook"; reason: string; - nextRunAt: Date | null; + nextRunAt?: Date | null; }) { const triggeredAt = new Date(); const run = await db.transaction(async (tx) => { @@ -1173,7 +1200,7 @@ export function routineService( companyId: input.routine.companyId, routineId: input.routine.id, triggerId: input.trigger.id, - source: "schedule", + source: input.source, status: "skipped", triggeredAt, failureReason: input.reason, @@ -1187,7 +1214,7 @@ export function routineService( routineId: input.routine.id, triggerId: input.trigger.id, triggeredAt, - status: "skipped_paused", + status: input.reason === "paused" ? "skipped_paused" : "skipped_worktree_execution_cutoff", nextRunAt: input.nextRunAt, }, txDb); return createdRun; @@ -1197,14 +1224,14 @@ export function routineService( await logActivity(db, { companyId: input.routine.companyId, actorType: "system", - actorId: "routine-scheduler", + actorId: input.source === "schedule" ? "routine-scheduler" : "routine-webhook", action: "routine.run_skipped", entityType: "routine_run", entityId: run.id, details: { routineId: input.routine.id, triggerId: input.trigger.id, - source: "schedule", + source: input.source, status: "skipped", reason: input.reason, }, @@ -2645,6 +2672,16 @@ export function routineService( if (!valid) throw unauthorized(); } + const eligibility = await getAutomaticRoutineDispatchEligibility(routine); + if (!eligibility.eligible) { + return recordSuppressedAutomaticRun({ + routine, + trigger, + source: "webhook", + reason: "worktree_execution_cutoff", + }); + } + return dispatchRoutineRun({ routine, trigger, @@ -2732,6 +2769,12 @@ export function routineService( }, tickScheduledTriggers: async (now: Date = new Date()) => { + const worktreeActivation = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE) + ? await resolveWorktreeRunExecutionActivationState({ + getExperimental: instanceSettings.getExperimental, + runtimeEnv, + }) + : undefined; const due = await db .select({ trigger: routineTriggers, @@ -2761,11 +2804,13 @@ export function routineService( // at the next cron boundary instead of replaying missed firings. Routines with no // project are never suppressed here. const projectPaused = !!(row.routine.projectId && row.projectPausedAt); + const automaticEligibility = await getAutomaticRoutineDispatchEligibility(row.routine, worktreeActivation); + const worktreeSuppressed = !automaticEligibility.eligible; let runCount = 1; let claimedNextRunAt = nextCronTickInTimeZone(row.trigger.cronExpression, row.trigger.timezone, now); - if (!projectPaused && row.routine.catchUpPolicy === "enqueue_missed_with_cap") { + if (!projectPaused && !worktreeSuppressed && row.routine.catchUpPolicy === "enqueue_missed_with_cap") { let cursor: Date | null = row.trigger.nextRunAt; runCount = 0; while (cursor && cursor <= now && runCount < MAX_CATCH_UP_RUNS) { @@ -2792,11 +2837,12 @@ export function routineService( .then((rows) => rows[0] ?? null); if (!claimed) continue; - if (projectPaused) { - await recordSuppressedScheduleRun({ + if (projectPaused || worktreeSuppressed) { + await recordSuppressedAutomaticRun({ routine: row.routine, trigger: row.trigger, - reason: "paused", + source: "schedule", + reason: worktreeSuppressed ? "worktree_execution_cutoff" : "paused", nextRunAt: claimedNextRunAt, }); continue; diff --git a/server/src/services/run-liveness.ts b/server/src/services/run-liveness.ts index 87b53285c71..1d7a05bba54 100644 --- a/server/src/services/run-liveness.ts +++ b/server/src/services/run-liveness.ts @@ -75,6 +75,8 @@ const RUNNABLE_RE = const PLAN_TASK_TITLE_RE = /\b(?:plan|planning|analysis|investigation|research|report|proposal|design doc|write-?up)\b/i; const PLAN_TASK_DESCRIPTION_RE = /\b(?:create|write|produce|draft|update|revise|prepare)\s+(?:a\s+|the\s+)?(?:plan|analysis|investigation|research report|report|proposal|design doc|write-?up)\b/i; +const UNMANAGED_BACKGROUND_TASK_STOP_REASON = "unmanaged_background_task_stopped"; +const UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON = "unmanaged background task stopped; no durable live path"; function compactReason(reason: string) { return reason.length <= 500 ? reason : `${reason.slice(0, 497)}...`; @@ -94,6 +96,17 @@ function readText(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function hasUnmanagedBackgroundTaskEvidence(resultJson: Record | null | undefined) { + if (!resultJson) return false; + if (resultJson.stopReason === UNMANAGED_BACKGROUND_TASK_STOP_REASON) return true; + const evidence = resultJson.unmanagedBackgroundTask; + if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) return false; + const record = evidence as Record; + return record.stopped === true && + (record.stopReason === UNMANAGED_BACKGROUND_TASK_STOP_REASON || + record.reason === UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON); +} + function resultFinalText(resultJson: Record | null | undefined) { if (!resultJson) return ""; return [ @@ -314,6 +327,9 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL } if (input.runStatus !== "succeeded") { + if (hasUnmanagedBackgroundTaskEvidence(input.resultJson)) { + return output("failed", UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON); + } return output("failed", input.errorCode ? `Run ended with ${input.runStatus} (${input.errorCode})` : `Run ended with ${input.runStatus}`); } diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index 9c6a814287d..0d3b508c9d8 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -31,7 +31,7 @@ export interface RunLogStore { begin(input: { companyId: string; agentId: string; runId: string }): Promise; append( handle: RunLogHandle, - event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string }, + event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string; seq?: number }, ): Promise; finalize(handle: RunLogHandle): Promise; read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise; @@ -113,6 +113,10 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore { ts: event.ts, stream: event.stream, chunk: event.chunk, + // Monotonic per-run sequence so readers can dedupe and order records + // even when several identical chunks share the same millisecond ts + // (common for ACP-style token deltas). + ...(typeof event.seq === "number" && Number.isFinite(event.seq) ? { seq: event.seq } : {}), }); const persisted = `${line}\n`; await fs.appendFile(absPath, persisted, "utf8"); diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index daee9a52c70..ed708d530ca 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests, @@ -1560,8 +1560,28 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) return toIssueWatchdog(updated); }, - reconcileTaskWatchdogs: async (opts: { companyId?: string | null; runId?: string | null } = {}) => { - const rows = await listActiveWatchdogsForCompany(opts.companyId ?? null); + reconcileTaskWatchdogs: async (opts: { + companyId?: string | null; + runId?: string | null; + issueCreatedAtGte?: Date | null; + } = {}) => { + let rows = await listActiveWatchdogsForCompany(opts.companyId ?? null); + if (opts.issueCreatedAtGte) { + const watchdogIssueIds = [...new Set(rows.map((row) => row.issueId))]; + const eligibleIssueIds = new Set( + watchdogIssueIds.length === 0 + ? [] + : (await db + .select({ id: issues.id }) + .from(issues) + .where(and( + inArray(issues.id, watchdogIssueIds), + gte(issues.createdAt, opts.issueCreatedAtGte), + ))) + .map((issue) => issue.id), + ); + rows = rows.filter((row) => eligibleIssueIds.has(row.issueId)); + } const result = { checked: 0, triggered: 0, diff --git a/server/src/ui-branding.ts b/server/src/ui-branding.ts index 8195c91e36b..5a264822823 100644 --- a/server/src/ui-branding.ts +++ b/server/src/ui-branding.ts @@ -16,6 +16,13 @@ export type WorktreeUiBranding = { color: string | null; textColor: string | null; faviconHref: string | null; + /** + * Runtime instance id for this worktree preview. Surfaced to the client so + * the experimental "Run tasks in this worktree" card can fail closed when a + * copied settings row was armed in a different instance. Null outside a + * worktree or when the runtime id is unset. + */ + instanceId: string | null; }; function isTruthyEnvValue(value: string | undefined): boolean { @@ -152,6 +159,7 @@ export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): Wor color: null, textColor: null, faviconHref: null, + instanceId: null, }; } @@ -165,6 +173,7 @@ export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): Wor color, textColor, faviconHref: createFaviconDataUrl(color, textColor), + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID), }; } @@ -181,12 +190,16 @@ export function renderFaviconLinks(branding: WorktreeUiBranding): string { export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string { if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return ""; - return [ + const tags = [ '', ``, ``, ``, - ].join("\n"); + ]; + if (branding.instanceId) { + tags.push(``); + } + return tags.join("\n"); } function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string { diff --git a/server/src/version.ts b/server/src/version.ts index 1dfff0aa1a7..e543b79e1d5 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; type PackageJson = { version?: string; @@ -7,14 +9,19 @@ type PackageJson = { type GitDescribeCommand = () => string; type DebugLog = (fields: Record, message: string) => void; +type PathExists = (path: string) => boolean; +type Realpath = (path: string) => string; const requirePackage = createRequire(import.meta.url); +const packageRoot = dirname(requirePackage.resolve("../package.json")); const pkg = requirePackage("../package.json") as PackageJson; const GIT_DESCRIBE_RE = /^v(?\d+\.\d+\.\d+)-(?\d+)-g(?[0-9a-f]{7,40})(?-dirty)?$/i; function defaultDebugLog(fields: Record, message: string): void { + if (process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION !== "1") return; + console.debug(message, fields); } @@ -23,6 +30,7 @@ function defaultGitDescribeCommand(): string { "git", ["describe", "--tags", "--match", "v*", "--long", "--dirty"], { + cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1500, @@ -30,6 +38,97 @@ function defaultGitDescribeCommand(): string { ); } +function hasPathSegment(path: string, segment: string): boolean { + return path.split(/[\\/]+/).includes(segment); +} + +function safeRealpath(path: string, realpath: Realpath): string { + try { + return realpath(path); + } catch { + return path; + } +} + +function hasGitMetadataBeforeNodeModulesBoundary( + path: string, + pathExists: PathExists, +): boolean { + let current = path; + + while (true) { + if (pathExists(join(current, ".git"))) return true; + + const parent = dirname(current); + if (parent === current || basename(current) === "node_modules") return false; + + current = parent; + } +} + +function isPackagedInstall( + path: string, + { + pathExists = existsSync, + realpath = realpathSync, + }: { pathExists?: PathExists; realpath?: Realpath } = {}, +): boolean { + const realPackageRoot = safeRealpath(path, realpath); + const candidateRoots = Array.from(new Set([path, realPackageRoot])); + const hasNodeModulesSegment = candidateRoots.some((candidate) => + hasPathSegment(candidate, "node_modules"), + ); + + if (!hasNodeModulesSegment) return false; + + return !candidateRoots.some((candidate) => + hasGitMetadataBeforeNodeModulesBoundary(candidate, pathExists), + ); +} + +function normalizeErrorField(value: unknown): unknown { + if (Buffer.isBuffer(value)) return value.toString("utf8"); + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8"); + return value; +} + +function compactRecord(fields: Record): Record { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); +} + +function summarizeError(err: unknown): Record { + if (err && typeof err === "object") { + const errorLike = err as { + name?: unknown; + message?: unknown; + status?: unknown; + signal?: unknown; + code?: unknown; + stdout?: unknown; + stderr?: unknown; + stack?: unknown; + cause?: unknown; + }; + + return compactRecord({ + name: errorLike.name, + message: errorLike.message, + status: errorLike.status, + signal: errorLike.signal, + code: errorLike.code, + stdout: normalizeErrorField(errorLike.stdout), + stderr: normalizeErrorField(errorLike.stderr), + stack: errorLike.stack, + cause: + errorLike.cause === undefined ? undefined : summarizeError(errorLike.cause), + }); + } + + return { message: String(err) }; +} + export function parseGitDescribeVersion(output: string): string | null { const match = output.trim().match(GIT_DESCRIBE_RE); if (!match?.groups) return null; @@ -51,11 +150,28 @@ export function resolveServerVersion( gitDescribeCommand?: GitDescribeCommand; packageVersion?: string; debugLog?: DebugLog; + packageRoot?: string; + pathExists?: PathExists; + realpath?: Realpath; } = {}, ): string { const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0"; const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand; const debugLog = opts.debugLog ?? defaultDebugLog; + const resolvedPackageRoot = opts.packageRoot ?? packageRoot; + + if ( + isPackagedInstall(resolvedPackageRoot, { + pathExists: opts.pathExists, + realpath: opts.realpath, + }) + ) { + debugLog( + { reason: "packaged_install" }, + "falling back to package version for server version", + ); + return packageVersion; + } try { const parsedVersion = parseGitDescribeVersion(gitDescribeCommand()); @@ -67,7 +183,7 @@ export function resolveServerVersion( ); } catch (err) { debugLog( - { err, reason: "git_describe_unavailable" }, + { err: summarizeError(err), reason: "git_describe_unavailable" }, "falling back to package version for server version", ); } diff --git a/skills/paperclip-board/SKILL.md b/skills/paperclip-board/SKILL.md index 55fa03b40cc..318fcb72bce 100644 --- a/skills/paperclip-board/SKILL.md +++ b/skills/paperclip-board/SKILL.md @@ -1,10 +1,9 @@ --- name: paperclip-board description: > - Manage a Paperclip company as a board member via chat. Covers onboarding - (company creation, CEO setup, hiring plans), agent management, approvals, - task monitoring, cost oversight, and work product review. Use this skill - whenever the user wants to interact with their Paperclip control plane. + Manage a Paperclip company as a board member via chat. Use when the user wants + onboarding, company or agent management, approvals, task monitoring, cost + oversight, or work product review in the Paperclip control plane. --- # Paperclip Board Skill diff --git a/skills/paperclip-converting-plans-to-tasks/SKILL.md b/skills/paperclip-converting-plans-to-tasks/SKILL.md index 64d846df395..8e4c4b6d0fd 100644 --- a/skills/paperclip-converting-plans-to-tasks/SKILL.md +++ b/skills/paperclip-converting-plans-to-tasks/SKILL.md @@ -1,13 +1,9 @@ --- name: paperclip-converting-plans-to-tasks description: > - The Paperclip way of converting a plan into executable tasks. Use whenever - you are asked to plan, scope, or break down work inside a Paperclip company. - Industry-agnostic guidance on how to translate a plan into assigned issues - with the right specialty, dependencies, and parallelization so Paperclip's - executor can pick up the work — it does not prescribe a plan format. Pair - with the `paperclip` skill, which covers the mechanics of writing the plan - document and reassigning the issue. + Convert Paperclip plans into executable issue graphs. Use when asked to plan, + scope, or break down Paperclip company work into assigned tasks with specialty + fit, dependencies, blockers, and parallelization. --- # Paperclip — Converting Plans to Tasks diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index bf4d86634bb..bc26b123e91 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -1,12 +1,10 @@ --- name: paperclip description: > - Interact with the Paperclip control plane API to manage tasks, coordinate with - other agents, and follow company governance. Use when you need to check - assignments, update task status, delegate work, post comments, set up or manage - routines (recurring scheduled tasks), or call any Paperclip API endpoint. Do NOT - use for the actual domain work itself (writing code, research, etc.) — only for - Paperclip coordination. + Interact with the Paperclip control plane API for task coordination and + governance. Use when checking assignments, updating issue status, posting + comments, delegating work, managing routines, or calling Paperclip API + endpoints. --- # Paperclip Skill @@ -202,20 +200,21 @@ POST /api/companies/{companyId}/approvals Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path. -Four kinds are supported. Pick the smallest kind that fits the decision shape: +Five kinds are supported. Pick the smallest kind that fits the decision shape: | Kind | When to use | When **not** to use | | ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. | | `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). | +| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. | | `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. | | `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. | Key shared semantics: -- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume. -- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction. -- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required. +- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume. +- **Target binding and staleness.** `request_confirmation`, `request_checkbox_confirmation`, and `request_item_verdicts` accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction. +- **Supersede on user comment.** Target-bound request kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required. - **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards. - **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path. @@ -259,6 +258,35 @@ When the board accepts, your wake delivers `result.selectedOptionIds` — the op For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**. +Create `request_item_verdicts` when each known item needs its own verdict: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial submit endpoint." }, + { "id": "docs", "label": "Docs update" } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`. + ## Niche Workflow Pointers Load `references/workflows.md` when the task matches one of these: diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 57ed100deaa..1b26b59aef1 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -960,6 +960,116 @@ Best practice: - After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`. - When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id. +### Item verdict requests + +Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time. + +Create an item-verdict request: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "title": "Review generated artifacts", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." }, + { "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "reasonLabel": "What should change?", + "allowBulkApprove": true, + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestItemVerdictsPayload`): + +| Field | Type | Default | Notes | +| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). | +| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. | +| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. | +| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. | +| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. | + +Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/verdicts +{ + "verdicts": [ + { "id": "api", "verdict": "approve" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions." } + ] +} +``` + +Server behavior: + +- Unknown item ids return 422. +- A verdict not listed in `payload.verdicts` returns 422. +- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`. +- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason. +- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests. + +Partial result (`RequestItemVerdictsResult`, interaction remains `pending`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": false, + "items": [ + { + "id": "docs", + "verdict": "reject", + "reason": "Needs install instructions.", + "resolvedByUserId": "local-board", + "resolvedAt": "2026-07-09T12:00:00.000Z" + } + ] +} +``` + +Complete result (interaction becomes `answered`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": true, + "items": [ + { "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" } + ] +} +``` + +Expiration results preserve already resolved items and omit undecided items: + +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`. +- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`. +- `cancelled` is reserved for future explicit cancellation flows. + ### Checking approval status ``` @@ -1069,10 +1179,11 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | | GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | -| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) | +| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) | | POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) | | POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | | POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | +| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` | | GET | `/api/issues/:issueId/documents` | List issue documents | | GET | `/api/issues/:issueId/documents/:key` | Get issue document by key | | PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) | diff --git a/skills/para-memory-files/SKILL.md b/skills/para-memory-files/SKILL.md index 99da806988c..b90a1aa8cf5 100644 --- a/skills/para-memory-files/SKILL.md +++ b/skills/para-memory-files/SKILL.md @@ -1,13 +1,9 @@ --- name: para-memory-files description: > - File-based memory system using Tiago Forte's PARA method. Use this skill whenever - you need to store, retrieve, update, or organize knowledge across sessions. Covers - three memory layers: (1) Knowledge graph in PARA folders with atomic YAML facts, - (2) Daily notes as raw timeline, (3) Tacit knowledge about user patterns. Also - handles planning files, memory decay, weekly synthesis, and recall via qmd. - Trigger on any memory operation: saving facts, writing daily notes, creating - entities, running weekly synthesis, recalling past context, or managing plans. + Use a file-based PARA memory system to store, retrieve, and organize durable + knowledge across sessions. Trigger on saving facts, daily notes, entity + records, weekly synthesis, recall, tacit user patterns, or plan memory. --- # PARA Memory Files diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 076484b3021..83fe93f2882 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -37,6 +37,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; +import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { BoardChat } from "./pages/BoardChat"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; @@ -102,6 +103,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -214,6 +217,7 @@ function boardRoutes() { } /> } /> + } /> } /> } /> } /> @@ -464,6 +468,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 95a81707dde..70f9c5369f1 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,7 +1,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types"; -export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; +export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string; seq?: number }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; type RedactionOptions = { enabled: boolean }; diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts new file mode 100644 index 00000000000..b76f25baa66 --- /dev/null +++ b/ui/src/api/attention.ts @@ -0,0 +1,14 @@ +import type { AttentionFeed } from "@paperclipai/shared"; +import { api } from "./client"; + +export const attentionApi = { + /** + * Fetch the ranked Decisions attention feed for a company. The server + * unions every attention source (approvals, interactions, recovery, reviews, + * failures, budget…) into one ranked queue with the §0 contract. + */ + list: (companyId: string, options: { includeDismissed?: boolean } = {}) => + api.get( + `/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`, + ), +}; diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index ab3cf09aaa5..030def1198b 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -15,10 +15,23 @@ import { api } from "./client"; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * `false` means the environment config changed since capture and runs fall + * back to the base image until a new image is captured. `null` when unknown. + */ + activeTemplateMatchesConfig?: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + +export type EnvironmentUpdateResult = Environment & { + customImageReconciliation?: EnvironmentCustomImageReconciliation; +}; + export interface EnvironmentCustomImageConnectionPayload { type: string; command?: string | null; @@ -64,8 +77,14 @@ export const environmentsApi = { status?: "active" | "archived"; config?: Record; metadata?: Record | null; - }) => api.patch(`/environments/${environmentId}`, body), - probe: (environmentId: string) => api.post(`/environments/${environmentId}/probe`, {}), + }) => api.patch(`/environments/${environmentId}`, body), + probe: (environmentId: string, companyId?: string | null) => + api.post( + companyId + ? `/environments/${environmentId}/probe?${customImageCompanyQuery(companyId)}` + : `/environments/${environmentId}/probe`, + {}, + ), probeConfig: (companyId: string, body: { name?: string; driver: "local" | "ssh" | "sandbox" | "plugin"; diff --git a/ui/src/api/inboxDismissals.ts b/ui/src/api/inboxDismissals.ts index f80d3aef966..e8de87dfd1c 100644 --- a/ui/src/api/inboxDismissals.ts +++ b/ui/src/api/inboxDismissals.ts @@ -5,4 +5,12 @@ export const inboxDismissalsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/inbox-dismissals`), dismiss: (companyId: string, itemKey: string) => api.post(`/companies/${companyId}/inbox-dismissals`, { itemKey }), + snooze: (companyId: string, itemKey: string, snoozedUntil: string) => + api.post(`/companies/${companyId}/inbox-dismissals`, { + itemKey, + kind: "snooze", + snoozedUntil, + }), + restore: (companyId: string, itemKey: string) => + api.delete(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 2fd1d1cebb1..73e6798b890 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -252,6 +252,12 @@ export const issuesApi = { data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null }, ) => api.post(`/issues/${id}/interactions/${interactionId}/respond`, data), + submitInteractionVerdicts: ( + id: string, + interactionId: string, + verdicts: { id: string; verdict: "approve" | "reject" | "defer"; reason?: string | null }[], + ) => + api.post(`/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }), getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 2c4d24cc6fa..1005860c20e 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent, Environment } from "@paperclipai/shared"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AgentConfigForm } from "./AgentConfigForm"; +import { defaultCreateValues } from "./agent-config-defaults"; const mockAgentsApi = vi.hoisted(() => ({ adapterModelProfiles: vi.fn(), @@ -68,7 +69,9 @@ vi.mock("../adapters", () => ({ adapterType === "hermes_gateway" ?
Hermes Gateway fields
: null, - buildAdapterConfig: () => ({}), + buildAdapterConfig: (values: { model?: string }) => ({ + model: values.model || undefined, + }), parseStdoutLine: () => [], }), })); @@ -223,6 +226,51 @@ async function renderForm( return { container, root }; } +async function renderCreateForm( + environments: Environment[], + valueOverrides: Partial = {}, + options: { showAdapterTestEnvironmentButton?: boolean } = {}, +) { + mockEnvironmentsApi.list.mockResolvedValue(environments); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const values = { + ...defaultCreateValues, + adapterType: "codex_local", + ...valueOverrides, + }; + const onChange = vi.fn(); + + await act(async () => { + root.render( + + + + + , + ); + }); + + await flushReact(); + return { container, root, onChange }; +} + describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; @@ -395,6 +443,87 @@ describe("AgentConfigForm environment selector", () => { }); }); + it("tests a Codex agent after clearing the primary model to the adapter default", async () => { + const result = await renderForm([ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + ], { + adapterConfig: { model: "gpt-5.4" }, + }, { + showAdapterTestEnvironmentButton: true, + }); + roots.push(result.root); + + const modelButton = Array.from(result.container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "gpt-5.4", + ); + expect(modelButton).toBeTruthy(); + + await act(async () => { + modelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const defaultButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Default", + ); + expect(defaultButton).toBeTruthy(); + + await act(async () => { + defaultButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const testButton = Array.from(result.container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Test", + ); + expect(testButton).toBeTruthy(); + + await act(async () => { + testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment.mock.calls[0]?.[2]).toMatchObject({ + adapterConfig: {}, + }); + const adapterConfig = (mockAgentsApi.testEnvironment.mock.calls[0]?.[2] as { + adapterConfig: Record; + }).adapterConfig; + expect(adapterConfig).not.toHaveProperty("model"); + expect(result.container.textContent).not.toContain("Cannot read properties of undefined"); + }); + + it("omits undefined adapter config entries when testing a create form with the default model", async () => { + const result = await renderCreateForm([ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + ], { + model: "", + }, { + showAdapterTestEnvironmentButton: true, + }); + roots.push(result.root); + + const testButton = Array.from(result.container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Test", + ); + expect(testButton).toBeTruthy(); + + await act(async () => { + testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment.mock.calls[0]?.[2]).toMatchObject({ + adapterConfig: {}, + }); + const adapterConfig = (mockAgentsApi.testEnvironment.mock.calls[0]?.[2] as { + adapterConfig: Record; + }).adapterConfig; + expect(adapterConfig).not.toHaveProperty("model"); + }); + it("flushes pending environment variable edits before testing adapter config", async () => { const result = await renderForm([ makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 5b00190ff71..d30af2b4a87 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -55,7 +55,7 @@ import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-confi import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; -import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; +import { buildAgentUpdatePatch, omitUndefinedEntries, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment"; @@ -195,7 +195,6 @@ function clampDelayMsFromSeconds(value: number) { return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000; } - /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { @@ -538,14 +537,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (adapterConfigPatch) { Object.assign(next, adapterConfigPatch); } - return next; + return omitUndefinedEntries(next); } const base = config as Record; const next = { ...base, ...overlay.adapterConfig }; if (adapterConfigPatch) { Object.assign(next, adapterConfigPatch); } - return next; + return omitUndefinedEntries(next); } function buildCheapAdapterConfigForTest(adapterConfigPatch?: Record): Record { @@ -747,9 +746,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }, [props.onTestFeedbackChange, testActionError, testEnvironment.data, testEnvironment.error]); // Current model for display - const currentModelId = isCreate - ? val!.model + const currentModelValue = isCreate + ? val!.model ?? "" : eff("adapterConfig", "model", String(config.model ?? "")); + const currentModelId = typeof currentModelValue === "string" ? currentModelValue : ""; async function handleRefreshModels() { if (!selectedCompanyId) return; diff --git a/ui/src/components/AttentionInteractionResolver.tsx b/ui/src/components/AttentionInteractionResolver.tsx new file mode 100644 index 00000000000..8176f534d8b --- /dev/null +++ b/ui/src/components/AttentionInteractionResolver.tsx @@ -0,0 +1,143 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { + isIssueThreadInteraction, + type AskUserQuestionsAnswer, + type AskUserQuestionsInteraction, + type IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, + type RequestConfirmationInteraction, + type RequestItemVerdictsInteraction, + type RequestItemVerdictValue, + type SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; + +interface AttentionInteractionResolverProps { + companyId: string; + issueId: string; + interactionId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + /** Called after a resolution so the parent can refresh the feed. */ + onResolved?: () => void; +} + +/** + * Lazily fetches the full issue-thread interaction referenced by an attention + * row and renders the existing {@link IssueThreadInteractionCard} inline, so + * confirmations and questions are answerable in-row without leaving the queue + * (converged PAP-12628). Reviews never reach here — they deep-link. + */ +export function AttentionInteractionResolver({ + companyId, + issueId, + interactionId, + agentMap, + currentUserId, + userLabelMap, + onResolved, +}: AttentionInteractionResolverProps) { + const queryClient = useQueryClient(); + + const { data: interactions, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.interactions(issueId), + queryFn: () => issuesApi.listInteractions(issueId), + enabled: !!issueId, + }); + + const interaction = useMemo(() => { + const match = (interactions ?? []).find((entry) => entry.id === interactionId); + return match && isIssueThreadInteraction(match) ? match : null; + }, [interactions, interactionId]); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + onResolved?.(); + }; + + const acceptMutation = useMutation({ + mutationFn: (input: { + interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; + selectedClientKeys?: string[]; + selectedOptionIds?: string[]; + }) => + issuesApi.acceptInteraction(issueId, input.interaction.id, { + selectedClientKeys: input.selectedClientKeys, + selectedOptionIds: input.selectedOptionIds, + }), + onSuccess: invalidate, + }); + + const rejectMutation = useMutation({ + mutationFn: (input: { interactionId: string; reason?: string }) => + issuesApi.rejectInteraction(issueId, input.interactionId, input.reason), + onSuccess: invalidate, + }); + + const respondMutation = useMutation({ + mutationFn: (input: { interactionId: string; answers: AskUserQuestionsAnswer[] }) => + issuesApi.respondToInteraction(issueId, input.interactionId, { answers: input.answers }), + onSuccess: invalidate, + }); + + const cancelMutation = useMutation({ + mutationFn: (input: { interactionId: string }) => + issuesApi.cancelInteraction(issueId, input.interactionId), + onSuccess: invalidate, + }); + + const verdictsMutation = useMutation({ + mutationFn: (input: { + interactionId: string; + verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[]; + }) => issuesApi.submitInteractionVerdicts(issueId, input.interactionId, input.verdicts), + onSuccess: invalidate, + }); + + if (isLoading) { + return ( +
+ Loading decision… +
+ ); + } + + if (error || !interaction) { + return ( +

+ This decision is no longer available — it may have been resolved elsewhere. +

+ ); + } + + return ( + + acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined) + } + onRejectInteraction={(target, reason) => + rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined) + } + onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) => + respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined) + } + onCancelInteraction={(target: AskUserQuestionsInteraction) => + cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined) + } + onSubmitInteractionVerdicts={(target: RequestItemVerdictsInteraction, verdicts) => + verdictsMutation.mutateAsync({ interactionId: target.id, verdicts }).then(() => undefined) + } + /> + ); +} diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx new file mode 100644 index 00000000000..09dfdf0ca08 --- /dev/null +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -0,0 +1,456 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { AnchorHTMLAttributes, ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared"; +import { approvalsApi } from "../api/approvals"; +import { issuesApi } from "../api/issues"; +import { ToastViewport } from "./ToastViewport"; +import { ToastProvider } from "../context/ToastContext"; +import { AttentionQueueRow } from "./AttentionQueueRow"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +vi.mock("../api/approvals", () => ({ + approvalsApi: { + approve: vi.fn(), + reject: vi.fn(), + requestRevision: vi.fn(), + }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + acceptInteraction: vi.fn(), + rejectInteraction: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(cb: () => T): T { + let result: T | undefined; + flushSync(() => { + result = cb(); + }); + return result as T; +} + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.clearAllMocks(); +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => + root?.render( + + + {element} + + + , + ), + ); + return container; +} + +function buildItem(overrides: Partial = {}): AttentionItem { + return { + id: "a1", + companyId: "c1", + sourceKind: "approval", + subject: { + kind: "approval", + id: "approval-1", + companyId: "c1", + title: "Hire agent: Research Analyst", + identifier: null, + status: "pending", + href: "/PAP/approvals/approval-1", + metadata: {}, + }, + whyNow: "Approval is pending a board decision.", + decisionVerbs: [], + inlineResolvable: true, + entryRule: "", + exitRule: "", + dedupKey: "approval:approval-1", + dismissalKey: "attention:approval:approval-1", + severity: "high", + rank: 0, + activityAt: "2026-07-09T12:00:00Z", + createdAt: "2026-07-09T12:00:00Z", + updatedAt: "2026-07-09T12:00:00Z", + relatedIssue: null, + project: null, + workspace: null, + detail: null, + dismissal: null, + ...overrides, + }; +} + +const noop = () => {}; + +describe("AttentionQueueRow", () => { + it("renders an inline approval resolver when expanded", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Approve"); + expect(el.textContent).toContain("Request revision"); + expect(el.textContent).toContain("Reject"); + // Inline rows show an expand chevron, not an "Open" deep-link. + expect(el.textContent).not.toContain("Open"); + }); + + it("does not inline a review — it deep-links instead", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Open"); + // No approval buttons should render for a review row. + expect(el.textContent).not.toContain("Request revision"); + }); + + it("fires onDismiss from the row menu action", () => { + const onDismiss = vi.fn(); + const item = buildItem(); + render( + , + ); + // The dropdown trigger + item live in a portal; invoke the handler contract + // directly via the rendered menu after opening is environment-flaky in + // jsdom, so assert the wiring by locating the trigger exists. + const trigger = container?.querySelector('[aria-label="Row actions"]'); + expect(trigger).toBeTruthy(); + }); + + it("toggles expand when the collapsed header of an inline row is clicked", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + const header = container?.querySelector('[role="button"][aria-expanded]'); + expect(header).toBeTruthy(); + expect(header?.getAttribute("aria-expanded")).toBe("false"); + act(() => { + header?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onToggleExpand).toHaveBeenCalledTimes(1); + }); + + it("does not navigate on title click — the title is plain text, not a link", () => { + render( + , + ); + const links = Array.from(container?.querySelectorAll("a") ?? []); + // No anchor should carry the subject title (only the identifier link, absent here). + expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false); + }); + + it("renders project identity once without a filter button", () => { + render( + , + ); + + const projectMeta = container?.querySelector('[data-testid="attention-project-meta"]'); + expect(projectMeta?.textContent).toBe("Alpha"); + expect(projectMeta?.querySelector("button")).toBeNull(); + expect(projectMeta?.getAttribute("class")).not.toContain("border"); + expect(projectMeta?.getAttribute("class")).not.toContain("bg-"); + expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull(); + expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1); + }); + + it("places the timestamp beside the row menu without a clock icon", () => { + render( + , + ); + + const menu = container?.querySelector('[aria-label="Row actions"]'); + const menuArea = menu?.closest('[data-attention-menu="true"]'); + expect(menuArea?.textContent).not.toBe(""); + expect(container?.querySelector("svg.lucide-clock")).toBeNull(); + }); + + it("uses square row edges and can show a keyboard selection ring", () => { + render( + , + ); + + const row = container?.querySelector("[data-attention-row]"); + expect(row?.getAttribute("class")).not.toContain("rounded"); + expect(row?.getAttribute("class")).toContain("ring-ring"); + }); + + it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => { + render( + , + ); + + const header = container?.querySelector('[role="button"][aria-expanded]'); + expect(header?.textContent).not.toContain("Approve"); + expect(header?.textContent).not.toContain("Reject"); + + const decisionActions = container?.querySelector('[aria-label="Decision actions"]'); + expect(decisionActions?.textContent).toContain("Approve"); + expect(decisionActions?.textContent).toContain("Reject"); + + const actionArea = decisionActions?.closest('[data-attention-actions="true"]'); + expect(actionArea?.getAttribute("class")).toContain("mt-auto"); + + const controls = decisionActions?.closest('[data-attention-controls="true"]'); + expect(controls?.getAttribute("class")).toContain("self-stretch"); + expect(controls?.getAttribute("class")).toContain("justify-between"); + + const rowMenu = container?.querySelector('[aria-label="Row actions"]'); + expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy(); + expect(rowMenu?.closest('[data-attention-actions="true"]')).toBeNull(); + + const buttons = Array.from(decisionActions?.querySelectorAll("button") ?? []); + expect(buttons.find((button) => button.textContent === "Approve")?.getAttribute("data-variant")).toBe( + "default", + ); + expect(buttons.find((button) => button.textContent === "Reject")?.getAttribute("data-variant")).toBe( + "destructive", + ); + }); + + it("submits a compact approval without expanding the card and confirms it", async () => { + const onToggleExpand = vi.fn(); + vi.mocked(approvalsApi.approve).mockResolvedValue({} as never); + render( + , + ); + + const approve = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Approve", + ); + act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(approvalsApi.approve).toHaveBeenCalledWith("approval-1"); + expect(onToggleExpand).not.toHaveBeenCalled(); + expect(container?.textContent).toContain("Approval approved"); + }); + + it("renders configured confirmation labels and accepts from the compact action area", async () => { + const onToggleExpand = vi.fn(); + vi.mocked(issuesApi.acceptInteraction).mockResolvedValue({} as never); + render( + , + ); + + const decisionActions = container?.querySelector('[aria-label="Decision actions"]'); + expect(decisionActions?.textContent).toContain("Approve plan"); + expect(decisionActions?.textContent).toContain("Request changes"); + expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Approve plan")?.getAttribute("data-variant")).toBe("default"); + expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Request changes")?.getAttribute("data-variant")).toBe("outline"); + + const approve = Array.from(decisionActions?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Approve plan", + ); + act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(issuesApi.acceptInteraction).toHaveBeenCalledWith("issue-1", "interaction-1"); + expect(onToggleExpand).not.toHaveBeenCalled(); + }); + + it("opens the matching confirmation form when requesting changes from a compact action", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + + const requestChanges = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Request changes", + ); + act(() => requestChanges?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + expect(onToggleExpand).toHaveBeenCalledOnce(); + expect(issuesApi.rejectInteraction).not.toHaveBeenCalled(); + }); + + it("centers thumbnails beside the full card text stack", () => { + render( + , + ); + + const image = container?.querySelector('img[alt="Screenshot"]'); + expect(image?.getAttribute("src")).toBe("/api/assets/asset-1/content"); + + const thumbnailStack = image?.parentElement?.parentElement; + expect(thumbnailStack?.getAttribute("class")).toContain("items-center"); + expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center"); + }); + + it("does not expose a toggle button for non-inline rows", () => { + render( + , + ); + expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull(); + }); +}); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx new file mode 100644 index 00000000000..1ead76c45d1 --- /dev/null +++ b/ui/src/components/AttentionQueueRow.tsx @@ -0,0 +1,638 @@ +import { useState, type KeyboardEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlarmClock, + ChevronDown, + ChevronRight, + ExternalLink, + Loader2, + MoreHorizontal, + RotateCcw, + X, +} from "lucide-react"; +import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { accessApi } from "../api/access"; +import { approvalsApi } from "../api/approvals"; +import { issuesApi } from "../api/issues"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { + attentionDetailImages, + attentionDetailLine, + attentionImageUrl, + attentionToneStyle, + isInlineResolvable, + severityBadge, + sourceMeta, +} from "../lib/attention"; +import { cn, relativeTime } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { AttentionInteractionResolver } from "./AttentionInteractionResolver"; +import { ProjectTile } from "./ProjectTile"; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** Tomorrow at 9am local time. */ +function tomorrowMorningIso(): string { + const d = new Date(); + d.setDate(d.getDate() + 1); + d.setHours(9, 0, 0, 0); + return d.toISOString(); +} + +/** Snooze presets, resolved to a future ISO timestamp at click time. */ +const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [ + { label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() }, + { label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() }, + { label: "Tomorrow morning", resolve: tomorrowMorningIso }, + { label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() }, +]; + +interface AttentionQueueRowProps { + item: AttentionItem; + companyId: string; + expanded: boolean; + onToggleExpand: () => void; + onDismiss: (item: AttentionItem) => void; + onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; + /** Restore a snoozed/dismissed row (curtain variant only). */ + onRestore?: (item: AttentionItem) => void; + /** "active" renders the live queue row; "hidden" renders a curtain row. */ + variant?: "active" | "hidden"; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + selected?: boolean; +} + +export function AttentionQueueRow({ + item, + companyId, + expanded, + onToggleExpand, + onDismiss, + onSnooze, + onRestore, + variant = "active", + agentMap, + currentUserId, + userLabelMap, + selected = false, +}: AttentionQueueRowProps) { + const meta = sourceMeta(item.sourceKind); + const tone = attentionToneStyle(item); + const sevBadge = severityBadge(item.severity); + const Icon = meta.icon; + const isHidden = variant === "hidden"; + const inline = !isHidden && isInlineResolvable(item); + const href = item.subject.href; + const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null; + const detailLine = attentionDetailLine(item) ?? item.whyNow; + const images = attentionDetailImages(item); + // Only inline-resolvable active rows can expand; that's the only case where a + // whole-header click has somewhere to go (plan §5). Non-inline rows keep the + // explicit Open button and never toggle on a stray click. + const expandable = inline; + + const activate = () => { + if (expandable) onToggleExpand(); + }; + const onHeaderKeyDown = (e: KeyboardEvent) => { + if (!expandable) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleExpand(); + } + }; + + return ( +
+ {/* Type accent bar (canonical color map — never severity). */} + + +
+ {/* Clickable header region: toggles expand for inline rows (plan §2/§5). */} +
+ {/* Expand affordance / source icon */} + {expandable ? ( + + {expanded ? : } + + ) : ( + + + + )} + +
+
+
+ + + {meta.label} + + {sevBadge && ( + + {sevBadge.label} + + )} + {item.relatedIssue?.identifier && ( + e.stopPropagation()} + > + {item.relatedIssue.identifier} + + )} +
+ +
+ + {item.subject.title ?? meta.label} + +

{detailLine}

+ + {item.project && ( +
+ +
+ )} +
+
+ + {images.length > 0 && } +
+
+ + {/* Controls: kept as siblings (not inside the clickable header) so they + never toggle expand and stay valid interactive targets. */} +
+
+ {isHidden && snoozedUntil ? ( + + Reappears {reappearLabel(snoozedUntil)} + + ) : ( + {relativeTime(item.activityAt)} + )} + {!isHidden && ( + + + + + + {onSnooze && onSnooze(item, iso)} />} + onDismiss(item)}> + + Dismiss + + {href && ( + <> + + + Open source + + + )} + + + )} +
+ +
+ {!expanded && } + +
+ {!inline && href && ( + + )} + + {isHidden && onRestore && ( + + )} +
+
+
+
+ + {inline && expanded && ( +
+ +
+ )} +
+ ); +} + +type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision"; + +function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null { + if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) { + return verbId; + } + if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) { + return verbId; + } + if ( + item.sourceKind === "issue_thread_interaction" + && item.subject.metadata?.kind === "request_confirmation" + && (verbId === "accept" || verbId === "reject") + ) { + return verbId; + } + return null; +} + +function CompactDecisionActions({ + item, + companyId, + onOpen, +}: { + item: AttentionItem; + companyId: string; + onOpen: () => void; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const actions = item.decisionVerbs + .slice(0, 3) + .flatMap((verb) => { + const action = compactDecisionAction(item, verb.id); + return action ? [{ action, label: verb.label, id: verb.id }] : []; + }); + + const decision = useMutation({ + mutationFn: (action: CompactDecisionAction) => { + if (item.sourceKind === "approval") { + if (action === "approve") return approvalsApi.approve(item.subject.id); + if (action === "reject") return approvalsApi.reject(item.subject.id); + return approvalsApi.requestRevision(item.subject.id); + } + if (item.sourceKind === "join_request") { + return action === "approve" + ? accessApi.approveJoinRequest(companyId, item.subject.id) + : accessApi.rejectJoinRequest(companyId, item.subject.id); + } + if (item.sourceKind === "issue_thread_interaction") { + const issueId = item.subject.metadata?.issueId; + if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision."); + if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id); + return issuesApi.rejectInteraction(issueId, item.subject.id); + } + throw new Error("This decision must be completed from its detail view."); + }, + onSuccess: (_result, action) => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + if (item.sourceKind === "approval") { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); + } else { + queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) }); + } + pushToast({ + title: compactDecisionSuccessLabel(item.sourceKind, action), + tone: "success", + }); + }, + onError: (error, action) => { + pushToast({ + title: `Could not ${decisionLabel(action)}`, + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); + }, + }); + + if (actions.length === 0) return null; + + return ( +
+ {actions.map(({ action, id, label }) => ( + + ))} +
+ ); +} + +function decisionLabel(action: CompactDecisionAction): string { + if (action === "request_revision") return "sent for revision"; + if (action === "accept" || action === "approve") return "approved"; + return "rejected"; +} + +function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string { + if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`; + if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`; + return action === "accept" ? "Confirmation accepted" : "Confirmation declined"; +} + +function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" { + const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase(); + if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive"; + if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default"; + return "outline"; +} + +/** Inline project identity keeps useful context without a competing badge. */ +function ProjectMeta({ project }: { project: NonNullable }) { + return ( + + + {project.name} + + ); +} + +/** Square screenshot thumbnails at the right of the description (plan §10). */ +function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) { + const visible = images.slice(0, 3); + const extra = images.length - visible.length; + return ( +
+
+ {visible.map((img, i) => ( + {img.alt + ))} +
+ {extra > 0 && ( + + +{extra} + + )} +
+ ); +} + +/** Snooze submenu: presets + a custom date-time (plan §6). */ +function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) { + const [customValue, setCustomValue] = useState(""); + const applyCustom = () => { + if (!customValue) return; + const ts = new Date(customValue); + if (Number.isNaN(ts.getTime())) return; + onSnooze(ts.toISOString()); + }; + return ( + + + + Snooze + + + {SNOOZE_PRESETS.map((preset) => ( + onSnooze(preset.resolve())}> + {preset.label} + + ))} + + {/* Custom picker: a non-menu-item region so interacting with the input + doesn't close the menu (guard keydown/select against Radix typeahead). */} +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + Custom + + setCustomValue(e.target.value)} + className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs" + /> + +
+
+
+ ); +} + +/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */ +function reappearLabel(snoozedUntil: string): string { + const diffMs = new Date(snoozedUntil).getTime() - Date.now(); + if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon"; + const diffMin = Math.round(diffMs / 60000); + if (diffMin < 60) return `in ${diffMin}m`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `in ${diffHr}h`; + const diffDay = Math.round(diffHr / 24); + return `in ${diffDay}d`; +} + +function InlineResolver({ + item, + companyId, + agentMap, + currentUserId, + userLabelMap, +}: { + item: AttentionItem; + companyId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +}) { + if (item.sourceKind === "issue_thread_interaction") { + const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id; + if (!issueId) { + return

Missing issue reference for this decision.

; + } + return ( + + ); + } + + if (item.sourceKind === "approval") { + return ; + } + + if (item.sourceKind === "join_request") { + return ; + } + + return null; +} + +function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(""); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); + }; + const approve = useMutation({ + mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const reject = useMutation({ + mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const revise = useMutation({ + mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const pending = approve.isPending || reject.isPending || revise.isPending; + + return ( +
+