From 1bd44c8a0dc2bfd5a8876143a11f8461fb57bf77 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 13 May 2026 22:00:10 -0700 Subject: [PATCH 01/53] Harden Cloudflare sandbox execution (#5967) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Remote-managed adapters need sandbox/environment execution to behave like real agent runs, not just local host probes. > - The Cloudflare sandbox path was the weakest leg in the SSH + Cloudflare QA matrix because bridge execution could truncate output, time out long-running installs, and under-provision the worker instance. > - That made several adapters fail for reasons unrelated to their actual business logic, which blocks confidence in Paperclip's non-local environment model. > - This pull request hardens the Cloudflare bridge/runtime path and adjusts sandbox probe budgets so adapter verification matches the measured behavior of the fixed environment. > - It also corrects the Pi sandbox install command so the QA matrix exercises a real, supported install path. > - The benefit is a materially more reliable SSH + Cloudflare adapter matrix with fewer false negatives and clearer failure boundaries. ## What Changed - Switched the Cloudflare bridge worker instance type to `standard-2` for the QA-matrix execution path. - Raised Cloudflare bridge/plugin-worker timeout budgets and added SSE keepalives so long-running install/exec calls can complete instead of dying at the transport layer. - Fixed Cloudflare bridge-channel command handling to avoid dropped final stdout chunks on short-lived execs. - Made Claude, OpenCode, and Cursor sandbox probe timeouts configurable/sandbox-aware, then tightened the defaults to the measured post-fix range. - Updated the Pi sandbox install command to use the package currently installed by the official `pi.dev` installer, pinned to a specific npm version. - Added/updated tests around Cloudflare bridge behavior and adapter sandbox probe paths. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/adapter-opencode-local typecheck` - `pnpm --filter @paperclipai/adapter-cursor-local typecheck` - `pnpm vitest run packages/adapters/cursor-local packages/adapters/claude-local packages/adapters/opencode-local packages/adapters/pi-local packages/plugins/sandbox-providers/cloudflare server/src/services/__tests__/plugin-worker-manager.test.ts` - Manual QA on the dedicated dev instance using the SSH + Cloudflare environment matrix (`ENV-29` through `ENV-40`). Clean end-to-end passes: SSH `claude_local`, `codex_local`, `cursor`, `gemini_local`; Cloudflare `claude_local`, `codex_local`, `cursor`, `gemini_local`. ## Risks - Cloudflare sandbox cost increases because the bridge worker now runs on `standard-2` instead of `lite`. - Higher timeout ceilings can delay surfacing truly hung Cloudflare bridge calls, even though they remove transport-level false negatives. - The manual heartbeat matrix still exposed follow-on execution/sync/disposition bugs in `opencode_local` and `pi_local`; those are not fixed by this PR. ## Model Used - OpenAI `gpt-5.4` via Paperclip `codex_local`, reasoning effort `high`, tool use enabled, repo search enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable) - [x] I have updated relevant documentation to reflect my changes (not applicable) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../adapters/claude-local/src/server/test.ts | 10 +++- .../adapters/cursor-local/src/server/test.ts | 18 +++++- .../opencode-local/src/server/test.ts | 12 +++- packages/adapters/pi-local/src/index.ts | 2 +- .../cloudflare/bridge-template/src/routes.ts | 12 ++++ .../cloudflare/bridge-template/wrangler.jsonc | 2 +- .../cloudflare/src/config.ts | 2 +- .../cloudflare/src/plugin.test.ts | 55 ++++++++++++++++++- .../cloudflare/src/plugin.ts | 8 ++- server/src/services/plugin-worker-manager.ts | 4 +- 10 files changed, 113 insertions(+), 12 deletions(-) diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 4b23fcb0..66e451cc 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -212,6 +212,14 @@ export async function testEnvironment( if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); if (extraArgs.length > 0) args.push(...extraArgs); + // Sandbox bridges still add lease warmup and transport overhead, but + // the standard-2 Cloudflare tier now probes fast enough that a 90s + // budget leaves headroom without masking real hangs. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45), + ); + const probe = await runAdapterExecutionTargetProcess( runId, target, @@ -220,7 +228,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {}, diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index cc2c2916..8059d897 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -4,6 +4,7 @@ import type { AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; import { + asNumber, asString, asStringArray, parseObject, @@ -98,6 +99,7 @@ export async function testEnvironment( let command = asString(config.command, "agent"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -230,6 +232,12 @@ export async function testEnvironment( hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.", }); } else { + // Cursor's `agent` binary still pays cold-start overhead in container + // sandboxes, but standard-2 probes no longer need a 120s version budget. + const versionProbeTimeoutSec = Math.max( + 1, + asNumber(config.versionProbeTimeoutSec, targetIsSandbox ? 60 : 45), + ); const versionProbe = await runAdapterExecutionTargetProcess( runId, target, @@ -238,7 +246,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: versionProbeTimeoutSec, graceSec: 5, onLog: async () => {}, }, @@ -295,6 +303,12 @@ export async function testEnvironment( if (extraArgs.length > 0) args.push(...extraArgs); args.push("Respond with hello."); + // Sandbox bridges still add cursor CLI cold-start overhead, but the + // standard-2 tier now completes probes fast enough that 90s is ample. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45), + ); const probe = await runAdapterExecutionTargetProcess( runId, target, @@ -303,7 +317,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, onLog: async () => {}, }, diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 7335fda1..634d920c 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -9,6 +9,7 @@ import type { import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import { asBoolean, + asNumber, asString, asStringArray, parseObject, @@ -72,6 +73,7 @@ export async function testEnvironment( const command = asString(config.command, "opencode"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -334,6 +336,14 @@ export async function testEnvironment( if (variant) args.push("--variant", variant); if (extraArgs.length > 0) args.push(...extraArgs); + // Sandbox bridges still add cold-start and transport overhead, but the + // standard-2 Cloudflare tier now probes quickly enough that 90s keeps + // useful headroom without letting slow hangs linger. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 60), + ); + try { const probe = await runAdapterExecutionTargetProcess( runId, @@ -343,7 +353,7 @@ export async function testEnvironment( { cwd: runtimeCwd, env: runtimeEnv, - timeoutSec: 60, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {}, diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 4d13eb76..fef8bc2b 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -3,7 +3,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "pi_local"; export const label = "Pi (local)"; -export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent"; +export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0"; export const models: Array<{ id: string; label: string }> = []; diff --git a/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts b/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts index 02369921..79dd758c 100644 --- a/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts +++ b/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts @@ -423,6 +423,17 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { + // Heartbeat keeps the SSE response alive during silent stretches + // (e.g. npm install downloading silently). SSE comment lines (`:`) + // are ignored by the client parser but keep the underlying HTTP + // connection from idling out at the Cloudflare edge. + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": keepalive\n\n")); + } catch { + // Controller may already be closed; ignore. + } + }, 15_000); try { const result = await executeInSandbox({ sandbox, @@ -444,6 +455,7 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro error: error instanceof Error ? error.message : String(error), }))); } finally { + clearInterval(heartbeat); controller.close(); } }, diff --git a/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc b/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc index 24266c99..e306d229 100644 --- a/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc +++ b/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc @@ -7,7 +7,7 @@ { "class_name": "Sandbox", "image": "./Dockerfile", - "instance_type": "lite", + "instance_type": "standard-2", "max_instances": 10 } ], diff --git a/packages/plugins/sandbox-providers/cloudflare/src/config.ts b/packages/plugins/sandbox-providers/cloudflare/src/config.ts index 9aff3ac3..1ed62a26 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/config.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/config.ts @@ -3,7 +3,7 @@ import type { CloudflareDriverConfig } from "./types.js"; const DEFAULT_REQUESTED_CWD = "/workspace/paperclip"; const DEFAULT_SLEEP_AFTER = "10m"; const DEFAULT_TIMEOUT_MS = 300_000; -const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000; const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); function readTrimmedString(value: unknown): string | null { diff --git a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts index 4452e97b..84a6077b 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import plugin from "./plugin.js"; const fetchMock = vi.fn(); +let plugin: typeof import("./plugin.js").default; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -23,9 +23,11 @@ function requestBodyAt(index = 0): Record { } describe("Cloudflare sandbox provider plugin", () => { - beforeEach(() => { + beforeEach(async () => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); + vi.resetModules(); + plugin = (await import("./plugin.js")).default; }); it("declares the Cloudflare environment lifecycle handlers", async () => { @@ -210,6 +212,12 @@ describe("Cloudflare sandbox provider plugin", () => { }); it("routes bridge-channel execute calls through a dedicated session", async () => { + // pluginLogger must be set for the streaming branch to be reachable, so + // we can assert that bridge-channel calls take the non-streaming path + // even when adapter sessions would otherwise stream. + await plugin.definition.setup?.({ + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + } as never); fetchMock.mockResolvedValueOnce( jsonResponse({ exitCode: 0, @@ -248,6 +256,49 @@ describe("Cloudflare sandbox provider plugin", () => { }, }); expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL"); + // Bridge-channel commands must use the non-streaming exec path. The + // @cloudflare/sandbox SDK's streaming mode can drop the final stdout + // chunk when a short shell exits the same tick it writes — bridge ops + // carry machine-consumed stdout (readiness JSON, base64 file payloads, + // queue response bodies) where that data loss surfaces as opaque + // "invalid readiness JSON" / "Invalid bridge request payload" errors. + expect(requestBodyAt().streamOutput).toBe(false); + }); + + it("uses streaming exec for non-bridge adapter commands so live logs flow", async () => { + // Streaming is gated on `pluginLogger` being set, which normally happens + // in `setup()`. Wire a minimal logger so the streaming branch is reachable. + await plugin.definition.setup?.({ + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + } as never); + fetchMock.mockResolvedValueOnce( + new Response( + "event: stdout\ndata: {\"data\":\"hello\\n\"}\n\nevent: complete\ndata: {\"exitCode\":0,\"signal\":null,\"timedOut\":false,\"stdout\":\"hello\\n\",\"stderr\":\"\"}\n\n", + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ), + ); + + await plugin.definition.onEnvironmentExecute?.({ + driverKey: "cloudflare", + companyId: "company-1", + environmentId: "env-1", + lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} }, + command: "echo", + args: ["hello"], + cwd: "/workspace/paperclip", + env: { KEEP_ME: "visible" }, + config: { + bridgeBaseUrl: "https://bridge.example.workers.dev", + bridgeAuthToken: "resolved-token", + sessionStrategy: "named", + sessionId: "paperclip", + }, + }); + + expect(requestBodyAt().streamOutput).toBe(true); }); it("maps lost-lease execute errors into a deterministic command failure", async () => { diff --git a/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts b/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts index ad579a45..63a96dbe 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts @@ -317,7 +317,13 @@ const plugin = definePlugin({ const { config, client } = bridgeClientFor(params.config); const session = resolveExecuteSession(config, params.env); try { - const streamingOptions = pluginLogger + // Bridge-channel commands carry machine-consumed stdout (JSON, base64, + // file contents). The @cloudflare/sandbox SDK's streaming mode can drop + // the final stdout chunk when the inner shell exits the same tick as it + // writes (e.g. `cat ready.json && exit 0`), so we never stream for + // bridge control traffic — only adapter sessions get live log forwarding. + const isBridgeChannel = params.env?.[SANDBOX_EXEC_CHANNEL_ENV] === SANDBOX_EXEC_CHANNEL_BRIDGE; + const streamingOptions = pluginLogger && !isBridgeChannel ? { onOutput: async (stream: "stdout" | "stderr", chunk: string) => { logCloudflareExecChunk(pluginLogger, stream, chunk); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index cdb0eb0b..daedc521 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -57,8 +57,8 @@ import { logger } from "../middleware/logger.js"; /** Default timeout for RPC calls in milliseconds. */ const DEFAULT_RPC_TIMEOUT_MS = 30_000; -/** Hard upper bound for any RPC timeout (5 minutes). Prevents unbounded waits. */ -const MAX_RPC_TIMEOUT_MS = 5 * 60 * 1_000; +/** Hard upper bound for any RPC timeout (15 minutes). Prevents unbounded waits. */ +const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000; /** Timeout for the initialize RPC call. */ const INITIALIZE_TIMEOUT_MS = 15_000; From 333a16b035c0811c39418ecda2c021a60247840e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 14 May 2026 08:37:04 -0500 Subject: [PATCH 02/53] Fix company export with missing run logs (#5960) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - Company export/import lets operators move company state, including issue threads and agent execution context, between Paperclip instances. > - Issue comments can be enriched by nearby heartbeat run logs so exported threads preserve useful agent/run attribution metadata. > - Some local instances can have heartbeat run database rows whose local log files were deleted or never copied into the current workspace. > - The export path should still include the original user comments instead of failing because optional run-log metadata is unavailable. > - This pull request makes comment run-log metadata derivation tolerate missing local log files, logs the missing-file condition for operators, and adds a regression test. > - The benefit is safer company exports for real instances with incomplete local run-log storage. ## What Changed - Treat missing local heartbeat run logs as absent optional metadata while listing issue comments. - Emit a structured warning with `runId` and `logRef` when optional comment-attribution log content is missing. - Preserve the existing error behavior for non-404 run-log read failures. - Added a regression test proving user comments still list when a candidate attribution run has a missing local log reference. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "candidate attribution run log is missing"` passed: 1 selected test passed, 47 skipped. - `pnpm --filter @paperclipai/server typecheck` passed. - Greptile Review passed with Confidence Score 5/5 and zero unresolved threads on commit `f68cac02bf98d7d31e7831e5bdfa95cffa85e254`. - GitHub PR workflow run succeeded: `policy`, `verify`, four serialized server suites, `e2e`, and `Canary Dry Run` all passed. - `security/snyk (cryppadotta)` passed. - Confirmed this branch is on top of `public-gh/master` and `pnpm-lock.yaml` is not in the PR diff. ## Risks - Low risk. The change only softens optional comment metadata derivation for 404/missing local log files; other log read errors still throw. - Exported comments in this edge case may lack derived run metadata, but they remain visible/exportable instead of failing the request. - Operators may see new warnings when historical run-log references point to missing local files; those warnings indicate degraded optional metadata, not data loss. ## Model Used - OpenAI Codex, GPT-5 coding agent in this Paperclip heartbeat, with shell/git/GitHub CLI tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- server/src/__tests__/issues-service.test.ts | 66 +++++++++++++++++++++ server/src/services/issues.ts | 41 ++++++++----- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44ac15e6..077332c6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1222,6 +1222,72 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(comments[0]?.body).toBe("Comment should be visible"); }); + it("lists user comments when a candidate attribution run log is missing", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const commentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Comments issue with missing run log", + status: "todo", + priority: "medium", + }); + + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId, + contextSnapshot: { issueId }, + createdAt: new Date("2026-05-12T22:58:00.000Z"), + startedAt: new Date("2026-05-12T22:58:00.000Z"), + finishedAt: new Date("2026-05-12T23:14:00.000Z"), + logStore: "local_file", + logRef: "missing/run-log.ndjson", + logBytes: 128, + }); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "user-1", + body: "Comment should still be visible", + createdAt: new Date("2026-05-12T23:00:00.000Z"), + updatedAt: new Date("2026-05-12T23:00:00.000Z"), + }); + + const comments = await svc.listComments(issueId, { + order: "desc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([commentId]); + expect(comments[0]?.body).toBe("Comment should still be visible"); + expect(comments[0]?.metadata).toBeNull(); + }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { const companyId = randomUUID(); const blockerId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e0a3f1a5..fb47582f 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -50,7 +50,8 @@ import { isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { parseObject } from "../adapters/utils.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -2804,6 +2805,7 @@ export function issueService(db: Db) { } async function readRunLogText(run: { + runId?: string | null; logStore: string | null; logRef: string | null; logBytes: number | null; @@ -2817,19 +2819,30 @@ export function issueService(db: Db) { let content = ""; let nextOffset: number | undefined = 0; - while (nextOffset !== undefined) { - const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); - if (remainingBytes <= 0) break; - const chunk = await store.read( - { store: "local_file", logRef: run.logRef }, - { - offset, - limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), - }, - ); - content += chunk.content; - nextOffset = chunk.nextOffset; - offset = chunk.nextOffset ?? 0; + try { + while (nextOffset !== undefined) { + const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); + if (remainingBytes <= 0) break; + const chunk = await store.read( + { store: "local_file", logRef: run.logRef }, + { + offset, + limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), + }, + ); + content += chunk.content; + nextOffset = chunk.nextOffset; + offset = chunk.nextOffset ?? 0; + } + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + logger.warn( + { err, runId: run.runId ?? undefined, logRef: run.logRef }, + "missing heartbeat run log while deriving issue comment metadata", + ); + return content; + } + throw err; } return content; From 901c088e1499d0ba8db76c03171d55f62c9aa312 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 14 May 2026 22:09:16 -0700 Subject: [PATCH 03/53] fix: propagate projectId into wakeup context and support identifier lookup (#6026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The server's heartbeat/wakeup pipeline resolves which project workspace an agent run should bind to > - `enqueueWakeup` resolves an issue (and therefore a project) before scheduling a run, but the resolved `projectId` was never written back into `enrichedContextSnapshot.projectId`, so `resolveWorkspaceForRun` always saw `contextProjectId === null` > - When the `issueProjectRef` DB lookup also returned null (e.g. identifier-style id like `ENV-13`, not a UUID), workspace resolution fell through to the `agent_home` fallback instead of the correct project workspace > - Surfaced while running the QA matrix on sandbox/SSH — runs were ending up in the wrong workspace > - This pull request stores the resolved `projectId` back into context and replaces the raw UUID-only DB query with `issuesSvc.getById`, which accepts both UUIDs and identifiers and canonicalizes `context.issueId` / `context.taskId` to the UUID on identifier hits > - The benefit is that wakeups triggered with identifier-style ids correctly bind to their project workspace instead of silently degrading to `agent_home` ## What Changed - In `enqueueWakeup`, after the issue resolves, write `projectId` back into `enrichedContextSnapshot.projectId` so downstream workspace resolution can use it. - Replace the raw UUID-only DB query for the issue with `issuesSvc.getById`, which handles both UUIDs and identifiers (e.g. `ENV-13`). - On an identifier hit, canonicalize `context.issueId` and `context.taskId` to the resolved UUID. ## Verification - Trigger a wakeup with an identifier-style id (`ENV-13`) on the dev instance and confirm the run binds to the correct project workspace instead of `agent_home`. - Confirm UUID-style wakeups still resolve to the same project workspace as before. ## Risks - Low risk. Scope is a single function in `server/src/services/heartbeat.ts` (+20/-7). Failure mode if regressed is the prior behavior (fallback to `agent_home`). ## Model Used - Claude (Anthropic), `claude-opus-4-7`, via Claude Code / Paperclip `claude_local` adapter. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [ ] I have run tests locally and they pass - [ ] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- server/src/services/heartbeat.ts | 40 ++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3fa574b7..248a51ed 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -159,7 +159,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { extractSkillMentionIds } from "@paperclipai/shared"; +import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { environmentRuntimeService } from "./environment-runtime.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; @@ -1777,7 +1777,7 @@ function enrichWakeContextSnapshot(input: { payload: Record | null; }) { const { contextSnapshot, reason, source, triggerDetail, payload } = input; - const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]); + const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]) ?? readNonEmptyString(payload?.["taskId"]); const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); @@ -3419,7 +3419,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null }, ): Promise { - const issueId = readNonEmptyString(context.issueId); + const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const contextProjectId = readNonEmptyString(context.projectId); const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); const issueProjectRef = issueId @@ -8606,11 +8606,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let projectId = readNonEmptyString(enrichedContextSnapshot.projectId); if (!projectId && issueId) { - projectId = await db - .select({ projectId: issues.projectId }) + // Look up by either UUID or identifier (e.g. "ENV-13"), but always scope + // by companyId so a row from another tenant can never be returned even + // when identifiers collide across companies. Guard the UUID arm because + // issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …) + // would fail with an invalid-input-syntax cast error before the OR is + // evaluated. + const lookupIsUuid = isUuidLike(issueId); + const idMatch = lookupIsUuid + ? 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 }) .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0]?.projectId ?? null); + .where(and(eq(issues.companyId, agent.companyId), idMatch)) + .then((rows) => rows[0] ?? null); + if (resolvedIssue) { + projectId = resolvedIssue.projectId ?? null; + // Canonicalize context to the UUID so downstream lookups always use UUID + if (resolvedIssue.id !== issueId) { + issueId = resolvedIssue.id; + enrichedContextSnapshot.issueId = issueId; + if (readNonEmptyString(enrichedContextSnapshot.taskId)) { + enrichedContextSnapshot.taskId = issueId; + } + } + } + } + // Propagate projectId into context so resolveWorkspaceForRun can bind the + // project workspace even when context.projectId wasn't set by the caller. + if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { + enrichedContextSnapshot.projectId = projectId; } const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, { From 03ad5c5bea50e3d89e2b8153cbd11c932c6ae298 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 15 May 2026 08:54:55 -0500 Subject: [PATCH 04/53] [codex] Add issue document locking (#6009) ## Thinking Path > - Paperclip orchestrates AI-agent companies through company-scoped issues, comments, and issue documents. > - Issue documents are the durable place where plans, handoffs, and other work artifacts are revised over time. > - Some documents need to be preserved as operator-approved snapshots while agents continue working on the same issue. > - Without document locking, a later board or agent write can overwrite the document key that reviewers expected to remain stable. > - This pull request adds board-managed issue document locks and makes agent writes to locked keys create a derived document instead of mutating the locked document. > - The benefit is safer document handoffs: approved or frozen issue documents stay immutable until the board explicitly unlocks them. ## What Changed - Added `locked_at`, `locked_by_agent_id`, and `locked_by_user_id` document fields plus migration `0085_tranquil_the_executioner.sql`. - Added document lock/unlock service behavior, route endpoints, activity events, and locked-document write protections. - Made agent document writes to locked keys create a new derived key such as `plan-2` rather than overwriting the locked document. - Surfaced lock state through shared issue document types, UI API methods, document header lock controls, and activity formatting. - Added server and UI tests for lock/unlock behavior, locked document immutability, and UI action visibility. - Updated `doc/SPEC-implementation.md` with the V1 document lock contract and endpoints. ## Verification - `git rebase public-gh/master` completed cleanly after committing the branch changes. - `git diff --check` passed before commit. - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/documents-service.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts ui/src/components/IssueDocumentsSection.test.tsx ui/src/components/IssueContinuationHandoff.test.tsx ui/src/lib/document-revisions.test.ts` passed: 5 files, 32 tests. ## Risks - Medium risk because this changes the document persistence contract and adds a migration. - The migration uses `ADD COLUMN IF NOT EXISTS` and guarded foreign-key creation so it remains safe for users who may have already applied an earlier copy of the migration. - Locked documents intentionally reject board edits/deletes/restores until unlocked; any existing workflows that expected direct overwrite need to unlock first. - Agent writes to locked keys now create derived documents, which may create extra issue documents when agents retry locked writes. ## Model Used - OpenAI Codex coding agent based on GPT-5, with tool use and local code execution in the Paperclip worktree. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/SPEC-implementation.md | 6 + .../0085_tranquil_the_executioner.sql | 8 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/documents.ts | 3 + packages/plugins/sdk/src/testing.ts | 3 + packages/shared/src/types/issue.ts | 3 + .../src/__tests__/documents-service.test.ts | 81 ++++++ ...ue-agent-mutation-ownership-routes.test.ts | 1 + server/src/routes/issues.ts | 94 ++++++ server/src/services/documents.ts | 267 +++++++++++++++++- ui/src/api/issues.ts | 4 + .../IssueContinuationHandoff.test.tsx | 3 + .../components/IssueDocumentsSection.test.tsx | 104 +++++++ ui/src/components/IssueDocumentsSection.tsx | 102 +++++-- ui/src/lib/activity-format.ts | 12 +- ui/src/lib/document-revisions.test.ts | 3 + ui/src/pages/IssueDetail.tsx | 1 + ui/storybook/fixtures/paperclipData.ts | 9 + 18 files changed, 684 insertions(+), 27 deletions(-) create mode 100644 packages/db/src/migrations/0085_tranquil_the_executioner.sql diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 45f2e15b..0f7152f9 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -376,6 +376,10 @@ Operational policy: - `created_by_user_id` uuid/text fk null - `updated_by_agent_id` uuid fk null - `updated_by_user_id` uuid/text fk null + - `locked_at` timestamptz null + - `locked_by_agent_id` uuid fk null + - `locked_by_user_id` uuid/text fk null + - Locked documents are immutable until unlocked. Board operators can lock/unlock; agent writes to a locked key create a new issue document with a derived key instead of overwriting the locked document. - `document_revisions` stores append-only history: - `id` uuid pk - `company_id` uuid fk not null @@ -524,6 +528,8 @@ All endpoints are under `/api` and return JSON. - `GET /issues/:issueId/documents` - `GET /issues/:issueId/documents/:key` - `PUT /issues/:issueId/documents/:key` +- `POST /issues/:issueId/documents/:key/lock` +- `POST /issues/:issueId/documents/:key/unlock` - `GET /issues/:issueId/documents/:key/revisions` - `DELETE /issues/:issueId/documents/:key` - `POST /issues/:issueId/checkout` diff --git a/packages/db/src/migrations/0085_tranquil_the_executioner.sql b/packages/db/src/migrations/0085_tranquil_the_executioner.sql new file mode 100644 index 00000000..5dc6e3cb --- /dev/null +++ b/packages/db/src/migrations/0085_tranquil_the_executioner.sql @@ -0,0 +1,8 @@ +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_user_id" text;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'documents_locked_by_agent_id_agents_id_fk') THEN + ALTER TABLE "documents" ADD CONSTRAINT "documents_locked_by_agent_id_agents_id_fk" FOREIGN KEY ("locked_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9d124c7d..953d0a09 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -596,6 +596,13 @@ "when": 1778355326070, "tag": "0084_issue_recovery_actions", "breakpoints": true + }, + { + "idx": 85, + "version": "7", + "when": 1778787362162, + "tag": "0085_tranquil_the_executioner", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts index 1dae7e1d..9b56c287 100644 --- a/packages/db/src/schema/documents.ts +++ b/packages/db/src/schema/documents.ts @@ -16,6 +16,9 @@ export const documents = pgTable( createdByUserId: text("created_by_user_id"), updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), updatedByUserId: text("updated_by_user_id"), + lockedAt: timestamp("locked_at", { withTimezone: true }), + lockedByAgentId: uuid("locked_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + lockedByUserId: text("locked_by_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index ce2e2d40..20f18e3f 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1604,6 +1604,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { createdByUserId: existing?.createdByUserId ?? null, updatedByAgentId: null, updatedByUserId: null, + lockedAt: existing?.lockedAt ?? null, + lockedByAgentId: existing?.lockedByAgentId ?? null, + lockedByUserId: existing?.lockedByUserId ?? null, createdAt: existing?.createdAt ?? now, updatedAt: now, body: input.body, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7bbdde36..d822aa0f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -96,6 +96,9 @@ export interface IssueDocumentSummary { createdByUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; + lockedAt: Date | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; createdAt: Date; updatedAt: Date; } diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index 12658845..44a6d3b1 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -112,4 +112,85 @@ describeEmbeddedPostgres("documentService system issue documents", () => { body: "# Handoff", })); }); + + it("locks and unlocks issue documents", async () => { + const { issueId } = await createIssueWithDocuments(); + + const locked = await svc.lockIssueDocument({ + issueId, + key: "plan", + lockedByUserId: "board-user", + }); + + expect(locked.changed).toBe(true); + expect(locked.document.lockedAt).toBeInstanceOf(Date); + expect(locked.document.lockedByUserId).toBe("board-user"); + + await expect(svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Updated plan", + baseRevisionId: locked.document.latestRevisionId, + createdByUserId: "board-user", + })).rejects.toMatchObject({ + status: 409, + message: "Document is locked", + }); + + const unlocked = await svc.unlockIssueDocument(issueId, "plan"); + expect(unlocked.changed).toBe(true); + expect(unlocked.document.lockedAt).toBeNull(); + + const updated = await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Updated plan", + baseRevisionId: unlocked.document.latestRevisionId, + createdByUserId: "board-user", + }); + + expect(updated.created).toBe(false); + expect(updated.document.body).toBe("# Updated plan"); + }); + + it("creates a new document instead of updating a locked document when requested", async () => { + const { issueId } = await createIssueWithDocuments(); + const locked = await svc.lockIssueDocument({ + issueId, + key: "plan", + lockedByUserId: "board-user", + }); + + const fallback = await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Agent replacement plan", + baseRevisionId: locked.document.latestRevisionId, + lockedDocumentStrategy: "create_new_document", + }); + + expect(fallback.created).toBe(true); + expect(fallback.document.key).toBe("plan-2"); + expect(fallback.document.body).toBe("# Agent replacement plan"); + expect("redirectedFromLockedDocument" in fallback ? fallback.redirectedFromLockedDocument : null) + .toEqual({ id: locked.document.id, key: "plan" }); + + const originalPlan = await svc.getIssueDocumentByKey(issueId, "plan"); + expect(originalPlan).toEqual(expect.objectContaining({ + body: "# Plan", + lockedAt: expect.any(Date), + })); + + const newPlan = await svc.getIssueDocumentByKey(issueId, "plan-2"); + expect(newPlan).toEqual(expect.objectContaining({ + body: "# Agent replacement plan", + lockedAt: null, + })); + }); }); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index a4d1d9f7..407592d5 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -410,6 +410,7 @@ describe("agent issue mutation checkout ownership", () => { key: "plan", createdByAgentId: ownerAgentId, createdByRunId: ownerRunId, + lockedDocumentStrategy: "create_new_document", }), ); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b0f61244..5de602a1 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2032,8 +2032,11 @@ export function issueRoutes( createdByAgentId: actor.agentId ?? null, createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByRunId: actor.runId ?? null, + lockedDocumentStrategy: req.actor.type === "agent" ? "create_new_document" : "conflict", }); const doc = result.document; + const redirectedFromLockedDocument = + "redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null; await issueReferencesSvc.syncDocument(doc.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); @@ -2053,6 +2056,7 @@ export function issueRoutes( title: doc.title, format: doc.format, revisionNumber: doc.latestRevisionNumber, + redirectedFromLockedDocument, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), @@ -2086,6 +2090,96 @@ export function issueRoutes( res.status(result.created ? 201 : 200).json(doc); }); + router.post("/issues/:id/documents/:key/lock", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const actor = getActorInfo(req); + const result = await documentsSvc.lockIssueDocument({ + issueId: issue.id, + key: keyParsed.data, + lockedByAgentId: actor.agentId ?? null, + lockedByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + + if (result.changed) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_locked", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + title: result.document.title, + lockedAt: result.document.lockedAt, + }, + }); + } + + res.json(result.document); + }); + + router.post("/issues/:id/documents/:key/unlock", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const actor = getActorInfo(req); + const result = await documentsSvc.unlockIssueDocument(issue.id, keyParsed.data); + + if (result.changed) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_unlocked", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + title: result.document.title, + }, + }); + } + + res.json(result.document); + }); + router.get("/issues/:id/documents/:key/revisions", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 5c5ee747..dc322397 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -17,6 +17,20 @@ function isUniqueViolation(error: unknown): boolean { return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505"; } +function nextAvailableDocumentKey(sourceKey: string, existingKeys: string[]) { + const usedKeys = new Set(existingKeys); + for (let index = 2; index < 1000; index += 1) { + const suffix = `-${index}`; + const baseMaxLength = 64 - suffix.length; + const base = sourceKey.slice(0, baseMaxLength).replace(/[-_]+$/g, "") || "document"; + const candidate = `${base}${suffix}`; + if (!usedKeys.has(candidate) && issueDocumentKeySchema.safeParse(candidate).success) { + return candidate; + } + } + throw conflict("Unable to choose a new document key for locked document", { key: sourceKey }); +} + export function extractLegacyPlanBody(description: string | null | undefined) { if (!description) return null; const match = /\s*([\s\S]*?)\s*<\/plan>/i.exec(description); @@ -40,6 +54,9 @@ function mapIssueDocumentRow( createdByUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; + lockedAt: Date | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; createdAt: Date; updatedAt: Date; }, @@ -59,6 +76,9 @@ function mapIssueDocumentRow( createdByUserId: row.createdByUserId, updatedByAgentId: row.updatedByAgentId, updatedByUserId: row.updatedByUserId, + lockedAt: row.lockedAt, + lockedByAgentId: row.lockedByAgentId, + lockedByUserId: row.lockedByUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -78,6 +98,9 @@ const issueDocumentSelect = { createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, + lockedAt: documents.lockedAt, + lockedByAgentId: documents.lockedByAgentId, + lockedByUserId: documents.lockedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }; @@ -179,6 +202,7 @@ export function documentService(db: Db) { createdByAgentId?: string | null; createdByUserId?: string | null; createdByRunId?: string | null; + lockedDocumentStrategy?: "conflict" | "create_new_document"; }) => { const key = normalizeDocumentKey(input.key); const issue = await db @@ -188,8 +212,10 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); - try { - return await db.transaction(async (tx) => { + const maxAttempts = input.lockedDocumentStrategy === "create_new_document" ? 3 : 1; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await db.transaction(async (tx) => { const now = new Date(); const existing = await tx .select({ @@ -206,6 +232,9 @@ export function documentService(db: Db) { createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, + lockedAt: documents.lockedAt, + lockedByAgentId: documents.lockedByAgentId, + lockedByUserId: documents.lockedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) @@ -215,6 +244,102 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (existing) { + if (existing.lockedAt) { + if (input.lockedDocumentStrategy === "create_new_document") { + const issueDocumentKeys = await tx + .select({ key: issueDocuments.key }) + .from(issueDocuments) + .where(eq(issueDocuments.issueId, issue.id)); + const fallbackKey = nextAvailableDocumentKey(key, issueDocumentKeys.map((row) => row.key)); + + const [document] = await tx + .insert(documents) + .values({ + companyId: issue.companyId, + title: input.title ?? null, + format: input.format, + latestBody: input.body, + latestRevisionId: null, + latestRevisionNumber: 1, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + updatedByAgentId: input.createdByAgentId ?? null, + updatedByUserId: input.createdByUserId ?? null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: now, + updatedAt: now, + }) + .returning(); + + const [revision] = await tx + .insert(documentRevisions) + .values({ + companyId: issue.companyId, + documentId: document.id, + revisionNumber: 1, + title: input.title ?? null, + format: input.format, + body: input.body, + changeSummary: input.changeSummary ?? null, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + createdByRunId: input.createdByRunId ?? null, + createdAt: now, + }) + .returning(); + + await tx + .update(documents) + .set({ latestRevisionId: revision.id }) + .where(eq(documents.id, document.id)); + + await tx.insert(issueDocuments).values({ + companyId: issue.companyId, + issueId: issue.id, + documentId: document.id, + key: fallbackKey, + createdAt: now, + updatedAt: now, + }); + + return { + created: true as const, + redirectedFromLockedDocument: { + id: existing.id, + key: existing.key, + }, + document: { + id: document.id, + companyId: issue.companyId, + issueId: issue.id, + key: fallbackKey, + title: document.title, + format: document.format, + body: document.latestBody, + latestRevisionId: revision.id, + latestRevisionNumber: 1, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }, + }; + } + + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } + if (!input.baseRevisionId) { throw conflict("Document update requires baseRevisionId", { currentRevisionId: existing.latestRevisionId, @@ -274,6 +399,9 @@ export function documentService(db: Db) { latestRevisionNumber: nextRevisionNumber, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, + lockedAt: existing.lockedAt, + lockedByAgentId: existing.lockedByAgentId, + lockedByUserId: existing.lockedByUserId, updatedAt: now, }, }; @@ -296,6 +424,9 @@ export function documentService(db: Db) { createdByUserId: input.createdByUserId ?? null, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, createdAt: now, updatedAt: now, }) @@ -348,17 +479,26 @@ export function documentService(db: Db) { createdByUserId: document.createdByUserId, updatedByAgentId: document.updatedByAgentId, updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, }, }; - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw conflict("Document key already exists on this issue", { key }); + }); + } catch (error) { + if (isUniqueViolation(error)) { + if (input.lockedDocumentStrategy === "create_new_document" && attempt < maxAttempts - 1) { + continue; + } + throw conflict("Document key already exists on this issue", { key }); + } + throw error; } - throw error; } + + throw conflict("Unable to choose a new document key for locked document", { key }); }, restoreIssueDocumentRevision: async (input: { @@ -378,6 +518,13 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) throw notFound("Document not found"); + if (existing.lockedAt) { + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } const revision = await tx .select({ @@ -455,6 +602,105 @@ export function documentService(db: Db) { }); }, + lockIssueDocument: async (input: { + issueId: string; + key: string; + lockedByAgentId?: string | null; + lockedByUserId?: string | null; + }) => { + const key = normalizeDocumentKey(input.key); + return db.transaction(async (tx) => { + const existing = await tx + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, key))) + .then((rows) => rows[0] ?? null); + + if (!existing) throw notFound("Document not found"); + if (existing.lockedAt) { + return { + changed: false as const, + document: mapIssueDocumentRow(existing, true), + }; + } + + const now = new Date(); + await tx + .update(documents) + .set({ + lockedAt: now, + lockedByAgentId: input.lockedByAgentId ?? null, + lockedByUserId: input.lockedByUserId ?? null, + updatedAt: now, + }) + .where(eq(documents.id, existing.id)); + + await tx + .update(issueDocuments) + .set({ updatedAt: now }) + .where(eq(issueDocuments.documentId, existing.id)); + + return { + changed: true as const, + document: { + ...mapIssueDocumentRow(existing, true), + lockedAt: now, + lockedByAgentId: input.lockedByAgentId ?? null, + lockedByUserId: input.lockedByUserId ?? null, + updatedAt: now, + }, + }; + }); + }, + + unlockIssueDocument: async (issueId: string, rawKey: string) => { + const key = normalizeDocumentKey(rawKey); + return db.transaction(async (tx) => { + const existing = await tx + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) + .then((rows) => rows[0] ?? null); + + if (!existing) throw notFound("Document not found"); + if (!existing.lockedAt) { + return { + changed: false as const, + document: mapIssueDocumentRow(existing, true), + }; + } + + const now = new Date(); + await tx + .update(documents) + .set({ + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }) + .where(eq(documents.id, existing.id)); + + await tx + .update(issueDocuments) + .set({ updatedAt: now }) + .where(eq(issueDocuments.documentId, existing.id)); + + return { + changed: true as const, + document: { + ...mapIssueDocumentRow(existing, true), + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }, + }; + }); + }, + deleteIssueDocument: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); return db.transaction(async (tx) => { @@ -466,6 +712,13 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) return null; + if (existing.lockedAt) { + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id)); await tx.delete(documents).where(eq(documents.id, existing.id)); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 389699a4..0bf061d6 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -259,6 +259,10 @@ export const issuesApi = { getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => api.put(`/issues/${id}/documents/${encodeURIComponent(key)}`, data), + lockDocument: (id: string, key: string) => + api.post(`/issues/${id}/documents/${encodeURIComponent(key)}/lock`, {}), + unlockDocument: (id: string, key: string) => + api.post(`/issues/${id}/documents/${encodeURIComponent(key)}/unlock`, {}), listDocumentRevisions: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`), restoreDocumentRevision: (id: string, key: string, revisionId: string) => diff --git a/ui/src/components/IssueContinuationHandoff.test.tsx b/ui/src/components/IssueContinuationHandoff.test.tsx index 80e7a3d5..78278569 100644 --- a/ui/src/components/IssueContinuationHandoff.test.tsx +++ b/ui/src/components/IssueContinuationHandoff.test.tsx @@ -38,6 +38,9 @@ function createHandoffDocument(): IssueDocument { createdByUserId: null, updatedByAgentId: "agent-1", updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, createdAt: new Date("2026-04-19T12:00:00.000Z"), updatedAt: new Date("2026-04-19T12:05:00.000Z"), }; diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 31a6e76a..0ce5cb66 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -15,6 +15,8 @@ const mockIssuesApi = vi.hoisted(() => ({ listDocumentRevisions: vi.fn(), restoreDocumentRevision: vi.fn(), upsertDocument: vi.fn(), + lockDocument: vi.fn(), + unlockDocument: vi.fn(), deleteDocument: vi.fn(), getDocument: vi.fn(), })); @@ -178,6 +180,9 @@ function createIssueDocument(overrides: Partial = {}): IssueDocum createdByUserId: "user-1", updatedByAgentId: null, updatedByUserId: "user-1", + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, createdAt: new Date("2026-03-31T12:00:00.000Z"), updatedAt: new Date("2026-03-31T12:05:00.000Z"), ...overrides, @@ -306,6 +311,105 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("locks documents from the document header action", async () => { + const unlockedDocument = createIssueDocument({ + body: "Draftable plan body", + lockedAt: null, + }); + const lockedDocument = createIssueDocument({ + body: "Draftable plan body", + lockedAt: new Date("2026-03-31T12:06:00.000Z"), + lockedByUserId: "user-1", + updatedAt: new Date("2026-03-31T12:06:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments + .mockResolvedValueOnce([unlockedDocument]) + .mockResolvedValue([lockedDocument]); + mockIssuesApi.lockDocument.mockResolvedValue(lockedDocument); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const lockButton = container.querySelector('button[title="Lock document"]'); + expect(lockButton).toBeTruthy(); + + await act(async () => { + lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(mockIssuesApi.lockDocument).toHaveBeenCalledWith("issue-1", "plan"); + expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("hides direct edit and delete actions for locked documents", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([ + createIssueDocument({ + body: "Locked plan body", + lockedAt: new Date("2026-03-31T12:06:00.000Z"), + lockedByUserId: "user-1", + }), + ]); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Locked plan body"); + expect(container.textContent).not.toContain("Edit document"); + expect(container.textContent).not.toContain("Delete document"); + expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("shows the restored document body immediately after a revision restore", async () => { const blankLatestDocument = createIssueDocument({ body: "", diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 717fbd0a..303123b9 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -32,7 +32,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react"; import { DocumentDiffModal } from "./DocumentDiffModal"; type DraftState = { @@ -91,6 +91,10 @@ function isDocumentConflictError(error: unknown) { return error instanceof ApiError && error.status === 409; } +function isLockedDocumentError(error: unknown) { + return error instanceof ApiError && error.status === 409 && error.message === "Document is locked"; +} + function downloadDocumentFile(key: string, body: string) { const blob = new Blob([body], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -128,6 +132,9 @@ function toDocumentSummary(document: IssueDocument) { createdByUserId: document.createdByUserId, updatedByAgentId: document.updatedByAgentId, updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, }; @@ -136,6 +143,7 @@ function toDocumentSummary(document: IssueDocument) { export function IssueDocumentsSection({ issue, canDeleteDocuments, + canManageDocumentLocks = false, feedbackVotes = [], feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, @@ -146,6 +154,7 @@ export function IssueDocumentsSection({ }: { issue: Issue; canDeleteDocuments: boolean; + canManageDocumentLocks?: boolean; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -279,6 +288,22 @@ export function IssueDocumentsSection({ }, }); + const setDocumentLock = useMutation({ + mutationFn: ({ key, locked }: { key: string; locked: boolean }) => + locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key), + onSuccess: (document) => { + syncDocumentCaches(document); + setDraft((current) => current?.key === document.key ? null : current); + setDocumentConflict((current) => current?.key === document.key ? null : current); + resetAutosaveState(); + setError(null); + invalidateIssueDocuments(); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : "Failed to update document lock"); + }, + }); + const sortedDocuments = useMemo(() => { return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { if (a.key === "plan" && b.key !== "plan") return -1; @@ -442,6 +467,12 @@ export function IssueDocumentsSection({ } return true; } catch (err) { + if (isLockedDocumentError(err)) { + setError("Document is locked. Unlock it before editing."); + resetAutosaveState(); + invalidateIssueDocuments(); + return false; + } if (isDocumentConflictError(err)) { try { const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey); @@ -563,6 +594,15 @@ export function IssueDocumentsSection({ setError(null); }, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]); + const toggleDocumentLock = useCallback((doc: IssueDocument, locked: boolean) => { + if (!canManageDocumentLocks || setDocumentLock.isPending) return; + if (locked && (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft))) { + setError("Save or cancel local changes before changing the document lock."); + return; + } + setDocumentLock.mutate({ key: doc.key, locked }); + }, [canManageDocumentLocks, documentConflict, draft, setDocumentLock]); + const handleDraftBlur = async (event: React.FocusEvent) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; if (autosaveDebounceRef.current) { @@ -789,8 +829,9 @@ export function IssueDocumentsSection({
{sortedDocuments.map((doc) => { - const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; - const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; + const isLocked = Boolean(doc.lockedAt); + const activeDraft = !isLocked && draft?.key === doc.key && !draft.isNew ? draft : null; + const activeConflict = !isLocked && documentConflict?.key === doc.key ? documentConflict : null; const isFolded = foldedDocumentKeys.includes(doc.key); const rawRevisionHistory = getDocumentRevisions(doc.key); const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory); @@ -809,6 +850,7 @@ export function IssueDocumentsSection({ const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt; const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); + const lockActionPending = setDocumentLock.isPending && setDocumentLock.variables?.key === doc.key; return (
{displayedTitle}

}
+ {canManageDocumentLocks ? ( + + ) : isLocked ? ( + + + + ) : null} - + {!isLocked ? ( + + ) : null}
diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index d1cd2b48..c2490e8b 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -32,6 +32,8 @@ const ACTIVITY_ROW_VERBS: Record = { "issue.attachment_removed": "removed attachment from", "issue.document_created": "created document for", "issue.document_updated": "updated document on", + "issue.document_locked": "locked document on", + "issue.document_unlocked": "unlocked document on", "issue.document_deleted": "deleted document from", "issue.monitor_scheduled": "scheduled monitor on", "issue.monitor_triggered": "triggered monitor for", @@ -88,6 +90,8 @@ const ISSUE_ACTIVITY_LABELS: Record = { "issue.attachment_removed": "removed an attachment", "issue.document_created": "created a document", "issue.document_updated": "updated a document", + "issue.document_locked": "locked a document", + "issue.document_unlocked": "unlocked a document", "issue.document_deleted": "deleted a document", "issue.monitor_scheduled": "scheduled a monitor", "issue.monitor_triggered": "triggered a monitor", @@ -333,7 +337,13 @@ export function formatIssueActivityAction( } if ( - (action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") && + ( + action === "issue.document_created" || + action === "issue.document_updated" || + action === "issue.document_locked" || + action === "issue.document_unlocked" || + action === "issue.document_deleted" + ) && details ) { const key = typeof details.key === "string" ? details.key : "document"; diff --git a/ui/src/lib/document-revisions.test.ts b/ui/src/lib/document-revisions.test.ts index 53e18713..ac9821d0 100644 --- a/ui/src/lib/document-revisions.test.ts +++ b/ui/src/lib/document-revisions.test.ts @@ -17,6 +17,9 @@ function createDocument(overrides: Partial = {}): IssueDocument { createdByUserId: null, updatedByAgentId: "agent-1", updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, createdAt: new Date("2026-04-10T15:00:00.000Z"), updatedAt: new Date("2026-04-10T16:00:00.000Z"), ...overrides, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 95156440..b8e71f2e 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3709,6 +3709,7 @@ export function IssueDetail() { Date: Fri, 15 May 2026 08:55:54 -0500 Subject: [PATCH 05/53] [codex] Refresh issue documents from live updates (#6005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The board UI keeps issue pages responsive by subscribing to live activity events and invalidating TanStack Query caches. > - Issue documents are first-class issue artifacts, but document activity events were not refreshing the document list, active document, or revision caches. > - That meant a user could update a document on an issue and another open board would keep showing stale document content until a page reload. > - This pull request routes issue document activity events through the same live invalidation path used for issue and comment updates. > - The benefit is that issue document changes become visible automatically on active issue pages without forcing operators to reload the board. ## What Changed - Added live-update cache invalidation for `issue.document_created`, `issue.document_updated`, `issue.document_restored`, and `issue.document_deleted` activity events. - Invalidated the issue document list, the active document cache, and document revisions for both issue id and identifier references when the activity payload includes a document key. - Added regression coverage for document activity events so active issue pages refetch document caches without inactive-only behavior. - Simplified the document invalidation test mock after Greptile feedback so the test only models the cache reads it actually uses. ## Verification - `git rebase public-gh/master` reported the branch was up to date after fetching `public-gh/master`. - `pnpm run preflight:workspace-links` passed. - `pnpm exec vitest run --project @paperclipai/ui ui/src/context/LiveUpdatesProvider.test.ts` passed: 1 file, 16 tests. - `pnpm --filter @paperclipai/ui typecheck` passed. - PR checks passed on `eecd19f7b0355490f17314c94bffa06aada8f9e3`: `policy`, `verify`, `e2e`, all 4 serialized server shards, `Canary Dry Run`, `security/snyk`, and `Greptile Review`. - Greptile completed with `5/5` confidence and no unresolved review threads. ## Risks - Low risk. This expands cache invalidation for existing live activity events and does not change API contracts, database schema, migrations, or document persistence behavior. - No migrations or `pnpm-lock.yaml` changes are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled local repository workflow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] No visible UI layout changed; screenshots are not applicable for live cache invalidation behavior - [x] No documentation changes were needed for this internal UI cache refresh fix - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/context/LiveUpdatesProvider.test.ts | 88 ++++++++++++++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 17 +++++ 2 files changed, 105 insertions(+) diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 5aa7654e..673b7032 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -100,6 +100,94 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refreshes issue document caches when a document activity event arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.document_updated", + actorType: "agent", + actorId: "agent-1", + details: { + identifier: "PAP-9403", + key: "plan", + }, + }, + { userId: "user-1", agentId: null }, + { pathname: "/PAP/issues/PAP-9403", isForegrounded: true }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.detail("issue-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.documents("issue-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.document("issue-1", "plan"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.documents("PAP-9403"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.document("PAP-9403", "plan"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.documents("issue-1"), + refetchType: "inactive", + }); + }); + + it("refreshes all issue document caches when document activity omits a document key", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.document_deleted", + actorType: "agent", + actorId: "agent-1", + details: null, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.documents("issue-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document", "issue-1"], + }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-revisions", "issue-1"], + }); + }); + it("keeps self-authored comment events from refetching the active issue tree", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 99f3a2fc..783537e0 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -408,6 +408,12 @@ async function hydrateVisibleIssueComment( } const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); +const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([ + "issue.document_created", + "issue.document_updated", + "issue.document_restored", + "issue.document_deleted", +]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -685,6 +691,17 @@ function invalidateActivityQueries( if (action === "issue.comment_added") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions }); } + if (action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action)) { + const documentKey = readString(details?.key); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref), ...invalidationOptions }); + if (documentKey) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.document(ref, documentKey), ...invalidationOptions }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.documentRevisions(ref, documentKey), ...invalidationOptions }); + } else { + queryClient.invalidateQueries({ queryKey: ["issues", "document", ref], ...invalidationOptions }); + queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions }); + } + } if (action?.startsWith("issue.thread_interaction_")) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions }); } From eb38b226c289551202d6405db03e998619e9049b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 15 May 2026 10:20:02 -0500 Subject: [PATCH 06/53] Fix LLM Wiki package and migration validation (#6010) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Plugins extend the control plane with optional capabilities such as LLM Wiki. > - LLM Wiki needs its package assets and plugin-owned database migrations to work when installed from the packaged plugin. > - The bundled spaces migration used validation-hostile dynamic SQL, and the packaged plugin could omit non-dist runtime assets. > - This pull request makes the LLM Wiki package include its required assets and cuts the spaces migration over to explicit, idempotent SQL that passes the production plugin database validator. > - The benefit is a simpler plugin install path that validates and applies the bundled LLM Wiki migrations without adding plugin-specific legacy handling to Paperclip core. ## What Changed - Added the LLM Wiki package asset allowlist so agents, migrations, skills, templates, dist output, and README are included when packaged. - Renamed the bootstrap `.gitignore` template to `gitignore.template` and updated the runtime lookup so package tooling does not drop the hidden template file. - Relaxed plugin migration validation to allow namespace-scoped `INSERT`/`UPDATE` backfills and `CREATE INDEX` statements while continuing to reject destructive or cross-namespace SQL. - Replaced the LLM Wiki spaces migration's dynamic constraint-drop DO block with explicit `DROP CONSTRAINT IF EXISTS` statements. - Replaced fragile regex-source dispatch in SQL reference extraction with explicit capture-group descriptors. - Added regression coverage that applies the bundled LLM Wiki migrations through the production validator and checks the expected constraints. ## Verification - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/plugin-database.test.ts --pool=forks --poolOptions.forks.isolate=true` - `pnpm --filter @paperclipai/plugin-llm-wiki build` - `git diff --check` - Confirmed `pnpm-lock.yaml` is not included in the branch diff. ## Risks - Low migration risk for current users: LLM Wiki spaces are new, so this intentionally cuts over the plugin migration instead of adding legacy handling in core. - Validator behavior is broader than before, but still requires fully qualified plugin namespace targets, blocks deletes/destructive DDL, and keeps public table access read-only and allowlisted. > Checked [`ROADMAP.md`](ROADMAP.md); this is a targeted plugin packaging/migration fix and does not duplicate planned core feature work. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 based coding agent, tool-enabled local repo access, reasoning mode managed by the Paperclip/Codex runtime. Exact context window was not surfaced in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../plugin-llm-wiki/migrations/003_spaces.sql | 39 +---- packages/plugins/plugin-llm-wiki/package.json | 8 + .../plugins/plugin-llm-wiki/src/templates.ts | 2 +- .../{.gitignore => gitignore.template} | 0 server/src/__tests__/plugin-database.test.ts | 139 +++++++++++++++++- server/src/services/plugin-database.ts | 56 ++++++- 6 files changed, 203 insertions(+), 41 deletions(-) rename packages/plugins/plugin-llm-wiki/templates/{.gitignore => gitignore.template} (100%) diff --git a/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql b/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql index e9c4a0fd..17d900a8 100644 --- a/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql +++ b/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql @@ -140,37 +140,14 @@ ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ALTER COLUMN ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ALTER COLUMN space_id SET NOT NULL; ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ALTER COLUMN space_id SET NOT NULL; -DO $$ -DECLARE - target record; - constraint_name text; -BEGIN - FOR target IN - SELECT * FROM (VALUES - ('wiki_pages', ARRAY['company_id', 'wiki_id', 'path']::text[]), - ('paperclip_distillation_cursors', ARRAY['company_id', 'wiki_id', 'source_scope', 'scope_key', 'source_kind']::text[]), - ('paperclip_distillation_work_items', ARRAY['company_id', 'wiki_id', 'idempotency_key']::text[]), - ('paperclip_page_bindings', ARRAY['company_id', 'wiki_id', 'page_path']::text[]) - ) AS targets(table_name, column_names) - LOOP - FOR constraint_name IN - SELECT c.conname - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE n.nspname = 'plugin_llm_wiki_8f50da974f' - AND t.relname = target.table_name - AND c.contype = 'u' - AND ( - SELECT array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] - FROM unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) - JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum - ) = target.column_names - LOOP - EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', 'plugin_llm_wiki_8f50da974f', target.table_name, constraint_name); - END LOOP; - END LOOP; -END $$; +ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages + DROP CONSTRAINT IF EXISTS wiki_pages_company_id_wiki_id_path_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors + DROP CONSTRAINT IF EXISTS paperclip_distillation_cursor_company_id_wiki_id_source_sco_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items + DROP CONSTRAINT IF EXISTS paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings + DROP CONSTRAINT IF EXISTS paperclip_page_bindings_company_id_wiki_id_page_path_key; ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages DROP CONSTRAINT IF EXISTS wiki_pages_company_wiki_space_path_key; diff --git a/packages/plugins/plugin-llm-wiki/package.json b/packages/plugins/plugin-llm-wiki/package.json index 4461c845..6f175b4a 100644 --- a/packages/plugins/plugin-llm-wiki/package.json +++ b/packages/plugins/plugin-llm-wiki/package.json @@ -4,6 +4,14 @@ "type": "module", "private": true, "description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.", + "files": [ + "agents", + "dist", + "migrations", + "skills", + "templates", + "README.md" + ], "scripts": { "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "node ./esbuild.config.mjs", diff --git a/packages/plugins/plugin-llm-wiki/src/templates.ts b/packages/plugins/plugin-llm-wiki/src/templates.ts index c07bf6f6..3eb5284a 100644 --- a/packages/plugins/plugin-llm-wiki/src/templates.ts +++ b/packages/plugins/plugin-llm-wiki/src/templates.ts @@ -46,7 +46,7 @@ export const DEFAULT_AGENT_INSTRUCTIONS = DEFAULT_AGENT_INSTRUCTION_FILES["AGENT export const DEFAULT_IDEA = templateFile("IDEA.md"); export const DEFAULT_INDEX = templateFile("wiki/index.md"); export const DEFAULT_LOG = templateFile("wiki/log.md"); -export const DEFAULT_GITIGNORE = templateFile(".gitignore"); +export const DEFAULT_GITIGNORE = templateFile("gitignore.template"); export const QUERY_PROMPT = `Answer from the LLM Wiki using the installed wiki-query skill. diff --git a/packages/plugins/plugin-llm-wiki/templates/.gitignore b/packages/plugins/plugin-llm-wiki/templates/gitignore.template similarity index 100% rename from packages/plugins/plugin-llm-wiki/templates/.gitignore rename to packages/plugins/plugin-llm-wiki/templates/gitignore.template diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 5ae677e5..d69fd34a 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -30,6 +30,7 @@ import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const multiMigrationPluginKey = "paperclip.dbfixture"; +const llmWikiPluginKey = "paperclipai.plugin-llm-wiki"; if (!embeddedPostgresSupport.supported) { console.warn( @@ -48,6 +49,63 @@ describe("plugin database SQL validation", () => { ).not.toThrow(); }); + it("allows qualified index creation and namespace-scoped migration backfills", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE INDEX IF NOT EXISTS rows_issue_idx ON plugin_test.rows (issue_id)", + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginMigrationStatement( + ` + WITH source_rows AS ( + SELECT id FROM plugin_test.rows + ) + INSERT INTO plugin_test.row_copies (id) + SELECT id FROM source_rows + ON CONFLICT (id) DO NOTHING + `, + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginMigrationStatement( + ` + UPDATE plugin_test.rows r + SET copied_from_id = s.id + FROM plugin_test.source_rows s + WHERE s.id = r.id + `, + "plugin_test", + ) + ).not.toThrow(); + }); + + it("keeps migration backfill writes scoped to the plugin namespace", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE TABLE rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))", + "plugin_test", + ["issues"], + ) + ).toThrow(/fully qualified/i); + expect(() => + validatePluginMigrationStatement( + "WITH source_rows AS (SELECT id FROM plugin_test.rows) INSERT INTO public.issues (id) SELECT id FROM source_rows", + "plugin_test", + ["issues"], + ) + ).toThrow(/public/i); + expect(() => + validatePluginMigrationStatement( + "UPDATE public.issues SET title = 'bad'", + "plugin_test", + ["issues"], + ) + ).toThrow(/public/i); + }); + it("rejects migrations that create public objects", () => { expect(() => validatePluginMigrationStatement( @@ -137,10 +195,11 @@ describeEmbeddedPostgres("plugin database namespaces", () => { }, 20_000); afterEach(async () => { - for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) { + for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey, llmWikiPluginKey]) { const namespace = derivePluginDatabaseNamespace(pluginKey); await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`)); } + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${derivePluginDatabaseNamespace(llmWikiPluginKey, "llm_wiki")}" CASCADE`)); await db.delete(pluginMigrations); await db.delete(pluginDatabaseNamespaces); await db.delete(plugins); @@ -164,6 +223,29 @@ describeEmbeddedPostgres("plugin database namespaces", () => { return packageRoot; } + function llmWikiManifest(): PaperclipPluginManifestV1 { + return { + id: llmWikiPluginKey, + apiVersion: 1, + version: "0.1.0", + displayName: "LLM Wiki", + description: "Local-file LLM Wiki plugin.", + author: "Paperclip", + categories: ["automation", "ui"], + capabilities: [ + "database.namespace.migrate", + "database.namespace.read", + "database.namespace.write", + ], + entrypoints: { worker: "./dist/worker.js" }, + database: { + namespaceSlug: "llm_wiki", + migrationsDir: "migrations", + coreReadTables: ["companies", "issues", "projects", "agents"], + }, + }; + } + async function createInstallablePluginPackage( pluginManifest: PaperclipPluginManifestV1, migrationSql: string, @@ -252,6 +334,61 @@ describeEmbeddedPostgres("plugin database namespaces", () => { expect(migrations).toHaveLength(2); }); + it("applies the bundled LLM Wiki migrations through the production validator", async () => { + const pluginManifest = llmWikiManifest(); + const repoRoot = path.basename(process.cwd()) === "server" ? path.resolve(process.cwd(), "..") : process.cwd(); + const packageRoot = path.join(repoRoot, "packages", "plugins", "plugin-llm-wiki"); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id, pluginManifest.database?.namespaceSlug); + const pluginId = await installPluginRecord(pluginManifest); + + await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot); + + const migrations = await db + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied"))); + expect(migrations.map((migration) => migration.migrationKey)).toEqual([ + "001_llm_wiki.sql", + "002_paperclip_distillation.sql", + "003_spaces.sql", + ]); + + const constraintRows = Array.from( + await db.execute( + sql<{ table_name: string; conname: string; columns: string[] }>` + SELECT t.relname AS table_name, c.conname, array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum + WHERE c.connamespace = ${namespace}::regnamespace AND c.contype = 'u' + GROUP BY t.relname, c.conname + ORDER BY t.relname, c.conname + `, + ) as Iterable<{ table_name: string; conname: string; columns: string[] }>, + ); + const constraints = constraintRows.map((row) => row.conname); + const uniqueColumnSets = new Set( + constraintRows.map((row) => `${row.table_name}:${row.columns.join(",")}`), + ); + expect(constraints).toEqual( + expect.arrayContaining([ + "wiki_pages_company_wiki_space_path_key", + "distillation_cursors_company_wiki_space_scope_key", + "distillation_work_items_company_wiki_space_idempotency_key", + "page_bindings_company_wiki_space_page_path_key", + ]), + ); + expect(constraints).not.toContain("wiki_pages_company_id_wiki_id_path_key"); + expect(constraints).not.toContain("paperclip_distillation_cursor_company_id_wiki_id_source_sco_key"); + expect(constraints).not.toContain("paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key"); + expect(constraints).not.toContain("paperclip_page_bindings_company_id_wiki_id_page_path_key"); + expect(uniqueColumnSets).not.toContain("wiki_pages:company_id,wiki_id,path"); + expect(uniqueColumnSets).not.toContain("paperclip_distillation_cursors:company_id,wiki_id,source_scope,scope_key,source_kind"); + expect(uniqueColumnSets).not.toContain("paperclip_distillation_work_items:company_id,wiki_id,idempotency_key"); + expect(uniqueColumnSets).not.toContain("paperclip_page_bindings:company_id,wiki_id,page_path"); + }); + it("applies migrations once and allows whitelisted core joins at runtime", async () => { const pluginManifest = manifest(); const namespace = derivePluginDatabaseNamespace(pluginManifest.id); diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts index e6811702..d6b4cdda 100644 --- a/server/src/services/plugin-database.ts +++ b/server/src/services/plugin-database.ts @@ -19,6 +19,9 @@ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const MAX_POSTGRES_IDENTIFIER_LENGTH = 63; type SqlRef = { schema: string; table: string; keyword: string }; +type QualifiedRefPattern = + | { pattern: RegExp; groups: "keyword-schema-table" } + | { pattern: RegExp; groups: "schema-table"; keyword: string }; export type PluginDatabaseRuntimeResult> = { rows?: T[]; @@ -123,14 +126,29 @@ function normaliseSql(input: string): string { function extractQualifiedRefs(statement: string): SqlRef[] { const refs: SqlRef[] = []; - const patterns = [ - /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, - /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + const patterns: QualifiedRefPattern[] = [ + { + pattern: /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "keyword-schema-table", + }, + { + pattern: /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "keyword-schema-table", + }, + { + pattern: /\bcreate\s+(?:unique\s+)?index(?:\s+concurrently)?\s+(?:if\s+not\s+exists\s+)?"?[A-Za-z_][A-Za-z0-9_]*"?\s+on\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "schema-table", + keyword: "create index", + }, ]; - for (const pattern of patterns) { + for (const { pattern, ...mapping } of patterns) { for (const match of statement.matchAll(pattern)) { - refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! }); + if (mapping.groups === "keyword-schema-table") { + refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! }); + } else { + refs.push({ keyword: mapping.keyword, schema: match[1]!, table: match[2]! }); + } } } return refs; @@ -182,9 +200,16 @@ export function validatePluginMigrationStatement( throw new Error("Destructive plugin migrations are not allowed in Phase 1"); } - const ddlAllowed = /^(create|alter|comment)\b/.test(normalized); - if (!ddlAllowed) { - throw new Error("Plugin migrations may contain DDL statements only"); + if (/\bdelete\s+from\b/.test(normalized)) { + throw new Error("Plugin migrations cannot delete data"); + } + + const ddlOrBackfillAllowed = + /^(create|alter|comment)\b/.test(normalized) || + /^(insert\s+into|update)\b/.test(normalized) || + (normalized.startsWith("with ") && /\b(insert\s+into|update)\b/.test(normalized)); + if (!ddlOrBackfillAllowed) { + throw new Error("Plugin migrations may contain DDL or namespace-scoped backfill statements only"); } const refs = extractQualifiedRefs(statement); @@ -192,6 +217,21 @@ export function validatePluginMigrationStatement( throw new Error("Plugin migration objects must use fully qualified schema names"); } + const objectRefKeywords = new Set([ + "alter table", + "create index", + "create table", + "create view", + "drop table", + "into", + "truncate table", + "update", + ]); + const hasQualifiedObjectRef = refs.some((ref) => objectRefKeywords.has(ref.keyword)); + if (!hasQualifiedObjectRef && !normalized.startsWith("comment ")) { + throw new Error("Plugin migration objects must use fully qualified schema names"); + } + const allowedCoreReadTables = new Set(coreReadTables); for (const ref of refs) { if (ref.schema === namespace) continue; From d5ba3348a9da7edb64b6c874b0e20a81aaf2ffe0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 15 May 2026 10:31:01 -0500 Subject: [PATCH 07/53] [codex] Add UI i18n runtime packages (#6058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI-agent companies through a web control plane. > - The UI i18n slice needs `i18next` and `react-i18next` available as runtime packages before the implementation PR can stay focused on code changes. > - The implementation PR should not mix package declaration work with Greptile-driven i18n code feedback. > - This pull request isolates only the package manifest additions requested by the maintainer. > - The benefit is a tiny dependency-declaration PR that can be reviewed and merged independently before rebasing the i18n implementation PR. ## What Changed - Added `i18next` to `ui/package.json` dependencies. - Added `react-i18next` to `ui/package.json` dependencies. - Intentionally did not change `pnpm-lock.yaml`, matching the repository policy that PRs do not commit lockfile changes. ## Verification - `node -e "JSON.parse(require('fs').readFileSync('ui/package.json','utf8')); console.log('ui/package.json valid JSON')"` - `git diff --name-only public-gh/master...HEAD` shows only `ui/package.json`. - `npm view i18next version` -> `26.2.0`. - `npm view i18next@26.1.0 version` -> `26.1.0`. - `npm view react-i18next version` -> `17.0.8`. - `npm view react-i18next@17.0.7 version` -> `17.0.7`. - Did not run `pnpm install --frozen-lockfile` because this PR intentionally changes only `ui/package.json` and leaves lockfile handling to the repository's separate lockfile workflow. ## Risks - CI jobs that run `pnpm install --frozen-lockfile` may fail until the repository lockfile workflow handles these package declarations. - Low behavioral risk: this PR does not import or execute the packages and changes no runtime code. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning mode. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run the applicable local validation for this manifest-only change - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why screenshots are not applicable because there is no runtime UI change - [x] I have updated relevant documentation to reflect my changes, or confirmed no docs changed because behavior/commands did not change - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- ui/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/package.json b/ui/package.json index 65caa25e..7286053f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -52,12 +52,14 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "hermes-paperclip-adapter": "^0.2.0", + "i18next": "^26.1.0", "lexical": "0.35.0", "lucide-react": "^0.574.0", "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.7", "react-markdown": "^10.1.0", "react-router-dom": "^7.1.5", "remark-gfm": "^4.0.1", From 7e1a27c8eca4e1c3788242713efef2f561018a77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 10:32:10 -0500 Subject: [PATCH 08/53] chore(lockfile): refresh pnpm-lock.yaml (#6062) Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml. Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56f18c65..2042be3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,6 +791,9 @@ importers: hermes-paperclip-adapter: specifier: ^0.2.0 version: 0.2.0 + i18next: + specifier: ^26.1.0 + version: 26.2.0(typescript@5.9.3) lexical: specifier: 0.35.0 version: 0.35.0 @@ -809,6 +812,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^17.0.7 + version: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -1247,6 +1253,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -5312,6 +5322,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -5341,6 +5354,14 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + i18next@26.2.0: + resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -6308,6 +6329,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -7084,6 +7121,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -7945,6 +7986,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -12303,6 +12346,10 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} http-cache-semantics@4.2.0: @@ -12352,6 +12399,10 @@ snapshots: ms: 2.1.3 optional: true + i18next@26.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -13681,6 +13732,17 @@ snapshots: dependencies: react: 19.2.4 + react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.2.0(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -14680,6 +14742,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: From afb73ba553eb3f741048bfb619a596165e3f6537 Mon Sep 17 00:00:00 2001 From: Emad Ibrahim Date: Fri, 15 May 2026 11:53:09 -0400 Subject: [PATCH 09/53] Scale issue kanban board for high-volume columns (#5309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is a control plane for autonomous AI-agent companies, and the board UI needs to keep operator visibility clear as company work scales. > - The involved subsystem is the Issues page board mode, specifically the Kanban rendering path for issue status columns. > - The current board keeps the classic Kanban model, but high-volume columns can become tall, slow, and hard to scan when hundreds of issues are loaded. > - We explored alternatives and chose the conservative Scaled Kanban direction: preserve status lanes and drag/drop, but bound visible cards and collapse low-signal lanes. > - This pull request adds UI-only density controls and high-volume defaults rather than introducing schema or API changes. > - The benefit is a board that remains usable with large issue inventories while keeping active workflow lanes visible. ## What Changed - Added scaled Kanban behavior with compact cards, collapsed cold-lane rails, per-column visible-card limits, and per-column "show more" reveal controls. - Added persisted board density preferences to the Issues page view state, scoped through the existing company-specific localStorage path. - Added board toolbar controls for compact cards, collapsed cold lanes, cards-per-column page size (`10`, `25`, `50`), and density reset. - Added a design spec and implementation plan under `doc/plans/`. - Added focused Vitest coverage for `KanbanBoard` and `IssuesList` high-volume board behavior. ## Verification - `pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx` — pass, 35 tests. - `pnpm -r typecheck` — pass. - `pnpm build` — pass before the upstream merge; not rerun after docs/assets cleanup. - `curl -fsS http://127.0.0.1:3100/api/health` — pass against restarted local dev server after applying pending migration `0078_white_darwin.sql`. - `pnpm test:run` — previously failed in unrelated Cursor remote-sandbox server tests: - `server/src/__tests__/cursor-local-adapter-environment.test.ts`: expected probe status `pass`, received `fail`. - `server/src/__tests__/cursor-local-execute.test.ts`: two remote sandbox execution cases exited `127` instead of `0`. Local dev server for manual UI inspection: `http://127.0.0.1:3100`. Screenshots were captured for review and attached in the PR thread rather than committed to source. ## Risks - Low schema/API risk: this is UI-only and uses the existing issue data path. - Board users may need to notice the new density controls if they want to override high-volume defaults. - Collapsed cold lanes remain valid drop targets, so status moves can happen without expanding the destination lane. - Very large remote columns can still hit the existing 200-item per-column query cap; this PR improves rendering, not server-side board pagination. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with repository tool use, shell execution, local test/build execution, and inline implementation planning. No subagents were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .gitignore | 1 + .../2026-05-05-scaled-kanban-board-design.md | 90 +++++++ doc/plans/2026-05-05-scaled-kanban-board.md | 250 ++++++++++++++++++ ui/src/components/IssuesList.test.tsx | 116 +++++++- ui/src/components/IssuesList.tsx | 140 +++++++++- ui/src/components/KanbanBoard.test.tsx | 198 ++++++++++++++ ui/src/components/KanbanBoard.tsx | 141 ++++++++-- 7 files changed, 904 insertions(+), 32 deletions(-) create mode 100644 doc/plans/2026-05-05-scaled-kanban-board-design.md create mode 100644 doc/plans/2026-05-05-scaled-kanban-board.md create mode 100644 ui/src/components/KanbanBoard.test.tsx diff --git a/.gitignore b/.gitignore index b9578487..d594a1f1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ tests/e2e/playwright-report/ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ .superset/ +.superpowers/ .claude/worktrees/ diff --git a/doc/plans/2026-05-05-scaled-kanban-board-design.md b/doc/plans/2026-05-05-scaled-kanban-board-design.md new file mode 100644 index 00000000..068b5995 --- /dev/null +++ b/doc/plans/2026-05-05-scaled-kanban-board-design.md @@ -0,0 +1,90 @@ +# Scaled Kanban Board Design + +Date: 2026-05-05 +Branch: `feat/scaled-kanban-board` + +## Context + +The Issues page currently supports list and board modes. List mode already has grouping, sorting, filtering, nested parent/child rows, deferred row rendering, and incremental render limits. Board mode uses classic status columns with draggable cards. It fetches per-status board data, but the current UI still presents each lane as an unbounded stack of cards, which becomes tall and heavy when a company has hundreds of issues. + +The goal is to keep the Kanban mental model while making high-volume boards usable. This is a UI-first change. It should not introduce schema changes or new API contracts in the first pass. + +## Problem + +When Paperclip has many issues, board columns get too tall and slow. The operator loses the ability to scan the board quickly, and rendering or dragging through long columns becomes unpleasant. The first version should solve this by reducing the number of visible cards per column and by collapsing low-signal columns, not by replacing Kanban with a different inventory surface. + +## Design + +Board mode remains status-column based. Each column shows its total issue count, a bounded set of visible cards, and a local affordance to reveal more cards in that column. The board should keep active workflow lanes expanded by default and collapse cold or noisy lanes once issue volume is high. + +Default high-volume behavior activates when the filtered board has more than 100 issues: + +- Compact cards are used by default. +- `backlog`, `done`, and `cancelled` auto-collapse to narrow rails. +- `todo`, `in_progress`, `in_review`, and `blocked` remain expanded by default. +- Each expanded column renders an initial 10 cards by default. +- The user can choose a page size of 10, 25, or 50 cards per column. +- The user can reveal one additional page at a time in each column without changing other columns. +- Drag and drop continues to work for visible cards. + +The toolbar should expose compact controls for: + +- toggling compact cards +- hiding or showing cold lanes +- choosing cards per column +- resetting board density to defaults + +These preferences should persist through the existing issue view-state/localStorage mechanism and remain scoped by company. + +## Component Shape + +`IssuesList` remains the owner of issue board view state. It should store board-density preferences alongside the existing issue view state, including compact card preference, cold-lane mode, and cards-per-column page size. + +`KanbanBoard` receives board tuning props from `IssuesList` and delegates per-lane display to `KanbanColumn`. + +`KanbanColumn` owns only local presentation mechanics for a lane: + +- whether the lane is rendered as an expanded column or collapsed rail +- how many cards are currently visible in that lane +- the local "show more" action + +`KanbanCard` gets a compact variant. The compact card should still show the issue identifier, title, live state, priority, and assignee when available, but with tighter spacing and fewer vertical affordances. + +## Data Flow + +The first implementation uses the current issue data already available to board mode. No database, shared type, or route change is required. + +Column totals are computed from the in-memory filtered board issues. If a column reaches the existing remote board query cap, the existing warning remains the truth source that more filtering may be required. + +Future server-side column pagination can be added later if the UI-only version is not enough for very large instances. + +## Error Handling + +This feature should not introduce new network errors. Existing issue loading and update errors continue to surface through the Issues page. + +For drag and drop: + +- Moving a visible card keeps the current optimistic behavior. +- Hidden cards remain hidden until revealed. +- A collapsed lane rail is a valid drop target. Dropping onto it moves the issue to that status and keeps the lane collapsed. + +## Testing + +Focused tests should cover: + +- board mode passes density preferences into `KanbanBoard` +- columns render only the initial visible card count +- "show more" reveals more cards in a single column +- high-volume cold lanes render as collapsed rails by default +- compact cards preserve identifier/title/live/priority/assignee signals +- drag/drop status updates still call `onUpdateIssue` + +Manual verification should include opening the Issues board with a large fixture or mocked issue set and confirming that columns remain usable with hundreds of issues. + +## Out of Scope + +- Server-side per-column pagination +- New issue schema fields +- Replacing Kanban with a dense table or action-only board +- Changing issue status semantics +- Broad visual redesign of the Issues page diff --git a/doc/plans/2026-05-05-scaled-kanban-board.md b/doc/plans/2026-05-05-scaled-kanban-board.md new file mode 100644 index 00000000..c36e00f7 --- /dev/null +++ b/doc/plans/2026-05-05-scaled-kanban-board.md @@ -0,0 +1,250 @@ +# Scaled Kanban Board Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Issues Kanban board usable with hundreds of issues by adding compact high-volume rendering, collapsed cold lanes, and per-column reveal controls. + +**Architecture:** Keep the change UI-only. `IssuesList` owns persisted board density preferences in existing company-scoped view state, while `KanbanBoard` owns lane rendering, card density, collapsed rails, and per-column "show more" state. + +**Tech Stack:** React 19, TypeScript, Vite, Vitest/jsdom, `@dnd-kit/core`, `@dnd-kit/sortable`, Tailwind utility classes. + +--- + +## File Structure + +- Modify `ui/src/components/IssuesList.tsx`: extend `IssueViewState`, derive high-volume board preferences, add toolbar controls, pass props into `KanbanBoard`. +- Modify `ui/src/components/KanbanBoard.tsx`: add compact cards, collapsed rail lanes, visible-card limits, and per-column reveal behavior. +- Create `ui/src/components/KanbanBoard.test.tsx`: focused tests for high-volume behavior and drag/drop update callback. +- Modify `ui/src/components/IssuesList.test.tsx`: update the mocked `KanbanBoard` expectations for new props. +- Keep `doc/plans/2026-05-05-scaled-kanban-board-design.md` as the design source of truth. + +## Task 1: Add Kanban Board Scaling Mechanics + +**Files:** +- Modify: `ui/src/components/KanbanBoard.tsx` +- Create: `ui/src/components/KanbanBoard.test.tsx` + +- [ ] **Step 1: Write focused tests** + +Create `ui/src/components/KanbanBoard.test.tsx` with tests that render 60 todo issues and assert: + +```tsx +renderBoard({ issues: createIssues(60, "todo"), compactCards: true, initialVisibleCount: 10, revealIncrement: 10 }); +expect(container.textContent).toContain("Showing 10 of 60"); +expect(container.textContent).toContain("Show 10 more"); +``` + +Also test collapsed rails: + +```tsx +renderBoard({ issues: createIssues(3, "done"), collapsedStatuses: ["done"] }); +expect(container.textContent).toContain("Done"); +expect(container.textContent).toContain("3"); +expect(container.textContent).not.toContain("Issue 1"); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx +``` + +Expected: fail because `KanbanBoard.test.tsx` is new and the props/behavior do not exist. + +- [ ] **Step 3: Implement minimal board behavior** + +In `KanbanBoard.tsx`, add exported constants: + +```ts +export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100; +export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; +export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE = 10; +export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const; +``` + +Extend props: + +```ts +compactCards?: boolean; +collapsedStatuses?: string[]; +initialVisibleCount?: number; +revealIncrement?: number; +``` + +Add per-status visible-count state keyed by status. Expanded columns render `issues.slice(0, visibleCount)` and show a button when hidden issues remain. Collapsed columns render a narrow droppable rail with status icon, label, and count, but no cards. + +Reset per-status visible-count state when `initialVisibleCount` or `revealIncrement` changes so choosing a smaller cards-per-column preset does not leave a column expanded past the newly selected page size. + +- [ ] **Step 4: Preserve drag/drop** + +Keep `DndContext`, `SortableContext`, and `handleDragEnd` status detection. Because collapsed rails use `useDroppable({ id: status })`, dropping a visible card onto a rail continues to resolve `targetStatus` through the existing status-id branch. + +- [ ] **Step 5: Run focused test** + +Run: + +```bash +pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/KanbanBoard.tsx ui/src/components/KanbanBoard.test.tsx +git commit -m "Scale kanban board columns" +``` + +## Task 2: Wire Board Density State Into IssuesList + +**Files:** +- Modify: `ui/src/components/IssuesList.tsx` +- Modify: `ui/src/components/IssuesList.test.tsx` + +- [ ] **Step 1: Write/update tests** + +In `IssuesList.test.tsx`, update the `KanbanBoard` mock to capture: + +```ts +compactCards?: boolean; +collapsedStatuses?: string[]; +initialVisibleCount?: number; +revealIncrement?: number; +``` + +Add a test that stores board mode in localStorage, renders more than 100 issues, and expects: + +```ts +expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + compactCards: true, + collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]), + initialVisibleCount: 10, + revealIncrement: 10, +})); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx +``` + +Expected: fail because `IssuesList` does not pass the new props yet. + +- [ ] **Step 3: Add persisted board density preferences** + +Extend `IssueViewState`: + +```ts +boardCardDensity: "auto" | "compact" | "comfortable"; +boardColdLaneMode: "auto" | "collapsed" | "expanded"; +boardColumnPageSize: 10 | 25 | 50; +``` + +Default the density modes to `"auto"` and page size to `10`. Derive: + +```ts +const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; +const boardCompactCards = viewState.boardCardDensity === "compact" + || (viewState.boardCardDensity === "auto" && boardHighVolume); +const boardCollapsedStatuses = viewState.boardColdLaneMode === "collapsed" + || (viewState.boardColdLaneMode === "auto" && boardHighVolume) + ? [...KANBAN_COLD_STATUSES] + : []; +``` + +- [ ] **Step 4: Add toolbar controls** + +When `viewState.viewMode === "board"`, add small outline/icon buttons near the existing view controls: + +```tsx + + + + +``` + +Use lucide icons already available or import `ChevronsDownUp`, `PanelTopClose`, and `RotateCcw`. + +- [ ] **Step 5: Pass board props** + +Update the `KanbanBoard` call: + +```tsx + +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/components/IssuesList.tsx ui/src/components/IssuesList.test.tsx +git commit -m "Wire issue board density controls" +``` + +## Task 3: Verification And PR Prep + +**Files:** +- Verify existing changes only. + +- [ ] **Step 1: Run targeted UI tests** + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 2: Run broader cheap test path** + +```bash +pnpm test +``` + +Expected: pass. + +- [ ] **Step 3: Check worktree** + +```bash +git status --short +``` + +Expected: only intentional changes before committing, or clean after final commit. + +- [ ] **Step 4: Prepare PR** + +Read `.github/PULL_REQUEST_TEMPLATE.md` and use it for the PR body. Include: + +- design spec path +- scaled Kanban behavior summary +- test commands and results +- Model Used section with the current Codex model details available in this session + +## Self-Review + +- Spec coverage: The plan covers compact high-volume board cards, collapsed cold lanes, cards-per-column presets, per-column reveal controls, persisted board preferences, current API reuse, and focused tests. +- Placeholder scan: No unresolved markers or unspecified implementation placeholders remain. +- Type consistency: The plan consistently uses `boardCardDensity`, `boardColdLaneMode`, `boardColumnPageSize`, `compactCards`, `collapsedStatuses`, `initialVisibleCount`, and `revealIncrement`. diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 9b09c2e5..c5e3e1a3 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -123,7 +123,17 @@ vi.mock("./IssueRow", () => ({ })); vi.mock("./KanbanBoard", () => ({ - KanbanBoard: (props: { issues: Issue[] }) => { + KANBAN_BOARD_HIGH_VOLUME_THRESHOLD: 100, + KANBAN_COLD_STATUSES: ["backlog", "done", "cancelled"], + KANBAN_COLUMN_DEFAULT_PAGE_SIZE: 10, + KANBAN_COLUMN_PAGE_SIZE_OPTIONS: [10, 25, 50], + KanbanBoard: (props: { + issues: Issue[]; + compactCards?: boolean; + collapsedStatuses?: string[]; + initialVisibleCount?: number; + revealIncrement?: number; + }) => { mockKanbanBoard(props); return (
@@ -1011,6 +1021,110 @@ describe("IssuesList", () => { }); }); + it("uses compact cards and collapsed cold lanes for high-volume boards", async () => { + localStorage.setItem( + "paperclip:test-issues:company-1", + JSON.stringify({ viewMode: "board" }), + ); + + const backlogIssues = Array.from({ length: 101 }, (_, index) => + createIssue({ + id: `issue-backlog-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Backlog issue ${index + 1}`, + status: "backlog", + }), + ); + + mockIssuesApi.list.mockImplementation((_companyId, filters) => { + if (filters?.status === "backlog") return Promise.resolve(backlogIssues); + return Promise.resolve([]); + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + compactCards: true, + collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]), + initialVisibleCount: 10, + revealIncrement: 10, + })); + }); + + act(() => { + root.unmount(); + }); + }); + + it("lets board users choose the per-column page size", async () => { + localStorage.setItem( + "paperclip:test-issues:company-1", + JSON.stringify({ viewMode: "board" }), + ); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + initialVisibleCount: 10, + revealIncrement: 10, + })); + }); + + const pageSizeButton = Array.from(container.querySelectorAll("button")).find((button) => + button.getAttribute("title") === "Cards per column", + ); + expect(pageSizeButton).toBeTruthy(); + + act(() => { + pageSizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + let option25: HTMLButtonElement | undefined; + await waitForAssertion(() => { + option25 = Array.from(document.body.querySelectorAll("button")).find((button) => + button.textContent?.includes("25 per column"), + ); + expect(option25).toBeTruthy(); + }); + + act(() => { + option25?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + initialVisibleCount: 25, + revealIncrement: 25, + })); + }); + + expect(localStorage.getItem("paperclip:test-issues:company-1")).toContain("\"boardColumnPageSize\":25"); + + act(() => { + root.unmount(); + }); + }); + it("shows a refinement hint when a board column hits its server cap", async () => { localStorage.setItem( "paperclip:test-issues:company-1", diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 0d62b295..84cdee0f 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -60,8 +60,15 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2 } from "lucide-react"; -import { KanbanBoard } from "./KanbanBoard"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2, ChevronsDownUp, PanelTopClose, RotateCcw, ListCollapse } from "lucide-react"; +import { + KanbanBoard, + KANBAN_BOARD_HIGH_VOLUME_THRESHOLD, + KANBAN_COLD_STATUSES, + KANBAN_COLUMN_DEFAULT_PAGE_SIZE, + KANBAN_COLUMN_PAGE_SIZE_OPTIONS, + type KanbanColumnPageSize, +} from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; @@ -110,6 +117,9 @@ const progressSegmentClasses: Record = { /* ── View state ── */ export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow"; +export type BoardCardDensity = "auto" | "compact" | "comfortable"; +export type BoardColdLaneMode = "auto" | "collapsed" | "expanded"; +export type BoardColumnPageSize = KanbanColumnPageSize; export type IssueViewState = IssueFilterState & { sortField: IssueSortField; @@ -119,6 +129,9 @@ export type IssueViewState = IssueFilterState & { nestingEnabled: boolean; collapsedGroups: string[]; collapsedParents: string[]; + boardCardDensity: BoardCardDensity; + boardColdLaneMode: BoardColdLaneMode; + boardColumnPageSize: BoardColumnPageSize; }; const defaultViewState: IssueViewState = { @@ -130,14 +143,38 @@ const defaultViewState: IssueViewState = { nestingEnabled: true, collapsedGroups: [], collapsedParents: [], + boardCardDensity: "auto", + boardColdLaneMode: "auto", + boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE, }; +function normalizeBoardCardDensity(value: unknown): BoardCardDensity { + return value === "compact" || value === "comfortable" || value === "auto" ? value : "auto"; +} + +function normalizeBoardColdLaneMode(value: unknown): BoardColdLaneMode { + return value === "collapsed" || value === "expanded" || value === "auto" ? value : "auto"; +} + +function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize { + return KANBAN_COLUMN_PAGE_SIZE_OPTIONS.includes(value as BoardColumnPageSize) + ? value as BoardColumnPageSize + : KANBAN_COLUMN_DEFAULT_PAGE_SIZE; +} + function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); if (raw) { const parsed = JSON.parse(raw); - return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) }; + return { + ...defaultViewState, + ...parsed, + ...normalizeIssueFilterState(parsed), + boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity), + boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode), + boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize), + }; } } catch { /* ignore */ } return { ...defaultViewState }; @@ -1007,6 +1044,22 @@ export function IssuesList({ }); const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); + const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; + const boardCompactCards = + viewState.boardCardDensity === "compact" + || (viewState.boardCardDensity === "auto" && boardHighVolume); + const boardCollapsedStatuses = useMemo( + () => + viewState.boardColdLaneMode === "collapsed" + || (viewState.boardColdLaneMode === "auto" && boardHighVolume) + ? [...KANBAN_COLD_STATUSES] + : [], + [boardHighVolume, viewState.boardColdLaneMode], + ); + const boardDensityCustomized = + viewState.boardCardDensity !== "auto" + || viewState.boardColdLaneMode !== "auto" + || viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE; const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { @@ -1324,6 +1377,83 @@ export function IssuesList({ )} + {viewState.viewMode === "board" && ( + <> + + + + + + + +
+ {KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => ( + + ))} +
+
+
+ + + )} + ) : ( diff --git a/ui/src/components/KanbanBoard.test.tsx b/ui/src/components/KanbanBoard.test.tsx new file mode 100644 index 00000000..3f1007a2 --- /dev/null +++ b/ui/src/components/KanbanBoard.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue, IssueStatus } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { KanbanBoard, resolveKanbanTargetStatus } from "./KanbanBoard"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + disableIssueQuicklook: _disableIssueQuicklook, + ...props + }: React.AnchorHTMLAttributes & { + to: string; + disableIssueQuicklook?: boolean; + }) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(index: number, status: IssueStatus): Issue { + return { + id: `issue-${status}-${index}`, + identifier: `PAP-${index}`, + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: `Issue ${index}`, + description: null, + status, + workMode: "standard", + priority: "medium", + assigneeAgentId: index === 1 ? "agent-1" : null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: index, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-05-05T00:00:00.000Z"), + updatedAt: new Date("2026-05-05T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + lastActivityAt: null, + isUnreadForMe: false, + }; +} + +function createIssues(count: number, status: IssueStatus): Issue[] { + return Array.from({ length: count }, (_, index) => createIssue(index + 1, status)); +} + +function renderBoard( + props: Partial> & { issues: Issue[] }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const render = (nextProps: Partial> & { issues: Issue[] }) => { + act(() => { + root.render( + , + ); + }); + }; + + render(props); + + return { container, root, render }; +} + +describe("KanbanBoard", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("limits visible cards and reveals more cards per column", () => { + const { container } = renderBoard({ + issues: createIssues(60, "todo"), + compactCards: true, + initialVisibleCount: 50, + revealIncrement: 50, + }); + + expect(container.textContent).toContain("Showing 50 of 60"); + expect(container.textContent).toContain("Show 10 more"); + expect(container.textContent).toContain("Issue 50"); + expect(container.textContent).not.toContain("Issue 51"); + + const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 10 more"), + ); + expect(showMoreButton).toBeTruthy(); + + act(() => { + showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Issue 60"); + expect(container.textContent).not.toContain("Show 10 more"); + }); + + it("resets visible counts when the column page size changes", () => { + const issues = createIssues(60, "todo"); + const { container, render } = renderBoard({ + issues, + initialVisibleCount: 50, + revealIncrement: 50, + }); + + const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 10 more"), + ); + expect(showMoreButton).toBeTruthy(); + + act(() => { + showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Issue 60"); + + render({ + issues, + initialVisibleCount: 10, + revealIncrement: 10, + }); + + expect(container.textContent).toContain("Showing 10 of 60"); + expect(container.textContent).toContain("Show 10 more"); + expect(container.textContent).toContain("Issue 10"); + expect(container.textContent).not.toContain("Issue 11"); + }); + + it("renders collapsed statuses as rails without cards", () => { + const { container } = renderBoard({ + issues: createIssues(3, "done"), + collapsedStatuses: ["done"], + }); + + expect(container.textContent).toContain("Done"); + expect(container.textContent).toContain("3"); + expect(container.textContent).not.toContain("Issue 1"); + }); + + it("keeps core issue signals in compact cards", () => { + const { container } = renderBoard({ + issues: createIssues(1, "todo"), + compactCards: true, + }); + + expect(container.textContent).toContain("PAP-1"); + expect(container.textContent).toContain("Issue 1"); + expect(container.textContent).toContain("Codex"); + expect(container.textContent).toContain("Live"); + }); + + it("resolves drop targets from status rails and cards", () => { + const issues = [ + createIssue(1, "todo"), + createIssue(2, "blocked"), + ]; + + expect(resolveKanbanTargetStatus("done", issues)).toBe("done"); + expect(resolveKanbanTargetStatus("issue-blocked-2", issues)).toBe("blocked"); + expect(resolveKanbanTargetStatus("missing", issues)).toBeNull(); + }); +}); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 712a67da..87e2192f 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { DndContext, @@ -20,11 +20,19 @@ import { import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; -import type { Issue } from "@paperclipai/shared"; +import type { Issue, IssueStatus } from "@paperclipai/shared"; import { AlertTriangle } from "lucide-react"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; -const boardStatuses = [ +export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100; +export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; +export type KanbanColumnPageSize = (typeof KANBAN_COLUMN_PAGE_SIZE_OPTIONS)[number]; +export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE: KanbanColumnPageSize = 10; +export const KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE; +export const KANBAN_COLUMN_REVEAL_INCREMENT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE; +export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const; + +export const boardStatuses = [ "backlog", "todo", "in_progress", @@ -32,12 +40,19 @@ const boardStatuses = [ "blocked", "done", "cancelled", -]; +] as const satisfies readonly IssueStatus[]; function statusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } +export function resolveKanbanTargetStatus(overId: string, issues: Issue[]): IssueStatus | null { + if ((boardStatuses as readonly string[]).includes(overId)) { + return overId as IssueStatus; + } + return issues.find((issue) => issue.id === overId)?.status ?? null; +} + interface Agent { id: string; name: string; @@ -47,6 +62,10 @@ interface KanbanBoardProps { issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; + compactCards?: boolean; + collapsedStatuses?: string[]; + initialVisibleCount?: number; + revealIncrement?: number; onUpdateIssue: (id: string, data: Record) => void; } @@ -57,15 +76,48 @@ function KanbanColumn({ issues, agents, liveIssueIds, + compactCards = false, + collapsed = false, + visibleCount, + revealIncrement, + onShowMore, }: { - status: string; + status: IssueStatus; issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; + compactCards?: boolean; + collapsed?: boolean; + visibleCount: number; + revealIncrement: number; + onShowMore: () => void; }) { const { setNodeRef, isOver } = useDroppable({ id: status }); const isEmpty = issues.length === 0; + const visibleIssues = collapsed ? [] : issues.slice(0, visibleCount); + const hiddenCount = Math.max(issues.length - visibleIssues.length, 0); + const nextRevealCount = Math.min(revealIncrement, hiddenCount); + + if (collapsed) { + return ( +
+ + + {statusLabel(status)} + + + {issues.length} + +
+ ); + } return (
@@ -88,19 +140,35 @@ function KanbanColumn({ isOver ? "bg-accent/40" : "bg-muted/20" }`} > + {/* Hidden cards are intentionally excluded from sort targets until revealed. */} i.id)} + items={visibleIssues.map((i) => i.id)} strategy={verticalListSortingStrategy} > - {issues.map((issue) => ( + {visibleIssues.map((issue) => ( ))} + {hiddenCount > 0 ? ( + + ) : null} + {issues.length > 0 && (hiddenCount > 0 || issues.length >= visibleCount) ? ( +

+ Showing {visibleIssues.length} of {issues.length} +

+ ) : null}
); @@ -113,11 +181,13 @@ function KanbanCard({ agents, isLive, isOverlay, + compact = false, }: { issue: Issue; agents?: Agent[]; isLive?: boolean; isOverlay?: boolean; + compact?: boolean; }) { const { attributes, @@ -144,9 +214,11 @@ function KanbanCard({ style={style} {...attributes} {...listeners} - className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${ + className={`rounded-md border bg-card cursor-grab active:cursor-grabbing transition-shadow ${ isDragging && !isOverlay ? "opacity-30" : "" - } ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`} + } ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"} ${ + compact ? "p-2" : "p-2.5" + }`} > -
+
{issue.identifier ?? issue.id.slice(0, 8)} @@ -172,14 +244,17 @@ function KanbanCard({ ) : null} {isLive && ( - - - + + + + + + {compact ? "Live" : null} )}
-

{issue.title}

-
+

{issue.title}

+
{issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); @@ -203,16 +278,26 @@ export function KanbanBoard({ issues, agents, liveIssueIds, + compactCards = false, + collapsedStatuses = [], + initialVisibleCount = KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT, + revealIncrement = KANBAN_COLUMN_REVEAL_INCREMENT, onUpdateIssue, }: KanbanBoardProps) { const [activeId, setActiveId] = useState(null); + const [visibleCountByStatus, setVisibleCountByStatus] = useState>({}); + const collapsedStatusSet = useMemo(() => new Set(collapsedStatuses), [collapsedStatuses]); + + useEffect(() => { + setVisibleCountByStatus({}); + }, [initialVisibleCount, revealIncrement]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ); const columnIssues = useMemo(() => { - const grouped: Record = {}; + const grouped: Record = {} as Record; for (const status of boardStatuses) { grouped[status] = []; } @@ -244,17 +329,7 @@ export function KanbanBoard({ // Determine target status: the "over" could be a column id (status string) // or another card's id. Find which column the "over" belongs to. - let targetStatus: string | null = null; - - if (boardStatuses.includes(over.id as string)) { - targetStatus = over.id as string; - } else { - // It's a card - find which column it's in - const targetIssue = issues.find((i) => i.id === over.id); - if (targetIssue) { - targetStatus = targetIssue.status; - } - } + const targetStatus = resolveKanbanTargetStatus(over.id as string, issues); if (targetStatus && targetStatus !== issue.status) { onUpdateIssue(issueId, { status: targetStatus }); @@ -280,12 +355,22 @@ export function KanbanBoard({ issues={columnIssues[status] ?? []} agents={agents} liveIssueIds={liveIssueIds} + compactCards={compactCards} + collapsed={collapsedStatusSet.has(status)} + visibleCount={visibleCountByStatus[status] ?? initialVisibleCount} + revealIncrement={revealIncrement} + onShowMore={() => { + setVisibleCountByStatus((current) => ({ + ...current, + [status]: (current[status] ?? initialVisibleCount) + revealIncrement, + })); + }} /> ))}
{activeIssue ? ( - + ) : null} From e2d7263b070c53394289807067a6901281f67be1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 15 May 2026 11:11:02 -0500 Subject: [PATCH 10/53] [codex] Add minimal i18next i18n foundation (#5943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI-agent companies through a web control plane. > - The UI currently renders operator-facing copy directly from React components. > - Internationalization needs a smallest-possible starting point before broader locale work can proceed. > - The package declarations for `i18next` and `react-i18next` landed separately, so this PR can stay focused on the implementation slice. > - The implementation keeps the first surface English-only and deliberately tiny while using the established `i18next` + `react-i18next` runtime. > - Future language contributions should be able to add a single locale JSON file, with validation guarding key shape, interpolation parity, suspicious payloads, and string length. > - Locale strings must remain display-only UI copy and must not flow into prompts, agent instructions, tool calls, shell commands, issue content, approvals, adapter config, or other LLM-visible control paths. ## What Changed - Initialized `i18next` behind the existing `@/i18n` boundary with fixed English resources, fallback English, no detector plugin, no backend plugin, no language picker, and no rich-text translation component. - Kept `ui/src/i18n/locales/en.json` as the English source locale and converted the validated JSON locale registry into i18next resources before app rendering. - Routed the no-companies start page title, description, and button through `t(key, { defaultValue })` while preserving unchanged rendered English copy. - Added locale validation and focused Vitest coverage for missing/extra keys, non-string leaves, interpolation parity, suspicious executable/link payloads, and length caps. - Addressed Greptile i18n review feedback: case-insensitive event-handler detection, multi-violation diagnostics, future-locale-friendly registration test, surfaced i18next init errors, and removed the redundant side-effect import. - Rebasing note: rebased onto current `public-gh/master` after the package-only PR landed; this PR no longer changes `ui/package.json` or `pnpm-lock.yaml`. ## Verification - `pnpm install --no-lockfile --ignore-scripts` to install local dependencies without reading or writing `pnpm-lock.yaml`. - `pnpm --filter @paperclipai/ui exec vitest run src/i18n/locale-validation.test.ts` -> passed, 7 tests. - `pnpm --filter @paperclipai/ui typecheck` -> passed. - `git diff --name-only public-gh/master...HEAD` shows only i18n implementation files and the touched App copy call site; no package manifest or lockfile changes remain in this PR. - Visual impact is intentionally unchanged for the touched no-companies copy because the English translations match the previous literal strings. ## Risks - Locale validation reduces prompt-injection risk, but the main safety invariant is architectural: locale strings must remain display-only and must never be used as LLM-visible control text. - This intentionally does not add non-English locales, a language picker, browser detection, HTTP/backend locale loading, server localization, adapter localization, broad copy migration, or new package scripts. - Repository-wide CI may still depend on the separate lockfile-refresh workflow for the already-merged package declaration, but this PR no longer introduces package manifest or lockfile changes itself. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning mode. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why screenshots are not applicable because there is no intended visual change - [x] I have updated relevant documentation to reflect my changes, or confirmed no docs changed because behavior/commands did not change - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/App.tsx | 12 ++- ui/src/i18n/index.ts | 26 ++++++ ui/src/i18n/locale-validation.test.ts | 102 ++++++++++++++++++++++ ui/src/i18n/locale-validation.ts | 121 ++++++++++++++++++++++++++ ui/src/i18n/locales.ts | 41 +++++++++ ui/src/i18n/locales/en.json | 9 ++ 6 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 ui/src/i18n/index.ts create mode 100644 ui/src/i18n/locale-validation.test.ts create mode 100644 ui/src/i18n/locale-validation.ts create mode 100644 ui/src/i18n/locales.ts create mode 100644 ui/src/i18n/locales/en.json diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 09161d09..0da91b9a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; import { CloudAccessGate } from "./components/CloudAccessGate"; @@ -245,16 +246,21 @@ function UnprefixedBoardRedirect() { function NoCompaniesStartPage() { const { openOnboarding } = useDialogActions(); + const { t } = useTranslation(); return (
-

Create your first company

+

+ {t("app.noCompanies.title", { defaultValue: "Create your first company" })} +

- Get started by creating a company. + {t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}

- +
diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 00000000..fb0bacd8 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,26 @@ +import i18n, { type InitOptions, type TOptions } from "i18next"; +import { initReactI18next, useTranslation as useReactI18nextTranslation } from "react-i18next"; + +import { DEFAULT_LOCALE, i18nextResources, supportedLocales } from "./locales"; + +const i18nextOptions: InitOptions = { + resources: i18nextResources, + lng: DEFAULT_LOCALE, + fallbackLng: DEFAULT_LOCALE, + supportedLngs: supportedLocales, + defaultNS: "translation", + interpolation: { escapeValue: false }, + returnObjects: false, + initAsync: false, +}; + +void i18n.use(initReactI18next).init(i18nextOptions).catch((error: unknown) => { + console.error("Failed to initialize i18next", error); +}); + +export function t(key: string, options: TOptions = {}) { + return i18n.t(key, options); +} + +export const useTranslation = useReactI18nextTranslation; +export { i18n }; diff --git a/ui/src/i18n/locale-validation.test.ts b/ui/src/i18n/locale-validation.test.ts new file mode 100644 index 00000000..ab0e4178 --- /dev/null +++ b/ui/src/i18n/locale-validation.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { t } from "."; +import en from "./locales/en.json"; +import { localeMessages } from "./locales"; +import { validateLocaleMessages } from "./locale-validation"; + +describe("locale validation", () => { + it("resolves English messages with key and default fallbacks", () => { + expect(t("app.noCompanies.title")).toBe(en.app.noCompanies.title); + expect(t("app.missing", { defaultValue: "Fallback" })).toBe("Fallback"); + expect(t("app.missing")).toBe("app.missing"); + }); + + it("accepts registered locale files", () => { + expect(Object.keys(localeMessages)).toContain("en"); + for (const [locale, messages] of Object.entries(localeMessages)) { + expect(validateLocaleMessages(messages), locale).toEqual([]); + } + }); + + it("rejects missing and extra nested keys", () => { + expect( + validateLocaleMessages({ + app: { + noCompanies: { + title: en.app.noCompanies.title, + description: en.app.noCompanies.description, + unexpected: "Unexpected", + }, + }, + }), + ).toEqual( + expect.arrayContaining([ + "app.noCompanies.newCompany is missing", + "app.noCompanies.unexpected is not defined in English", + ]), + ); + }); + + it("rejects non-string leaves", () => { + expect( + validateLocaleMessages({ + app: { + noCompanies: { + ...en.app.noCompanies, + title: ["Create your first company"], + }, + }, + }), + ).toEqual(expect.arrayContaining(["app.noCompanies.title must be a string"])); + }); + + it("requires interpolation placeholders to match English", () => { + const reference = { + message: "Invite {{name}} to {{company}}", + }; + + expect(validateLocaleMessages({ message: "Invite {{name}}" }, reference)).toEqual([ + 'message interpolation placeholders must match English exactly: expected ["company","name"], received ["name"]', + ]); + }); + + it("rejects executable, raw HTML, and unexpected link payloads not present in English", () => { + const reference = { + script: "Create company", + handler: "Create company", + js: "Create company", + data: "Create company", + url: "Create company", + html: "Create company", + }; + + expect( + validateLocaleMessages( + { + script: "", + handler: 'Create', + js: "javascript:alert(1)", + data: "data:text/html,hello", + url: "https://example.test", + html: "Create company", + }, + reference, + ), + ).toEqual( + expect.arrayContaining([ + "script contains disallowed { + expect(validateLocaleMessages({ message: "x".repeat(200) }, { message: "Short" })).toEqual([ + "message is too long: 200 characters exceeds 133", + ]); + }); +}); diff --git a/ui/src/i18n/locale-validation.ts b/ui/src/i18n/locale-validation.ts new file mode 100644 index 00000000..8450677b --- /dev/null +++ b/ui/src/i18n/locale-validation.ts @@ -0,0 +1,121 @@ +import en from "./locales/en.json"; + +const MAX_STRING_LENGTH = 2_000; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function formatPath(path: string[]) { + return path.length > 0 ? path.join(".") : ""; +} + +function interpolationPlaceholders(value: string) { + return Array.from(value.matchAll(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g), (match) => match[1]).sort(); +} + +function hasRawHtml(value: string) { + return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?>/.test(value); +} + +function hasEventHandlerAttribute(value: string) { + return /\son[A-Za-z]+\s*=/i.test(value); +} + +function urlsIn(value: string) { + return Array.from(value.matchAll(/\bhttps?:\/\/[^\s<>"')]+/gi), (match) => match[0]).sort(); +} + +function hasBlockedData(value: string, englishValue: string) { + const checks: Array<[boolean, boolean, string]> = [ + [/ candidateHas && !englishHas) + .map(([, , blockedPayload]) => blockedPayload); + + const englishUrls = new Set(urlsIn(englishValue)); + const unexpectedUrl = urlsIn(value).find((url) => !englishUrls.has(url)); + if (unexpectedUrl) blocked.push("unexpected URL"); + + return blocked; +} + +function validateString(path: string[], candidateValue: string, englishValue: string, errors: string[]) { + const candidatePlaceholders = interpolationPlaceholders(candidateValue); + const englishPlaceholders = interpolationPlaceholders(englishValue); + if (candidatePlaceholders.join("\u0000") !== englishPlaceholders.join("\u0000")) { + errors.push( + `${formatPath(path)} interpolation placeholders must match English exactly: expected ${JSON.stringify( + englishPlaceholders, + )}, received ${JSON.stringify(candidatePlaceholders)}`, + ); + } + + for (const blockedPayload of hasBlockedData(candidateValue, englishValue)) { + errors.push(`${formatPath(path)} contains disallowed ${blockedPayload}`); + } + + const relativeLimit = Math.max(englishValue.length * 4 + 64, englishValue.length + 128); + const lengthLimit = Math.min(MAX_STRING_LENGTH, relativeLimit); + if (candidateValue.length > lengthLimit) { + errors.push(`${formatPath(path)} is too long: ${candidateValue.length} characters exceeds ${lengthLimit}`); + } +} + +function validateNode(path: string[], candidate: unknown, englishReference: unknown, errors: string[]) { + if (typeof englishReference === "string") { + if (typeof candidate !== "string") { + errors.push(`${formatPath(path)} must be a string`); + return; + } + validateString(path, candidate, englishReference, errors); + return; + } + + if (!isPlainObject(englishReference)) { + errors.push(`${formatPath(path)} has unsupported English reference type`); + return; + } + + if (!isPlainObject(candidate)) { + errors.push(`${formatPath(path)} must be an object`); + return; + } + + const englishKeys = Object.keys(englishReference).sort(); + const candidateKeys = Object.keys(candidate).sort(); + const missingKeys = englishKeys.filter((key) => !candidateKeys.includes(key)); + const extraKeys = candidateKeys.filter((key) => !englishKeys.includes(key)); + + for (const key of missingKeys) { + errors.push(`${formatPath([...path, key])} is missing`); + } + for (const key of extraKeys) { + errors.push(`${formatPath([...path, key])} is not defined in English`); + } + + for (const key of englishKeys) { + if (key in candidate) { + validateNode([...path, key], candidate[key], englishReference[key], errors); + } + } +} + +export function validateLocaleMessages(candidate: unknown, englishReference: unknown = en) { + const errors: string[] = []; + validateNode([], candidate, englishReference, errors); + return errors; +} + +export function assertValidLocaleMessages(candidate: unknown, englishReference: unknown = en) { + const errors = validateLocaleMessages(candidate, englishReference); + if (errors.length > 0) { + throw new Error(`Invalid locale messages:\n${errors.join("\n")}`); + } +} diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts new file mode 100644 index 00000000..188ee2b1 --- /dev/null +++ b/ui/src/i18n/locales.ts @@ -0,0 +1,41 @@ +import type { Resource } from "i18next"; + +import { assertValidLocaleMessages } from "./locale-validation"; + +export const DEFAULT_LOCALE = "en" as const; + +const localeModules = import.meta.glob("./locales/*.json", { + eager: true, + import: "default", +}) as Record; + +export const localeMessages = Object.fromEntries( + Object.entries(localeModules).map(([path, messages]) => { + const locale = path.match(/\/([A-Za-z0-9_-]+)\.json$/)?.[1]; + if (!locale) { + throw new Error(`Invalid locale file path: ${path}`); + } + return [locale, messages]; + }), +); + +if (!(DEFAULT_LOCALE in localeMessages)) { + throw new Error(`Missing default locale messages for ${DEFAULT_LOCALE}`); +} + +for (const [locale, messages] of Object.entries(localeMessages)) { + try { + assertValidLocaleMessages(messages); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${locale} locale messages: ${message}`); + } +} + +export const supportedLocales = Object.keys(localeMessages); + +export const i18nextResources: Resource = Object.fromEntries( + Object.entries(localeMessages).map(([locale, messages]) => [locale, { translation: messages }]), +) as Resource; + +export type SupportedLocale = keyof typeof localeMessages; diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json new file mode 100644 index 00000000..9e50199b --- /dev/null +++ b/ui/src/i18n/locales/en.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Create your first company", + "description": "Get started by creating a company.", + "newCompany": "New Company" + } + } +} From 4c47eb46c3c4525325e30d3a7b793090b5bcc06b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 15 May 2026 12:49:57 -0500 Subject: [PATCH 11/53] [codex] Add multilingual issue preservation coverage (#6069) ## Thinking Path > - Paperclip orchestrates AI agents for autonomous companies. > - Agents and board operators coordinate through company-scoped issues, comments, documents, and heartbeat wake payloads. > - Chinese, Japanese, and Hindi text needs to survive the full issue lifecycle without normalization or prompt serialization damage. > - The riskiest paths are board issue creation, server issue/comment/document round-tripping, and scoped wake prompt rendering. > - This pull request adds focused regression coverage across those surfaces. > - The benefit is higher confidence that multilingual operators and agents can create, search, comment on, complete, and wake on issues using non-Latin text. ## What Changed - Added adapter-utils wake payload and prompt rendering coverage for Chinese, Japanese, and Hindi issue/comment text. - Added UI New Issue dialog coverage proving multilingual title and description text is submitted unchanged. - Added server route coverage that round-trips multilingual issue text through create, search, comments, documents, completion comments, and heartbeat context. - Addressed Greptile feedback by using a typed storage mock and splitting the server route integration path into smaller ordered assertions. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts ui/src/components/NewIssueDialog.test.tsx server/src/__tests__/multilingual-issues-routes.test.ts` - Result: 3 test files passed, 51 tests passed. ## Risks - Low risk: this PR adds regression coverage only and does not change runtime behavior. - The new server test uses embedded Postgres support and skips on unsupported hosts using the existing helper pattern. - No migrations are included. - No `pnpm-lock.yaml` changes are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 based coding agent, with shell, git, Vitest, and GitHub connector/CLI tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/server-utils.test.ts | 44 +++++ .../multilingual-issues-routes.test.ts | 182 ++++++++++++++++++ ui/src/components/NewIssueDialog.test.tsx | 43 +++++ 3 files changed, 269 insertions(+) create mode 100644 server/src/__tests__/multilingual-issues-routes.test.ts diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 654b1929..aeb6d2a8 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -462,6 +462,50 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("named unblock owner/action"); }); + it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => { + const title = "验证中文任务"; + const commentBody = [ + "请用中文回复。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-9452", + title, + status: "in_progress", + workMode: "standard", + }, + commentIds: ["comment-1"], + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [ + { + id: "comment-1", + body: commentBody, + author: { type: "user", id: "board-user-1" }, + createdAt: "2026-05-15T16:30:00.000Z", + }, + ], + fallbackFetchNeeded: false, + }; + + const serialized = stringifyPaperclipWakePayload(payload); + expect(serialized).toContain(title); + expect(serialized).toContain("日本語"); + expect(serialized).toContain("हिन्दी"); + expect(JSON.parse(serialized ?? "{}")).toMatchObject({ + issue: { title }, + comments: [{ body: commentBody }], + }); + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain(`- issue: PAP-9452 ${title}`); + expect(prompt).toContain(commentBody); + }); + it("renders planning-mode directives for assignment and comment wakes", () => { const assignmentPrompt = renderPaperclipWakePrompt({ reason: "issue_assigned", diff --git a/server/src/__tests__/multilingual-issues-routes.test.ts b/server/src/__tests__/multilingual-issues-routes.test.ts new file mode 100644 index 00000000..da9f0aa3 --- /dev/null +++ b/server/src/__tests__/multilingual-issues-routes.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres multilingual issue route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("multilingual issue routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let app!: ReturnType; + let companyId!: string; + + const title = "验证中文任务"; + const description = [ + "请用中文回复并保留上下文。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const firstReply = [ + "结果: 中文响应保留。", + "日本語の返信も保持。", + "हिन्दी उत्तर भी सुरक्षित है।", + ].join("\n"); + const completionNote = [ + "完成: 已验证中文。", + "日本語: 完了しました。", + "हिन्दी: सत्यापन पूरा हुआ।", + ].join("\n"); + const documentBody = [ + "# QA notes", + "", + "- 中文: 可以创建、读取、搜索、评论。", + "- 日本語: ドキュメント本文を保持します。", + "- हिन्दी: दस्तावेज़ पाठ सुरक्षित रहता है।", + ].join("\n"); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-multilingual-issues-"); + db = createDb(tempDb.connectionString); + companyId = randomUUID(); + app = createApp(companyId); + + await db.insert(companies).values({ + id: companyId, + name: "Multilingual tenant", + issuePrefix: "LNG", + requireBoardApprovalForNewAgents: false, + }); + }, 20_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createStorage(): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(async () => { + throw new Error("Unexpected storage.putFile call in multilingual issue route test"); + }), + getObject: vi.fn(async () => { + throw new Error("Unexpected storage.getObject call in multilingual issue route test"); + }), + headObject: vi.fn(async () => ({ exists: false })), + deleteObject: vi.fn(async () => undefined), + }; + } + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes(db, createStorage())); + app.use(errorHandler); + return app; + } + + it("creates an issue with multilingual title and description", async () => { + const createRes = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ + title, + description, + status: "todo", + priority: "medium", + }); + + expect(createRes.status, JSON.stringify(createRes.body)).toBe(201); + expect(createRes.body).toMatchObject({ + title, + description, + status: "todo", + priority: "medium", + identifier: "LNG-1", + }); + }); + + it("reads the multilingual title and description unchanged", async () => { + const getRes = await request(app).get("/api/issues/LNG-1"); + expect(getRes.status, JSON.stringify(getRes.body)).toBe(200); + expect(getRes.body.title).toBe(title); + expect(getRes.body.description).toBe(description); + }); + + it("finds the issue by Chinese search text", async () => { + const searchRes = await request(app).get(`/api/companies/${companyId}/issues`).query({ q: "中文" }); + expect(searchRes.status, JSON.stringify(searchRes.body)).toBe(200); + expect(searchRes.body.map((issue: { identifier: string }) => issue.identifier)).toContain("LNG-1"); + }); + + it("preserves multilingual comment bodies", async () => { + const commentRes = await request(app) + .post("/api/issues/LNG-1/comments") + .send({ body: firstReply }); + expect(commentRes.status, JSON.stringify(commentRes.body)).toBe(201); + expect(commentRes.body.body).toBe(firstReply); + }); + + it("preserves multilingual document bodies", async () => { + const documentRes = await request(app) + .put("/api/issues/LNG-1/documents/qa-notes") + .send({ + title: "Multilingual QA", + format: "markdown", + body: documentBody, + }); + expect(documentRes.status, JSON.stringify(documentRes.body)).toBe(201); + expect(documentRes.body.body).toBe(documentBody); + }); + + it("preserves multilingual completion comments", async () => { + const completeRes = await request(app) + .patch("/api/issues/LNG-1") + .send({ status: "done", comment: completionNote }); + expect(completeRes.status, JSON.stringify(completeRes.body)).toBe(200); + expect(completeRes.body.status).toBe("done"); + expect(completeRes.body.comment.body).toBe(completionNote); + }); + + it("lists multilingual comments in write order", async () => { + const commentsRes = await request(app).get("/api/issues/LNG-1/comments").query({ order: "asc" }); + expect(commentsRes.status, JSON.stringify(commentsRes.body)).toBe(200); + expect(commentsRes.body.map((comment: { body: string }) => comment.body)).toEqual([ + firstReply, + completionNote, + ]); + }); + + it("exposes multilingual issue text in heartbeat context", async () => { + const heartbeatContextRes = await request(app).get("/api/issues/LNG-1/heartbeat-context"); + expect(heartbeatContextRes.status, JSON.stringify(heartbeatContextRes.body)).toBe(200); + expect(heartbeatContextRes.body.issue.title).toBe(title); + expect(heartbeatContextRes.body.issue.description).toBe(description); + expect(heartbeatContextRes.body.commentCursor.totalComments).toBe(2); + }); +}); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 569d1e8f..7eea9baf 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -588,6 +588,49 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => { + const title = "验证中文任务"; + const description = [ + "请用中文回复。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + + const { root } = renderDialog(container); + await flush(); + + const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null; + const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null; + expect(titleInput).not.toBeNull(); + expect(descriptionInput).not.toBeNull(); + + await typeTextareaValue(titleInput!, title); + await typeTextareaValue(descriptionInput!, description); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Issue")); + expect(submitButton).not.toBeUndefined(); + await vi.waitFor(() => { + expect(submitButton?.hasAttribute("disabled")).toBe(false); + }); + + await act(async () => { + submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(mockIssuesApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + title, + description, + workMode: "standard", + }), + ); + + act(() => root.unmount()); + }); + it("submits planning work mode when planning is selected", async () => { const { root } = renderDialog(container); await flush(); From 63821bfe4c155103bce86bc8c4fad6c2c040e7ff Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 16 May 2026 08:24:31 -0500 Subject: [PATCH 12/53] [codex] Add full locale catalog (#6070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents through a board-facing control plane. > - The UI is the operator surface where onboarding and company creation flows need to remain understandable across languages. > - The i18next foundation now supports locale resource loading and validation, but only English was present on `master`. > - The branch exists to populate that foundation with the supported language catalog without changing routing, data contracts, or runtime behavior. > - This pull request adds locale JSON resources for the current non-English language set. > - The benefit is that future locale selection work has validated message catalogs ready for the first translated onboarding strings. ## What Changed - Added 39 localized message catalogs under `ui/src/i18n/locales/` for the existing no-companies onboarding strings. - Kept the PR rebased onto current `master` so it only contains the all-languages layer on top of the already-merged i18next foundation. ## Verification - `pnpm exec vitest run ui/src/i18n/locale-validation.test.ts` - Checked `ROADMAP.md`; this is scoped locale catalog follow-up work and does not duplicate a listed roadmap feature. - No before/after screenshots included because this PR only adds resource JSON files and does not change rendered layout or visible default-English UI behavior. ## Risks - Low risk: static JSON resource additions validated against the English reference schema. - Translation quality may need native-speaker review before enabling visible locale switching broadly. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex CLI, GPT-5 family coding agent, tool-enabled repository access, medium reasoning effort. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/i18n/locales/ar.json | 9 +++++++++ ui/src/i18n/locales/bn.json | 9 +++++++++ ui/src/i18n/locales/cs.json | 9 +++++++++ ui/src/i18n/locales/da.json | 9 +++++++++ ui/src/i18n/locales/de.json | 9 +++++++++ ui/src/i18n/locales/el.json | 9 +++++++++ ui/src/i18n/locales/es.json | 9 +++++++++ ui/src/i18n/locales/fa.json | 9 +++++++++ ui/src/i18n/locales/fi.json | 9 +++++++++ ui/src/i18n/locales/fil.json | 9 +++++++++ ui/src/i18n/locales/fr.json | 9 +++++++++ ui/src/i18n/locales/he.json | 9 +++++++++ ui/src/i18n/locales/hi.json | 9 +++++++++ ui/src/i18n/locales/hu.json | 9 +++++++++ ui/src/i18n/locales/id.json | 9 +++++++++ ui/src/i18n/locales/it.json | 9 +++++++++ ui/src/i18n/locales/ja.json | 9 +++++++++ ui/src/i18n/locales/ko.json | 9 +++++++++ ui/src/i18n/locales/mr.json | 9 +++++++++ ui/src/i18n/locales/ms.json | 9 +++++++++ ui/src/i18n/locales/nb.json | 9 +++++++++ ui/src/i18n/locales/nl.json | 9 +++++++++ ui/src/i18n/locales/pa.json | 9 +++++++++ ui/src/i18n/locales/pl.json | 9 +++++++++ ui/src/i18n/locales/pt-BR.json | 9 +++++++++ ui/src/i18n/locales/pt-PT.json | 9 +++++++++ ui/src/i18n/locales/ro.json | 9 +++++++++ ui/src/i18n/locales/ru.json | 9 +++++++++ ui/src/i18n/locales/sv.json | 9 +++++++++ ui/src/i18n/locales/sw.json | 9 +++++++++ ui/src/i18n/locales/ta.json | 9 +++++++++ ui/src/i18n/locales/te.json | 9 +++++++++ ui/src/i18n/locales/th.json | 9 +++++++++ ui/src/i18n/locales/tr.json | 9 +++++++++ ui/src/i18n/locales/uk.json | 9 +++++++++ ui/src/i18n/locales/ur.json | 9 +++++++++ ui/src/i18n/locales/vi.json | 9 +++++++++ ui/src/i18n/locales/zh-CN.json | 9 +++++++++ ui/src/i18n/locales/zh-TW.json | 9 +++++++++ 39 files changed, 351 insertions(+) create mode 100644 ui/src/i18n/locales/ar.json create mode 100644 ui/src/i18n/locales/bn.json create mode 100644 ui/src/i18n/locales/cs.json create mode 100644 ui/src/i18n/locales/da.json create mode 100644 ui/src/i18n/locales/de.json create mode 100644 ui/src/i18n/locales/el.json create mode 100644 ui/src/i18n/locales/es.json create mode 100644 ui/src/i18n/locales/fa.json create mode 100644 ui/src/i18n/locales/fi.json create mode 100644 ui/src/i18n/locales/fil.json create mode 100644 ui/src/i18n/locales/fr.json create mode 100644 ui/src/i18n/locales/he.json create mode 100644 ui/src/i18n/locales/hi.json create mode 100644 ui/src/i18n/locales/hu.json create mode 100644 ui/src/i18n/locales/id.json create mode 100644 ui/src/i18n/locales/it.json create mode 100644 ui/src/i18n/locales/ja.json create mode 100644 ui/src/i18n/locales/ko.json create mode 100644 ui/src/i18n/locales/mr.json create mode 100644 ui/src/i18n/locales/ms.json create mode 100644 ui/src/i18n/locales/nb.json create mode 100644 ui/src/i18n/locales/nl.json create mode 100644 ui/src/i18n/locales/pa.json create mode 100644 ui/src/i18n/locales/pl.json create mode 100644 ui/src/i18n/locales/pt-BR.json create mode 100644 ui/src/i18n/locales/pt-PT.json create mode 100644 ui/src/i18n/locales/ro.json create mode 100644 ui/src/i18n/locales/ru.json create mode 100644 ui/src/i18n/locales/sv.json create mode 100644 ui/src/i18n/locales/sw.json create mode 100644 ui/src/i18n/locales/ta.json create mode 100644 ui/src/i18n/locales/te.json create mode 100644 ui/src/i18n/locales/th.json create mode 100644 ui/src/i18n/locales/tr.json create mode 100644 ui/src/i18n/locales/uk.json create mode 100644 ui/src/i18n/locales/ur.json create mode 100644 ui/src/i18n/locales/vi.json create mode 100644 ui/src/i18n/locales/zh-CN.json create mode 100644 ui/src/i18n/locales/zh-TW.json diff --git a/ui/src/i18n/locales/ar.json b/ui/src/i18n/locales/ar.json new file mode 100644 index 00000000..3ca77e0e --- /dev/null +++ b/ui/src/i18n/locales/ar.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "أنشئ شركتك الأولى", + "description": "ابدأ بإنشاء شركة.", + "newCompany": "شركة جديدة" + } + } +} diff --git a/ui/src/i18n/locales/bn.json b/ui/src/i18n/locales/bn.json new file mode 100644 index 00000000..b198cc69 --- /dev/null +++ b/ui/src/i18n/locales/bn.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "আপনার প্রথম কোম্পানি তৈরি করুন", + "description": "একটি কোম্পানি তৈরি করে শুরু করুন।", + "newCompany": "নতুন কোম্পানি" + } + } +} diff --git a/ui/src/i18n/locales/cs.json b/ui/src/i18n/locales/cs.json new file mode 100644 index 00000000..c5a9064e --- /dev/null +++ b/ui/src/i18n/locales/cs.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Vytvořte svou první společnost", + "description": "Začněte vytvořením společnosti.", + "newCompany": "Nová společnost" + } + } +} diff --git a/ui/src/i18n/locales/da.json b/ui/src/i18n/locales/da.json new file mode 100644 index 00000000..1f4e7111 --- /dev/null +++ b/ui/src/i18n/locales/da.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Opret din første virksomhed", + "description": "Kom i gang ved at oprette en virksomhed.", + "newCompany": "Ny virksomhed" + } + } +} diff --git a/ui/src/i18n/locales/de.json b/ui/src/i18n/locales/de.json new file mode 100644 index 00000000..955a3a7b --- /dev/null +++ b/ui/src/i18n/locales/de.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Erstellen Sie Ihr erstes Unternehmen", + "description": "Beginnen Sie, indem Sie ein Unternehmen erstellen.", + "newCompany": "Neues Unternehmen" + } + } +} diff --git a/ui/src/i18n/locales/el.json b/ui/src/i18n/locales/el.json new file mode 100644 index 00000000..b0a7194e --- /dev/null +++ b/ui/src/i18n/locales/el.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Δημιουργήστε την πρώτη σας εταιρεία", + "description": "Ξεκινήστε δημιουργώντας μια εταιρεία.", + "newCompany": "Νέα εταιρεία" + } + } +} diff --git a/ui/src/i18n/locales/es.json b/ui/src/i18n/locales/es.json new file mode 100644 index 00000000..51e05f2a --- /dev/null +++ b/ui/src/i18n/locales/es.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Crea tu primera empresa", + "description": "Comienza creando una empresa.", + "newCompany": "Nueva empresa" + } + } +} diff --git a/ui/src/i18n/locales/fa.json b/ui/src/i18n/locales/fa.json new file mode 100644 index 00000000..20677042 --- /dev/null +++ b/ui/src/i18n/locales/fa.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "اولین شرکت خود را ایجاد کنید", + "description": "با ایجاد یک شرکت شروع کنید.", + "newCompany": "شرکت جدید" + } + } +} diff --git a/ui/src/i18n/locales/fi.json b/ui/src/i18n/locales/fi.json new file mode 100644 index 00000000..74aec335 --- /dev/null +++ b/ui/src/i18n/locales/fi.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Luo ensimmäinen yrityksesi", + "description": "Aloita luomalla yritys.", + "newCompany": "Uusi yritys" + } + } +} diff --git a/ui/src/i18n/locales/fil.json b/ui/src/i18n/locales/fil.json new file mode 100644 index 00000000..ee557d2c --- /dev/null +++ b/ui/src/i18n/locales/fil.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Gumawa ng iyong unang kumpanya", + "description": "Magsimula sa paggawa ng kumpanya.", + "newCompany": "Bagong Kumpanya" + } + } +} diff --git a/ui/src/i18n/locales/fr.json b/ui/src/i18n/locales/fr.json new file mode 100644 index 00000000..af832704 --- /dev/null +++ b/ui/src/i18n/locales/fr.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Créez votre première entreprise", + "description": "Commencez par créer une entreprise.", + "newCompany": "Nouvelle entreprise" + } + } +} diff --git a/ui/src/i18n/locales/he.json b/ui/src/i18n/locales/he.json new file mode 100644 index 00000000..f9dc12b9 --- /dev/null +++ b/ui/src/i18n/locales/he.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "צרו את החברה הראשונה שלכם", + "description": "התחילו ביצירת חברה.", + "newCompany": "חברה חדשה" + } + } +} diff --git a/ui/src/i18n/locales/hi.json b/ui/src/i18n/locales/hi.json new file mode 100644 index 00000000..4a772a94 --- /dev/null +++ b/ui/src/i18n/locales/hi.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "अपनी पहली कंपनी बनाएं", + "description": "कंपनी बनाकर शुरुआत करें.", + "newCompany": "नई कंपनी" + } + } +} diff --git a/ui/src/i18n/locales/hu.json b/ui/src/i18n/locales/hu.json new file mode 100644 index 00000000..e6d780cd --- /dev/null +++ b/ui/src/i18n/locales/hu.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Hozza létre első vállalatát", + "description": "Kezdje egy vállalat létrehozásával.", + "newCompany": "Új vállalat" + } + } +} diff --git a/ui/src/i18n/locales/id.json b/ui/src/i18n/locales/id.json new file mode 100644 index 00000000..86da78cb --- /dev/null +++ b/ui/src/i18n/locales/id.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Buat perusahaan pertama Anda", + "description": "Mulai dengan membuat perusahaan.", + "newCompany": "Perusahaan Baru" + } + } +} diff --git a/ui/src/i18n/locales/it.json b/ui/src/i18n/locales/it.json new file mode 100644 index 00000000..f727ee93 --- /dev/null +++ b/ui/src/i18n/locales/it.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Crea la tua prima azienda", + "description": "Inizia creando un'azienda.", + "newCompany": "Nuova azienda" + } + } +} diff --git a/ui/src/i18n/locales/ja.json b/ui/src/i18n/locales/ja.json new file mode 100644 index 00000000..44251e80 --- /dev/null +++ b/ui/src/i18n/locales/ja.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "最初の会社を作成", + "description": "会社を作成して始めましょう。", + "newCompany": "新しい会社" + } + } +} diff --git a/ui/src/i18n/locales/ko.json b/ui/src/i18n/locales/ko.json new file mode 100644 index 00000000..9a6313a3 --- /dev/null +++ b/ui/src/i18n/locales/ko.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "첫 회사를 만드세요", + "description": "회사를 만들어 시작하세요.", + "newCompany": "새 회사" + } + } +} diff --git a/ui/src/i18n/locales/mr.json b/ui/src/i18n/locales/mr.json new file mode 100644 index 00000000..153bfcc9 --- /dev/null +++ b/ui/src/i18n/locales/mr.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "तुमची पहिली कंपनी तयार करा", + "description": "कंपनी तयार करून सुरुवात करा.", + "newCompany": "नवीन कंपनी" + } + } +} diff --git a/ui/src/i18n/locales/ms.json b/ui/src/i18n/locales/ms.json new file mode 100644 index 00000000..588cb4d0 --- /dev/null +++ b/ui/src/i18n/locales/ms.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Cipta syarikat pertama anda", + "description": "Mulakan dengan mencipta syarikat.", + "newCompany": "Syarikat Baharu" + } + } +} diff --git a/ui/src/i18n/locales/nb.json b/ui/src/i18n/locales/nb.json new file mode 100644 index 00000000..575e4d90 --- /dev/null +++ b/ui/src/i18n/locales/nb.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Opprett ditt første selskap", + "description": "Kom i gang ved å opprette et selskap.", + "newCompany": "Nytt selskap" + } + } +} diff --git a/ui/src/i18n/locales/nl.json b/ui/src/i18n/locales/nl.json new file mode 100644 index 00000000..0f4b0521 --- /dev/null +++ b/ui/src/i18n/locales/nl.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Maak uw eerste bedrijf aan", + "description": "Begin door een bedrijf aan te maken.", + "newCompany": "Nieuw bedrijf" + } + } +} diff --git a/ui/src/i18n/locales/pa.json b/ui/src/i18n/locales/pa.json new file mode 100644 index 00000000..57ce9727 --- /dev/null +++ b/ui/src/i18n/locales/pa.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "ਆਪਣੀ ਪਹਿਲੀ ਕੰਪਨੀ ਬਣਾਓ", + "description": "ਕੰਪਨੀ ਬਣਾਕੇ ਸ਼ੁਰੂ ਕਰੋ।", + "newCompany": "ਨਵੀਂ ਕੰਪਨੀ" + } + } +} diff --git a/ui/src/i18n/locales/pl.json b/ui/src/i18n/locales/pl.json new file mode 100644 index 00000000..4a851929 --- /dev/null +++ b/ui/src/i18n/locales/pl.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Utwórz swoją pierwszą firmę", + "description": "Zacznij od utworzenia firmy.", + "newCompany": "Nowa firma" + } + } +} diff --git a/ui/src/i18n/locales/pt-BR.json b/ui/src/i18n/locales/pt-BR.json new file mode 100644 index 00000000..c1c0f0f8 --- /dev/null +++ b/ui/src/i18n/locales/pt-BR.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Crie sua primeira empresa", + "description": "Comece criando uma empresa.", + "newCompany": "Nova empresa" + } + } +} diff --git a/ui/src/i18n/locales/pt-PT.json b/ui/src/i18n/locales/pt-PT.json new file mode 100644 index 00000000..b11ddf56 --- /dev/null +++ b/ui/src/i18n/locales/pt-PT.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Crie a sua primeira empresa", + "description": "Comece por criar uma empresa.", + "newCompany": "Nova empresa" + } + } +} diff --git a/ui/src/i18n/locales/ro.json b/ui/src/i18n/locales/ro.json new file mode 100644 index 00000000..830f1542 --- /dev/null +++ b/ui/src/i18n/locales/ro.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Creați prima companie", + "description": "Începeți prin crearea unei companii.", + "newCompany": "Companie nouă" + } + } +} diff --git a/ui/src/i18n/locales/ru.json b/ui/src/i18n/locales/ru.json new file mode 100644 index 00000000..f9c6e1f0 --- /dev/null +++ b/ui/src/i18n/locales/ru.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Создайте свою первую компанию", + "description": "Начните с создания компании.", + "newCompany": "Новая компания" + } + } +} diff --git a/ui/src/i18n/locales/sv.json b/ui/src/i18n/locales/sv.json new file mode 100644 index 00000000..a1a263e6 --- /dev/null +++ b/ui/src/i18n/locales/sv.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Skapa ditt första företag", + "description": "Kom igång genom att skapa ett företag.", + "newCompany": "Nytt företag" + } + } +} diff --git a/ui/src/i18n/locales/sw.json b/ui/src/i18n/locales/sw.json new file mode 100644 index 00000000..46a64ee1 --- /dev/null +++ b/ui/src/i18n/locales/sw.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Unda kampuni yako ya kwanza", + "description": "Anza kwa kuunda kampuni.", + "newCompany": "Kampuni Mpya" + } + } +} diff --git a/ui/src/i18n/locales/ta.json b/ui/src/i18n/locales/ta.json new file mode 100644 index 00000000..278c2de4 --- /dev/null +++ b/ui/src/i18n/locales/ta.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "உங்கள் முதல் நிறுவனத்தை உருவாக்குங்கள்", + "description": "ஒரு நிறுவனத்தை உருவாக்கி தொடங்குங்கள்.", + "newCompany": "புதிய நிறுவனம்" + } + } +} diff --git a/ui/src/i18n/locales/te.json b/ui/src/i18n/locales/te.json new file mode 100644 index 00000000..7735c966 --- /dev/null +++ b/ui/src/i18n/locales/te.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "మీ మొదటి కంపెనీని సృష్టించండి", + "description": "కంపెనీని సృష్టించడం ద్వారా ప్రారంభించండి.", + "newCompany": "కొత్త కంపెనీ" + } + } +} diff --git a/ui/src/i18n/locales/th.json b/ui/src/i18n/locales/th.json new file mode 100644 index 00000000..c62aeadb --- /dev/null +++ b/ui/src/i18n/locales/th.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "สร้างบริษัทแรกของคุณ", + "description": "เริ่มต้นด้วยการสร้างบริษัท", + "newCompany": "บริษัทใหม่" + } + } +} diff --git a/ui/src/i18n/locales/tr.json b/ui/src/i18n/locales/tr.json new file mode 100644 index 00000000..c5b953f2 --- /dev/null +++ b/ui/src/i18n/locales/tr.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "İlk şirketinizi oluşturun", + "description": "Bir şirket oluşturarak başlayın.", + "newCompany": "Yeni Şirket" + } + } +} diff --git a/ui/src/i18n/locales/uk.json b/ui/src/i18n/locales/uk.json new file mode 100644 index 00000000..780f402d --- /dev/null +++ b/ui/src/i18n/locales/uk.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Створіть свою першу компанію", + "description": "Почніть зі створення компанії.", + "newCompany": "Нова компанія" + } + } +} diff --git a/ui/src/i18n/locales/ur.json b/ui/src/i18n/locales/ur.json new file mode 100644 index 00000000..3b8cd14f --- /dev/null +++ b/ui/src/i18n/locales/ur.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "اپنی پہلی کمپنی بنائیں", + "description": "کمپنی بنا کر شروع کریں۔", + "newCompany": "نئی کمپنی" + } + } +} diff --git a/ui/src/i18n/locales/vi.json b/ui/src/i18n/locales/vi.json new file mode 100644 index 00000000..7992efba --- /dev/null +++ b/ui/src/i18n/locales/vi.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Tạo công ty đầu tiên của bạn", + "description": "Bắt đầu bằng cách tạo một công ty.", + "newCompany": "Công ty mới" + } + } +} diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json new file mode 100644 index 00000000..a9c84364 --- /dev/null +++ b/ui/src/i18n/locales/zh-CN.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "创建您的第一家公司", + "description": "通过创建公司开始。", + "newCompany": "新公司" + } + } +} diff --git a/ui/src/i18n/locales/zh-TW.json b/ui/src/i18n/locales/zh-TW.json new file mode 100644 index 00000000..c5393e36 --- /dev/null +++ b/ui/src/i18n/locales/zh-TW.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "建立您的第一家公司", + "description": "從建立公司開始。", + "newCompany": "新公司" + } + } +} From ab8b471685bf17efd7d170cd43187c0318dcdeaa Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 16 May 2026 09:51:09 -0700 Subject: [PATCH 13/53] Add built-in grok_local adapter (#6087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so adapter quality directly affects what runtimes the control plane can supervise. > - Local CLI adapters are one of the core execution surfaces because they turn real coding tools into Paperclip-managed employees with heartbeats, transcripts, and reviewability. > - Grok Build was installed on the Paperclip host, but Paperclip had no built-in `grok_local` adapter, so the runtime could not be configured through the normal server/UI/CLI adapter path. > - That gap needed to be closed with the same built-in registry, environment diagnostics, transcript parsing, and skill/instructions behavior that the other local adapters already rely on. > - After the initial adapter landed, a real follow-up run showed that Grok streaming text was being rendered one fragment per line, which made transcripts harder to read even though the runtime itself was working. > - This pull request adds the built-in `grok_local` adapter end-to-end and then fixes the transcript parser so streamed Grok output is coalesced into readable assistant/thinking blocks. > - The benefit is that Grok Build becomes a first-class Paperclip runtime with a usable operator experience instead of a partially wired runtime with noisy transcript output. ## What Changed - Added a new built-in `@paperclipai/adapter-grok-local` package with server, UI, and CLI entrypoints. - Implemented Grok execution, session handling, environment diagnostics, config building, skill syncing, and parser coverage inside the new adapter package. - Registered `grok_local` across the built-in adapter inventories and capability/display metadata in server, UI, CLI, and shared constants. - Added adapter route coverage for the new built-in type. - Fixed Grok transcript readability by emitting streamed `text` and `thought` fragments as deltas so the shared transcript builder coalesces them into readable message blocks. - Added regression tests for the Grok parser and transcript coalescing behavior. ## Verification - `pnpm vitest run packages/adapters/grok-local/src/ui/parse-stdout.test.ts ui/src/adapters/transcript.test.ts` - `pnpm --filter @paperclipai/adapter-grok-local build` - Manual runtime verification on the Paperclip host during implementation and follow-up review: - confirmed the Grok CLI was installed and authenticated - confirmed the worktree dev server could be restarted cleanly and health-checked after the parser follow-up - No screenshots attached. This change is primarily adapter plumbing plus transcript formatting behavior; reviewers can verify via the Grok-backed run surfaces directly. ## Risks - This adds a new built-in adapter, so any missed registration surface could create inconsistencies between server, UI, and CLI behavior. - The adapter depends on Grok Build's current event/output shape; if upstream Grok streaming JSON changes, transcript parsing or session extraction may need follow-up updates. - The transcript readability fix intentionally changes how Grok fragments are grouped, so any downstream code that implicitly expected one entry per fragment would behave differently. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex via Paperclip `codex_local` agent runtime. - GPT-5-class coding model with tool use, shell execution, file editing, and repo inspection enabled. - Exact backend model ID/context window were not surfaced to the agent in this Paperclip session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- Dockerfile | 1 + cli/package.json | 1 + cli/src/adapters/registry.ts | 7 + packages/adapters/grok-local/package.json | 60 ++ .../grok-local/src/cli/format-event.test.ts | 24 + .../grok-local/src/cli/format-event.ts | 59 ++ packages/adapters/grok-local/src/cli/index.ts | 1 + packages/adapters/grok-local/src/index.ts | 45 ++ .../grok-local/src/server/execute.test.ts | 187 ++++++ .../adapters/grok-local/src/server/execute.ts | 583 ++++++++++++++++++ .../adapters/grok-local/src/server/index.ts | 66 ++ .../grok-local/src/server/parse.test.ts | 38 ++ .../adapters/grok-local/src/server/parse.ts | 87 +++ .../adapters/grok-local/src/server/skills.ts | 80 +++ .../grok-local/src/server/test.test.ts | 142 +++++ .../adapters/grok-local/src/server/test.ts | 313 ++++++++++ .../grok-local/src/ui/build-config.test.ts | 26 + .../grok-local/src/ui/build-config.ts | 74 +++ packages/adapters/grok-local/src/ui/index.ts | 2 + .../grok-local/src/ui/parse-stdout.test.ts | 27 + .../grok-local/src/ui/parse-stdout.ts | 61 ++ packages/adapters/grok-local/tsconfig.json | 8 + scripts/release-package-manifest.json | 5 + server/package.json | 1 + server/src/__tests__/adapter-routes.test.ts | 9 + server/src/adapters/builtin-adapter-types.ts | 1 + server/src/adapters/registry.ts | 33 + ui/package.json | 1 + ui/src/adapters/adapter-display-registry.ts | 5 + ui/src/adapters/grok-local/config-fields.tsx | 51 ++ ui/src/adapters/grok-local/index.ts | 12 + ui/src/adapters/registry.ts | 2 + ui/src/adapters/transcript.test.ts | 17 + ui/src/adapters/use-adapter-capabilities.ts | 1 + vitest.config.ts | 1 + 35 files changed, 2031 insertions(+) create mode 100644 packages/adapters/grok-local/package.json create mode 100644 packages/adapters/grok-local/src/cli/format-event.test.ts create mode 100644 packages/adapters/grok-local/src/cli/format-event.ts create mode 100644 packages/adapters/grok-local/src/cli/index.ts create mode 100644 packages/adapters/grok-local/src/index.ts create mode 100644 packages/adapters/grok-local/src/server/execute.test.ts create mode 100644 packages/adapters/grok-local/src/server/execute.ts create mode 100644 packages/adapters/grok-local/src/server/index.ts create mode 100644 packages/adapters/grok-local/src/server/parse.test.ts create mode 100644 packages/adapters/grok-local/src/server/parse.ts create mode 100644 packages/adapters/grok-local/src/server/skills.ts create mode 100644 packages/adapters/grok-local/src/server/test.test.ts create mode 100644 packages/adapters/grok-local/src/server/test.ts create mode 100644 packages/adapters/grok-local/src/ui/build-config.test.ts create mode 100644 packages/adapters/grok-local/src/ui/build-config.ts create mode 100644 packages/adapters/grok-local/src/ui/index.ts create mode 100644 packages/adapters/grok-local/src/ui/parse-stdout.test.ts create mode 100644 packages/adapters/grok-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/grok-local/tsconfig.json create mode 100644 ui/src/adapters/grok-local/config-fields.tsx create mode 100644 ui/src/adapters/grok-local/index.ts diff --git a/Dockerfile b/Dockerfile index e367910e..ece9d86b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ +COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ diff --git a/cli/package.json b/cli/package.json index 2dffbeee..b60dde4d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -43,6 +43,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index d7d16f17..31dfe0d0 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; +import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGeminiStreamEvent, }; +const grokLocalCLIAdapter: CLIAdapterModule = { + type: "grok_local", + formatStdoutEvent: printGrokStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -66,6 +72,7 @@ const adaptersByType = new Map( cursorLocalCLIAdapter, cursorCloudCLIAdapter, geminiLocalCLIAdapter, + grokLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json new file mode 100644 index 00000000..466a6d49 --- /dev/null +++ b/packages/adapters/grok-local/package.json @@ -0,0 +1,60 @@ +{ + "name": "@paperclipai/adapter-grok-local", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/grok-local" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/grok-local/src/cli/format-event.test.ts b/packages/adapters/grok-local/src/cli/format-event.test.ts new file mode 100644 index 00000000..81ab879e --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { printGrokStreamEvent } from "./format-event.js"; + +describe("printGrokStreamEvent", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + afterEach(() => { + spy.mockClear(); + }); + + it("prints thought/text/end events", () => { + printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false); + printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false); + printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false); + + expect(spy.mock.calls.flat()).toEqual( + expect.arrayContaining([ + expect.stringContaining("thinking: Plan"), + expect.stringContaining("assistant: hello"), + expect.stringContaining("Grok run completed"), + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/cli/format-event.ts b/packages/adapters/grok-local/src/cli/format-event.ts new file mode 100644 index 00000000..951e5737 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.ts @@ -0,0 +1,59 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function printGrokStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type).trim(); + if (type === "thought") { + const text = asString(parsed.data); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "text") { + const text = asString(parsed.data); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason); + const sessionId = asString(parsed.sessionId); + const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""] + .filter(Boolean) + .join(" "); + console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`)); + return; + } + + if (type === "error") { + const text = + asString(parsed.data) || + asString(parsed.message) || + asString(parsed.error) || + "Grok error"; + console.log(pc.red(`error: ${text}`)); + return; + } + + const payload = asRecord(parsed); + console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`)); +} diff --git a/packages/adapters/grok-local/src/cli/index.ts b/packages/adapters/grok-local/src/cli/index.ts new file mode 100644 index 00000000..c6bdf988 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGrokStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/grok-local/src/index.ts b/packages/adapters/grok-local/src/index.ts new file mode 100644 index 00000000..7e12bd1d --- /dev/null +++ b/packages/adapters/grok-local/src/index.ts @@ -0,0 +1,45 @@ +export const type = "grok_local"; +export const label = "Grok Build (local)"; + +export const DEFAULT_GROK_LOCAL_MODEL = "grok-build"; + +export const models = [ + { id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL }, +]; + +export const agentConfigurationDoc = `# grok_local agent configuration + +Adapter: grok_local + +Use when: +- You want Paperclip to run the native Grok Build CLI locally on the host machine +- You want resumable Grok sessions across heartbeats via \`--resume\` +- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`) + +Don't use when: +- You need a webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Grok CLI is not installed or authenticated on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\` +- promptTemplate (string, optional): run prompt template +- model (string, optional): Grok model id. Defaults to grok-build. +- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\` +- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\` +- maxTurns (number, optional): maximum agent turns for the run +- command (string, optional): defaults to "grok" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use \`grok --single\` with \`--output-format streaming-json\`. +- Sessions resume with \`--resume \` when the saved session cwd matches the current cwd. +- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills. +- Use \`grok models\` to inspect authentication and available models on the host. +`; diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts new file mode 100644 index 00000000..b520bf13 --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ + workspaceRemoteDir: null, + restoreWorkspace: async () => {}, +}))); +const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok")); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + adapterExecutionTargetIsRemote: () => false, + adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd, + overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target, + adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }), + adapterExecutionTargetSessionMatches: () => true, + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock, + prepareAdapterExecutionTargetRuntime: prepareRuntimeMock, + readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" }, + resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock, + resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { execute } from "./execute.js"; + +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-")); + tempRoots.push(root); + return root; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +describe("grok_local execute", () => { + beforeEach(() => { + ensureRuntimeInstalledMock.mockClear(); + ensureCommandMock.mockClear(); + prepareRuntimeMock.mockClear(); + resolveCommandForLogsMock.mockClear(); + runProcessMock.mockReset(); + }); + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + + runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => { + expect(args).toEqual( + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + ]), + ); + expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok."); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true); + await options.onLog?.("stdout", '{"type":"text","data":"done"}\n'); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "done" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }; + }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const ctx: AdapterExecutionContext = { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async (stream: "stdout" | "stderr", chunk: string) => { + logs.push({ stream, chunk }); + }, + }; + + const result = await execute(ctx); + + expect(result).toMatchObject({ + exitCode: 0, + errorMessage: null, + summary: "done", + sessionId: "sess-1", + sessionDisplayId: "sess-1", + }); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + expect(logs.map((entry) => entry.chunk)).not.toEqual([]); + }); + + it("cleans up staged assets when setup fails before the Grok process starts", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed")); + + const ctx: AdapterExecutionContext = { + runId: "run-setup-fail", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + }; + + await expect(execute(ctx)).rejects.toThrow("grok not installed"); + expect(runProcessMock).not.toHaveBeenCalled(); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts new file mode 100644 index 00000000..0d25845a --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -0,0 +1,583 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + overrideAdapterExecutionTargetRemoteCwd, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + resolveAdapterExecutionTargetTimeoutSec, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildInvocationEnvForLogs, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensurePathInEnv, + joinPromptSections, + materializePaperclipSkillCopy, + parseObject, + readPaperclipIssueWorkModeFromContext, + readPaperclipRuntimeSkillEntries, + renderTemplate, + renderPaperclipWakePrompt, + resolvePaperclipDesiredSkillNames, + stringifyPaperclipWakePayload, + refreshPaperclipWorkspaceEnvForExecution, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use shell commands with curl to make Paperclip API requests when needed.", + "Include X-Paperclip-Run-Id on mutating requests.", + "", + "", + ].join("\n"); +} + +type StageCleanup = { + kind: "file" | "dir"; + path: string; +}; + +type StagedGrokAssets = { + cleanup: () => Promise; + stagedSkillsCount: number; + stagedInstructionsPath: string | null; + rulesFilePath: string | null; +}; + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function stageGrokProjectAssets(input: { + cwd: string; + instructionsFilePath: string; + skillEntries: Array<{ key: string; runtimeName: string; source: string }>; + desiredSkillNames: string[]; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const cleanup: StageCleanup[] = []; + const ensureCleanupDir = (candidate: string) => { + cleanup.push({ kind: "dir", path: candidate }); + }; + const ensureCleanupFile = (candidate: string) => { + cleanup.push({ kind: "file", path: candidate }); + }; + + let stagedInstructionsPath: string | null = null; + let rulesFilePath: string | null = null; + let stagedSkillsCount = 0; + + const instructionsTarget = path.join(input.cwd, "Agents.md"); + if (input.instructionsFilePath) { + if (!await pathExists(instructionsTarget)) { + await fs.copyFile(input.instructionsFilePath, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) { + rulesFilePath = input.instructionsFilePath; + await input.onLog( + "stdout", + `[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`, + ); + } + } else { + const canonicalAgents = path.join(input.cwd, "AGENTS.md"); + if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) { + await fs.copyFile(canonicalAgents, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } + } + + const desiredSet = new Set(input.desiredSkillNames); + const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key)); + if (selectedSkills.length > 0) { + const claudeDir = path.join(input.cwd, ".claude"); + const skillsRoot = path.join(claudeDir, "skills"); + if (!await pathExists(claudeDir)) { + await fs.mkdir(claudeDir, { recursive: true }); + ensureCleanupDir(claudeDir); + } + if (!await pathExists(skillsRoot)) { + await fs.mkdir(skillsRoot, { recursive: true }); + ensureCleanupDir(skillsRoot); + } + + for (const skill of selectedSkills) { + const target = path.join(skillsRoot, skill.runtimeName); + if (await pathExists(target)) { + await input.onLog( + "stdout", + `[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`, + ); + continue; + } + await materializePaperclipSkillCopy(skill.source, target); + ensureCleanupDir(target); + stagedSkillsCount += 1; + } + } + + return { + stagedSkillsCount, + stagedInstructionsPath, + rulesFilePath, + cleanup: async () => { + for (const entry of [...cleanup].reverse()) { + if (entry.kind === "file") { + await fs.rm(entry.path, { force: true }).catch(() => undefined); + continue; + } + await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined); + } + }, + }; +} + +function resolveBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription"; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + + const promptTemplate = asString( + config.promptTemplate, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + ); + const command = asString(config.command, "grok"); + const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk"; + const reasoningEffort = asString(config.reasoningEffort, "").trim(); + const maxTurns = asNumber(config.maxTurns, 0); + const alwaysApprove = asBoolean(config.alwaysApprove, true); + const disableWebSearch = asBoolean(config.disableWebSearch, true); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value: unknown): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const stagedAssets = await stageGrokProjectAssets({ + cwd, + instructionsFilePath, + skillEntries: grokSkillEntries, + desiredSkillNames: desiredGrokSkillNames, + onLog, + }); + let restoreRemoteWorkspace: (() => Promise) | null = null; + + try { + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + + if (executionTargetIsRemote) { + await onLog( + "stdout", + `[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "grok", + workspaceLocalDir: cwd, + timeoutSec, + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command, + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + } + + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + const effectiveEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const runtimeEnv = ensurePathInEnv(effectiveEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + timeoutSec, + }); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + const billingType = resolveBillingType(effectiveEnv); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, + ); + } + + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."]; + if (alwaysApprove) notes.push("Added --always-approve for unattended execution."); + if (stagedAssets.stagedInstructionsPath) { + notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`); + } + if (stagedAssets.rulesFilePath) { + notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`); + } + if (stagedAssets.stagedSkillsCount > 0) { + notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`); + } + return notes; + })(); + + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + wakePrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model); + if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort); + if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (disableWebSearch) args.push("--disable-web-search"); + if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("--single", prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "grok_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + parsed: parseGrokJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const failed = (attempt.proc.exitCode ?? 0) !== 0; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const fallbackErrorMessage = + parsedError || + stderrLine || + `Grok exited with code ${attempt.proc.exitCode ?? -1}`; + + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + } as Record) + : null; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: failed ? fallbackErrorMessage : null, + usage: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "xai", + biller: billingType === "api" ? "xai" : "grok", + model, + billingType, + costUsd: null, + resultJson: { + stopReason: attempt.parsed.stopReason, + requestId: attempt.parsed.requestId, + ...(failed ? { stderr: attempt.proc.stderr } : {}), + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stdout", + `[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + restoreRemoteWorkspace?.(), + stagedAssets.cleanup(), + ]); + } +} diff --git a/packages/adapters/grok-local/src/server/index.ts b/packages/adapters/grok-local/src/server/index.ts new file mode 100644 index 00000000..127128cf --- /dev/null +++ b/packages/adapters/grok-local/src/server/index.ts @@ -0,0 +1,66 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; + +export { execute } from "./execute.js"; +export { listGrokSkills, syncGrokSkills } from "./skills.js"; +export { testEnvironment } from "./test.js"; +export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/grok-local/src/server/parse.test.ts b/packages/adapters/grok-local/src/server/parse.test.ts new file mode 100644 index 00000000..5b31cf88 --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +describe("parseGrokJsonl", () => { + it("collects streamed thought/text content and final session metadata", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "thought", data: "Plan" }), + JSON.stringify({ type: "thought", data: " first." }), + JSON.stringify({ type: "text", data: "hel" }), + JSON.stringify({ type: "text", data: "lo" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n")); + + expect(parsed).toEqual({ + sessionId: "sess-1", + summary: "hello", + thought: "Plan first.", + errorMessage: null, + stopReason: "EndTurn", + requestId: "req-1", + }); + }); + + it("reads structured error payloads", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "error", error: { message: "Authentication required" } }), + ].join("\n")); + + expect(parsed.errorMessage).toBe("Authentication required"); + }); +}); + +describe("isGrokUnknownSessionError", () => { + it("detects stale resume failures", () => { + expect(isGrokUnknownSessionError("", "session not found")).toBe(true); + expect(isGrokUnknownSessionError("", "everything fine")).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/parse.ts b/packages/adapters/grok-local/src/server/parse.ts new file mode 100644 index 00000000..eb5ab92d --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.ts @@ -0,0 +1,87 @@ +import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +export interface ParsedGrokJsonl { + sessionId: string | null; + summary: string; + thought: string; + errorMessage: string | null; + stopReason: string | null; + requestId: string | null; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "").trim() || + asString(rec.error, "").trim() || + asString(rec.detail, "").trim() || + asString(rec.code, "").trim(); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseGrokJsonl(stdout: string): ParsedGrokJsonl { + let sessionId: string | null = null; + let stopReason: string | null = null; + let requestId: string | null = null; + let errorMessage: string | null = null; + const thoughtParts: string[] = []; + const textParts: string[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const type = asString(event.type, "").trim(); + if (type === "thought") { + const text = asString(event.data, ""); + if (text) thoughtParts.push(text); + continue; + } + + if (type === "text") { + const text = asString(event.data, ""); + if (text) textParts.push(text); + continue; + } + + if (type === "end") { + sessionId = asString(event.sessionId, "").trim() || sessionId; + stopReason = asString(event.stopReason, "").trim() || stopReason; + requestId = asString(event.requestId, "").trim() || requestId; + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim(); + if (text) errorMessage = text; + } + } + + return { + sessionId, + summary: textParts.join("").trim(), + thought: thoughtParts.join("").trim(), + errorMessage, + stopReason, + requestId, + }; +} + +export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack); +} diff --git a/packages/adapters/grok-local/src/server/skills.ts b/packages/adapters/grok-local/src/server/skills.ts new file mode 100644 index 00000000..fdbbb548 --- /dev/null +++ b/packages/adapters/grok-local/src/server/skills.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildGrokSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be copied into `.claude/skills` in the execution workspace on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "grok_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listGrokSkills(ctx: AdapterSkillContext): Promise { + return buildGrokSkillSnapshot(ctx.config); +} + +export async function syncGrokSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildGrokSkillSnapshot(ctx.config); +} diff --git a/packages/adapters/grok-local/src/server/test.test.ts b/packages/adapters/grok-local/src/server/test.test.ts new file mode 100644 index 00000000..6800744b --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetDirectory: ensureDirectoryMock, + resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) => + configuredCwd || fallbackCwd, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { parseGrokModelsOutput, testEnvironment } from "./test.js"; + +describe("parseGrokModelsOutput", () => { + it("extracts auth state and models from `grok models` output", () => { + expect(parseGrokModelsOutput([ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + " * grok-code", + ].join("\n"))).toEqual({ + authenticated: true, + defaultModel: "grok-build", + models: ["grok-build", "grok-code"], + }); + }); +}); + +describe("grok_local testEnvironment", () => { + beforeEach(() => { + ensureDirectoryMock.mockClear(); + ensureCommandMock.mockClear(); + runProcessMock.mockReset(); + }); + + it("reports a healthy authenticated host with a working hello probe", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + ].join("\n"), + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "hello" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + model: "grok-build", + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_command_resolvable", + "grok_models_probe_passed", + "grok_model_configured", + "grok_hello_probe_passed", + ]), + ); + expect(runProcessMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + null, + "grok", + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + "--single", + "Respond with exactly hello.", + ]), + expect.any(Object), + ); + }); + + it("downgrades auth failures to warnings", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_auth_required", + "grok_hello_probe_auth_required", + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/server/test.ts b/packages/adapters/grok-local/src/server/test.ts new file mode 100644 index 00000000..b69ad7ff --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.ts @@ -0,0 +1,313 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + asStringArray, + ensurePathInEnv, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + resolveAdapterExecutionTargetCwd, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { parseGrokJsonl } from "./parse.js"; + +export interface GrokModelsProbe { + authenticated: boolean; + defaultModel: string | null; + models: string[]; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +const GROK_AUTH_REQUIRED_RE = + /(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i; + +export function parseGrokModelsOutput(stdout: string): GrokModelsProbe { + const trimmedLines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const models: string[] = []; + let defaultModel: string | null = null; + let authenticated = false; + let inModelsBlock = false; + + for (const line of trimmedLines) { + if (/logged in/i.test(line)) authenticated = true; + const defaultMatch = /^Default model:\s*(.+)$/i.exec(line); + if (defaultMatch?.[1]) { + defaultModel = defaultMatch[1].trim(); + continue; + } + if (/^Available models:/i.test(line)) { + inModelsBlock = true; + continue; + } + if (!inModelsBlock) continue; + const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line); + if (bulletMatch?.[1]) { + models.push(bulletMatch[1].trim()); + continue; + } + if (line.length > 0) { + models.push(line.replace(/\s+\(default\)$/, "").trim()); + } + } + + return { + authenticated, + defaultModel, + models: Array.from(new Set(models.filter(Boolean))), + }; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "grok"); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) + : null; + const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "grok_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } + + try { + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); + checks.push({ + code: "grok_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "grok_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const env = normalizeEnv(config.env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + + try { + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); + checks.push({ + code: "grok_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "grok_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable"); + + const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + + if (canRunProbe) { + const modelsProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + ["models"], + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + + const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`; + const parsedModels = parseGrokModelsOutput(modelsProbe.stdout); + const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput); + + if (modelsProbe.timedOut) { + checks.push({ + code: "grok_models_probe_timed_out", + level: "warn", + message: "`grok models` timed out.", + hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.", + }); + } else if ((modelsProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_auth_required" : "grok_models_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI is not authenticated." + : "`grok models` failed.", + detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else { + checks.push({ + code: "grok_models_probe_passed", + level: "info", + message: parsedModels.authenticated + ? "Grok CLI authentication is configured." + : "`grok models` completed.", + detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined, + }); + if (parsedModels.models.length > 0) { + checks.push({ + code: "grok_models_discovered", + level: "info", + message: `Discovered ${parsedModels.models.length} Grok model(s).`, + }); + } else { + checks.push({ + code: "grok_models_empty", + level: "warn", + message: "Grok returned no available models.", + hint: "Run `grok models` manually and verify the account has access to a model.", + }); + } + if (configuredModel) { + checks.push({ + code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found", + level: parsedModels.models.includes(configuredModel) ? "info" : "warn", + message: parsedModels.models.includes(configuredModel) + ? `Configured model: ${configuredModel}` + : `Configured model "${configuredModel}" not found in available models.`, + hint: parsedModels.models.includes(configuredModel) + ? undefined + : "Run `grok models` and choose an available model id.", + }); + } + } + } + + if (canRunProbe) { + const probeArgs = [ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + ]; + if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) { + probeArgs.push("--model", configuredModel); + } + probeArgs.push("--single", "Respond with exactly hello."); + + const helloProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + probeArgs, + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + const parsed = parseGrokJsonl(helloProbe.stdout); + const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage); + const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`); + + if (helloProbe.timedOut) { + checks.push({ + code: "grok_hello_probe_timed_out", + level: "warn", + message: "Grok hello probe timed out.", + hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.", + }); + } else if ((helloProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI could not answer the hello probe because authentication is missing." + : "Grok hello probe failed.", + ...(detail ? { detail } : {}), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else if (/\bhello\b/i.test(parsed.summary)) { + checks.push({ + code: "grok_hello_probe_passed", + level: "info", + message: "Grok hello probe succeeded.", + }); + } else { + checks.push({ + code: "grok_hello_probe_unexpected_output", + level: "warn", + message: "Grok hello probe succeeded but returned unexpected output.", + ...(detail ? { detail } : {}), + }); + } + } + + return { + adapterType: "grok_local", + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/grok-local/src/ui/build-config.test.ts b/packages/adapters/grok-local/src/ui/build-config.test.ts new file mode 100644 index 00000000..118a7494 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildGrokLocalConfig } from "./build-config.js"; + +describe("buildGrokLocalConfig", () => { + it("maps create-form values into adapter config", () => { + expect(buildGrokLocalConfig({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + thinkingEffort: "high", + envVars: "XAI_API_KEY=secret\n", + extraArgs: "--check, --verbatim", + } as never)).toEqual({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + timeoutSec: 0, + graceSec: 20, + reasoningEffort: "high", + env: { + XAI_API_KEY: { type: "plain", value: "secret" }, + }, + extraArgs: ["--check", "--verbatim"], + }); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/build-config.ts b/packages/adapters/grok-local/src/ui/build-config.ts new file mode 100644 index 00000000..6c9e9a66 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.ts @@ -0,0 +1,74 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGrokLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 20; + if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/grok-local/src/ui/index.ts b/packages/adapters/grok-local/src/ui/index.ts new file mode 100644 index 00000000..a37e3d82 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGrokStdoutLine } from "./parse-stdout.js"; +export { buildGrokLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.test.ts b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000..d60b5b61 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseGrokStdoutLine } from "./parse-stdout.js"; + +describe("parseGrokStdoutLine", () => { + const ts = "2026-05-15T00:00:00.000Z"; + + it("maps thought/text/end events into transcript entries", () => { + expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([ + { kind: "thinking", ts, text: "Plan first.", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([ + { kind: "assistant", ts, text: "hello", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([ + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); + + it("surfaces structured Grok error payload text", () => { + expect(parseGrokStdoutLine(JSON.stringify({ + type: "error", + error: { message: "Authentication required" }, + }), ts)).toEqual([ + { kind: "stderr", ts, text: "Authentication required" }, + ]); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.ts b/packages/adapters/grok-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..b82a36b4 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.ts @@ -0,0 +1,61 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function extractErrorText(value: unknown): string { + if (typeof value === "string") return value; + const record = asRecord(value); + if (!record) return ""; + return asString(record.message) || asString(record.detail) || asString(record.code); +} + +export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type).trim(); + + if (type === "thought") { + const text = asString(parsed.data); + return text ? [{ kind: "thinking", ts, text, delta: true }] : []; + } + + if (type === "text") { + const text = asString(parsed.data); + return text ? [{ kind: "assistant", ts, text, delta: true }] : []; + } + + if (type === "error") { + const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error); + return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }]; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason).trim(); + const sessionId = asString(parsed.sessionId).trim(); + const parts = [ + stopReason ? `stop_reason=${stopReason}` : "", + sessionId ? `session=${sessionId}` : "", + ].filter(Boolean); + return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }]; + } + + return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }]; +} diff --git a/packages/adapters/grok-local/tsconfig.json b/packages/adapters/grok-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/grok-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 954c258e..81bb12f2 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -34,6 +34,11 @@ "name": "@paperclipai/adapter-gemini-local", "publishFromCi": true }, + { + "dir": "packages/adapters/grok-local", + "name": "@paperclipai/adapter-grok-local", + "publishFromCi": false + }, { "dir": "packages/adapters/opencode-local", "name": "@paperclipai/adapter-opencode-local", diff --git a/server/package.json b/server/package.json index 2ea875b8..6855ba6b 100644 --- a/server/package.json +++ b/server/package.json @@ -50,6 +50,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 06c25c7f..b90869e6 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -174,6 +174,15 @@ describe("adapter routes", () => { expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + const grokAdapter = res.body.find((a: any) => a.type === "grok_local"); + expect(grokAdapter).toBeDefined(); + expect(grokAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + }); + // hermes_local currently supports skills + local JWT, but not the managed // instructions bundle flow because the bundled adapter does not consume // instructionsFilePath at runtime. diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index a30ed5cc..bb96eb99 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "cursor_cloud", "cursor", "gemini_local", + "grok_local", "openclaw_gateway", "opencode_local", "pi_local", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 32a748b6..abe73ea0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -78,6 +78,17 @@ import { models as geminiModels, modelProfiles as geminiModelProfiles, } from "@paperclipai/adapter-gemini-local"; +import { + execute as grokExecute, + listGrokSkills, + syncGrokSkills, + testEnvironment as grokTestEnvironment, + sessionCodec as grokSessionCodec, +} from "@paperclipai/adapter-grok-local/server"; +import { + agentConfigurationDoc as grokAgentConfigurationDoc, + models as grokModels, +} from "@paperclipai/adapter-grok-local"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -349,6 +360,27 @@ const geminiLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: geminiAgentConfigurationDoc, }; +const grokLocalAdapter: ServerAdapterModule = { + type: "grok_local", + execute: grokExecute, + testEnvironment: grokTestEnvironment, + listSkills: listGrokSkills, + syncSkills: syncGrokSkills, + sessionCodec: grokSessionCodec, + sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined, + models: grokModels, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => ({ + command: readConfiguredCommand(config, "grok"), + detectCommand: readConfiguredCommand(config, "grok"), + installCommand: null, + }), + agentConfigurationDoc: grokAgentConfigurationDoc, +}; + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -486,6 +518,7 @@ function registerBuiltInAdapters() { cursorCloudAdapter, cursorLocalAdapter, geminiLocalAdapter, + grokLocalAdapter, openclawGatewayAdapter, hermesLocalAdapter, processAdapter, diff --git a/ui/package.json b/ui/package.json index 7286053f..4c3acec4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 948a3c8d..ddd5dacb 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -78,6 +78,11 @@ const adapterDisplayMap: Record = { description: "Local Gemini agent", icon: Gem, }, + grok_local: { + label: "Grok Build", + description: "Local Grok Build agent", + icon: Bot, + }, opencode_local: { label: "OpenCode", description: "Local multi-provider agent", diff --git a/ui/src/adapters/grok-local/config-fields.tsx b/ui/src/adapters/grok-local/config-fields.tsx new file mode 100644 index 00000000..13ab66a0 --- /dev/null +++ b/ui/src/adapters/grok-local/config-fields.tsx @@ -0,0 +1,51 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + DraftInput, + Field, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Paperclip stages it into the Grok workspace as Agents.md when possible."; + +export function GrokLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + ); +} diff --git a/ui/src/adapters/grok-local/index.ts b/ui/src/adapters/grok-local/index.ts new file mode 100644 index 00000000..13c579a0 --- /dev/null +++ b/ui/src/adapters/grok-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui"; +import { buildGrokLocalConfig } from "@paperclipai/adapter-grok-local/ui"; +import { GrokLocalConfigFields } from "./config-fields"; + +export const grokLocalUIAdapter: UIAdapterModule = { + type: "grok_local", + label: "Grok Build (local)", + parseStdoutLine: parseGrokStdoutLine, + ConfigFields: GrokLocalConfigFields, + buildAdapterConfig: buildGrokLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 0e2f27a3..9b2476d3 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; +import { grokLocalUIAdapter } from "./grok-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -56,6 +57,7 @@ function registerBuiltInUIAdapters() { codexLocalUIAdapter, cursorCloudUIAdapter, geminiLocalUIAdapter, + grokLocalUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index ddd164a5..56627f32 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; +import { grokLocalUIAdapter } from "./grok-local"; import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { @@ -182,4 +183,20 @@ describe("buildTranscript", () => { }, ]); }); + + it("coalesces grok_local streaming text fragments into one assistant entry", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "Hello " })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "world" })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" })}\n` }, + ], + grokLocalUIAdapter, + ); + + expect(entries).toEqual([ + { kind: "assistant", ts, text: "Hello world", delta: true }, + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); }); diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 786776fe..89f2c2b6 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -21,6 +21,7 @@ const KNOWN_DEFAULTS: Record = { codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, diff --git a/vitest.config.ts b/vitest.config.ts index f27929e7..3fe56779 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "packages/adapters/cursor-cloud", "packages/adapters/cursor-local", "packages/adapters/gemini-local", + "packages/adapters/grok-local", "packages/adapters/opencode-local", "packages/adapters/pi-local", "packages/plugins/sdk", From 9b6d2e6b7996dfdb3b1e92a1b31e84190daea99d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 10:13:22 -0700 Subject: [PATCH 14/53] chore(lockfile): refresh pnpm-lock.yaml (#6136) Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml. Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2042be3b..2053a175 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -219,6 +222,22 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/grok-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -592,6 +611,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -755,6 +777,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -10693,7 +10718,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 From 81d18f2d77af7ec2994ab5c46c51e3ec2f141169 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 16 May 2026 11:28:25 -0700 Subject: [PATCH 15/53] ci: speed up PR verify workflow (#6137) ## Thinking Path > - Paperclip orchestrates AI agents through a control-plane repo that relies on GitHub Actions as part of its release and verification safety net. > - The PR workflow in `.github/workflows/pr.yml` is the core CI path protecting pull requests before merge. > - Baseline measurement work in [PAPA-335](/PAPA/issues/PAPA-335) showed the old single `verify` job was the critical-path bottleneck, with general tests and build serialized together. > - Follow-up implementation in [PAPA-338](/PAPA/issues/PAPA-338) and [PAPA-339](/PAPA/issues/PAPA-339) split that work into parallel lanes and removed redundant clean-runner prebuild work. > - [PAPA-340](/PAPA/issues/PAPA-340) now needs real post-change PR workflow evidence, not local inference, to compare against the May 15, 2026 baseline and decide whether phase-2 work is still justified. > - This pull request publishes the already-implemented CI speedup branch so GitHub can run the actual `PR` workflow against it. > - The benefit is that CI timing decisions are based on measured runs from the exact workflow shape we intend to ship. ## What Changed - Split the PR workflow so `policy` fans out into separate `Typecheck + Release Registry`, grouped `General tests`, and `Build` jobs. - Kept the serialized server matrix, canary dry run, and e2e jobs intact while removing the old monolithic `verify` bottleneck. - Reworked grouped general-test execution in `scripts/run-vitest-stable.mjs` so the workflow can run balanced non-serialized lanes. - Replaced redundant clean-runner prebuild gates with the idempotent `ensure-build-deps` path used by the relevant CI entrypoints. ## Verification - `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/pr.yml'); puts 'yaml-ok'"` - `node scripts/run-vitest-stable.mjs --mode general --dry-run` - `node scripts/run-vitest-stable.mjs --mode general --group general-server --dry-run` - `node scripts/run-vitest-stable.mjs --mode general --group general-workspaces-a --dry-run` - `node scripts/run-vitest-stable.mjs --mode general --group general-workspaces-b --dry-run` - `pnpm test:run:general -- --group general-workspaces-b` - `pnpm test:run:general -- --group general-workspaces-a` - `pnpm test:run:general -- --group general-server` - `pnpm run typecheck:build-gaps` - `pnpm --filter @paperclipai/plugin-hello-world-example typecheck` ## Risks - Required-check and branch-protection settings may still reference the old single `verify` job name. - Parallel CI lanes can expose hidden ordering assumptions or clean-runner bootstrap gaps that local grouped dry-runs did not surface. - Because the branch is behind current `master`, merge conflicts or unrelated upstream drift could affect the measured runtime until the branch is rebased. > Checked `ROADMAP.md`; this work is CI throughput maintenance for the existing PR verification path, not duplicate feature work. ## Model Used - OpenAI Codex via Paperclip `codex_local`, GPT-5-class coding agent with repository read/write, shell execution, and GitHub CLI/tool use. The runtime does not expose a more specific backend model ID in-session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .github/workflows/pr.yml | 86 ++++++++++++++++++++++++++-- package.json | 4 +- scripts/prepare-server-ui-dist.sh | 17 +++++- scripts/release.sh | 4 ++ scripts/run-typecheck-build-gaps.mjs | 2 +- scripts/run-vitest-stable.mjs | 83 +++++++++++++++++++++++---- 6 files changed, 175 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fabaab1b..fa081796 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,7 +62,8 @@ jobs: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile fi - verify: + typecheck_release_registry: + name: Typecheck + Release Registry needs: [policy] runs-on: ubuntu-latest timeout-minutes: 20 @@ -88,12 +89,89 @@ jobs: - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps - - name: Run general test suites - run: pnpm test:run:general - - name: Verify release registry test coverage run: pnpm run test:release-registry + general_tests: + name: General tests (${{ matrix.group_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - group: general-server + group_label: server + - group: general-workspaces-a + group_label: workspaces-a + - group: general-workspaces-b + group_label: workspaces-b + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run grouped general test suites + run: pnpm test:run:general -- --group '${{ matrix.group }}' + + verify: + # Preserve the legacy required-check name while the underlying work runs in parallel. + name: verify + if: ${{ always() }} + needs: [typecheck_release_registry, general_tests, build] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Fail if any split verify lane failed + env: + TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }} + GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success" + test "$GENERAL_TESTS_RESULT" = "success" + test "$BUILD_RESULT" = "success" + + build: + name: Build + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build run: pnpm build diff --git a/package.json b/package.json index 152fe471..60b80d5a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", - "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general", - "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized", + "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general", + "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh index d43807b3..0c785d4a 100755 --- a/scripts/prepare-server-ui-dist.sh +++ b/scripts/prepare-server-ui-dist.sh @@ -3,13 +3,26 @@ set -euo pipefail # prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. # This keeps @paperclipai/server publish artifacts self-contained for static UI serving. +# When PAPERCLIP_RELEASE_REUSE_UI_DIST=1 and ui/dist already exists, reuse that +# output instead of rebuilding it again inside the release packaging flow. REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" UI_DIST="$REPO_ROOT/ui/dist" SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" -echo " -> Building @paperclipai/ui..." -pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +should_reuse_existing_ui_dist=false +case "${PAPERCLIP_RELEASE_REUSE_UI_DIST:-}" in + 1|true|TRUE|yes|YES) + should_reuse_existing_ui_dist=true + ;; +esac + +if [ "$should_reuse_existing_ui_dist" = true ] && [ -f "$UI_DIST/index.html" ]; then + echo " -> Reusing existing @paperclipai/ui dist output" +else + echo " -> Building @paperclipai/ui..." + pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +fi if [ ! -f "$UI_DIST/index.html" ]; then echo "Error: UI build output missing at $UI_DIST/index.html" diff --git a/scripts/release.sh b/scripts/release.sh index 9d27c9cf..35da947d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -198,6 +198,10 @@ fi set_cleanup_trap +# The release flow already prepares ui/dist before packaging. Reuse that output +# so server prepack does not rebuild the UI a second time during preview/publish. +export PAPERCLIP_RELEASE_REUSE_UI_DIST=1 + if [ "$skip_verify" = false ]; then release_info "" release_info "==> Step 1/7: Verification gate..." diff --git a/scripts/run-typecheck-build-gaps.mjs b/scripts/run-typecheck-build-gaps.mjs index 6210ab2a..e149990b 100644 --- a/scripts/run-typecheck-build-gaps.mjs +++ b/scripts/run-typecheck-build-gaps.mjs @@ -86,7 +86,7 @@ if (buildGapPackages.length === 0) { process.exit(0); } -run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "build"]); +run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]); for (const workspacePkg of buildGapPackages) { run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index c85c48aa..a3ef4ba4 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -49,6 +49,12 @@ let invocationIndex = 0; const serializedModeName = "serialized"; const generalModeName = "general"; const allModeName = "all"; +const generalServerGroupName = "general-server"; +const generalWorkspacesAGroupName = "general-workspaces-a"; +const generalWorkspacesBGroupName = "general-workspaces-b"; +const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; +const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); +const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; function walk(dir) { const entries = readdirSync(dir); @@ -117,6 +123,7 @@ function parseCliOptions(argv) { let mode = allModeName; let shardIndex = null; let shardCount = null; + let group = null; let dryRun = false; for (let index = 0; index < argv.length; index += 1) { @@ -163,6 +170,17 @@ function parseCliOptions(argv) { continue; } + if (arg === "--group") { + group = readOptionValue(argv, index, arg); + index += 1; + continue; + } + + if (arg.startsWith("--group=")) { + group = arg.slice("--group=".length); + continue; + } + fail(`Unknown argument "${arg}".`); } @@ -178,6 +196,14 @@ function parseCliOptions(argv) { fail("--shard-index/--shard-count are only valid with --mode serialized."); } + if (group !== null && mode !== generalModeName) { + fail("--group is only valid with --mode general."); + } + + if (group !== null && !generalGroupNames.includes(group)) { + fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`); + } + if (mode === serializedModeName) { const resolvedShardCount = shardCount ?? 1; const resolvedShardIndex = shardIndex ?? 0; @@ -189,6 +215,7 @@ function parseCliOptions(argv) { mode, shardIndex: resolvedShardIndex, shardCount: resolvedShardCount, + group: null, dryRun, }; } @@ -197,6 +224,7 @@ function parseCliOptions(argv) { mode, shardIndex: null, shardCount: null, + group, dryRun, }; } @@ -208,12 +236,14 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) { function runVitest(args, label) { console.log(`\n[test:run] ${label}`); invocationIndex += 1; - const testRoot = mkdtempSync(path.join(os.tmpdir(), `paperclip-vitest-${process.pid}-${invocationIndex}-`)); + const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp"; + const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)); + // Keep per-run paths compact so Unix socket fixtures stay under macOS path limits. const env = { ...process.env, - PAPERCLIP_HOME: path.join(testRoot, "home"), - PAPERCLIP_INSTANCE_ID: `vitest-${process.pid}-${invocationIndex}`, - TMPDIR: path.join(testRoot, "tmp"), + PAPERCLIP_HOME: path.join(testRoot, "h"), + PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`, + TMPDIR: path.join(testRoot, "t"), }; mkdirSync(env.PAPERCLIP_HOME, { recursive: true }); mkdirSync(env.TMPDIR, { recursive: true }); @@ -232,15 +262,38 @@ function runVitest(args, label) { } function runGeneralSuites(routeTests) { - const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); - for (const project of nonServerProjects) { - runVitest(["--project", project], `non-server project ${project}`); + for (const groupName of generalGroupNames) { + runGeneralGroup(routeTests, groupName); + } +} + +function runProjectGroup(projects, groupName) { + for (const project of projects) { + runVitest(["--project", project], `${groupName} project ${project}`); + } +} + +function runGeneralGroup(routeTests, groupName) { + if (groupName === generalServerGroupName) { + const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); + runVitest( + ["--project", "@paperclipai/server", ...excludeRouteArgs], + `${groupName} server suites excluding ${routeTests.length} serialized suites`, + ); + return; } - runVitest( - ["--project", "@paperclipai/server", ...excludeRouteArgs], - `server suites excluding ${routeTests.length} serialized suites`, - ); + if (groupName === generalWorkspacesAGroupName) { + runProjectGroup(generalWorkspacesAProjects, groupName); + return; + } + + if (groupName === generalWorkspacesBGroupName) { + runProjectGroup(generalWorkspacesBProjects, groupName); + return; + } + + fail(`Unknown group "${groupName}".`); } function runSerializedSuites(routeTests, shardIndex, shardCount) { @@ -283,6 +336,8 @@ if (options.dryRun) { mode: options.mode, shardIndex: options.shardIndex, shardCount: options.shardCount, + group: options.group, + availableGeneralGroups: generalGroupNames, serializedSuiteCount: routeTests.length, selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath), }, @@ -294,7 +349,11 @@ if (options.dryRun) { } if (options.mode === generalModeName || options.mode === allModeName) { - runGeneralSuites(routeTests); + if (options.group) { + runGeneralGroup(routeTests, options.group); + } else { + runGeneralSuites(routeTests); + } } if (options.mode === serializedModeName || options.mode === allModeName) { From 573e9ec909c772001064f83c319566df160a5719 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 16 May 2026 11:48:51 -0700 Subject: [PATCH 16/53] fix(grok-local): restore turn boundaries in streaming reasoning text (#6142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The `grok-local` adapter streams reasoning text to the issue "Working..." panel as the grok CLI runs > - The `grok` CLI's `--output-format streaming-json` mode silently drops the `\n` separator between reasoning turns around tool calls > - Consecutive `thought` chunks (e.g. `` "`" `` followed by `"The"`) arrive with no intervening whitespace event, so the UI's `delta: true` concatenator merged them into run-on text like `"…planningGreat, now I have the issue descriptionThe only co"` > - This PR adds a small turn-boundary helper that detects sentence boundaries in the upstream `thought` stream and inserts a single `\n` only when the previous chunk ended with sentence punctuation (or a balanced closing backtick) AND the next chunk begins a new uppercase sentence > - The benefit is readable streaming reasoning in the UI without changing how completed messages are stored ## What Changed - Added `packages/adapters/grok-local/src/shared/turn-boundary.ts` with per-stream state (last chunk + backtick parity) and a `restoreTurnBoundary()` helper that inserts `\n` only between balanced, sentence-terminated `thought` chunks - Wired the helper into `parseGrokJsonl` (server) and added a new `createGrokStdoutParser` factory used by `grokLocalUIAdapter` for the live "Working..." panel - Added focused tests in `shared/turn-boundary.test.ts`, plus regression assertions in `server/parse.test.ts` and `ui/parse-stdout.test.ts` ## Verification - `pnpm --filter @paperclip/grok-local test` — 23/23 adapter tests pass - `pnpm --filter @paperclip/grok-local typecheck` and UI typecheck — clean - Replayed an actual broken `grok 0.1.210` stream from the report; previously-merged boundaries (`` `ls`The ``, `returned:Confirmed`) now render with a separating newline; chunks inside un-closed backtick spans are left alone ## Risks - Low risk. Boundary insertion only fires when prev ends with `.`/`!`/`?`/balanced `` ` `` and next begins with an uppercase ≥2-char word, with no whitespace on either side. Worst case: a rare missed split or a misplaced newline inside reasoning — both purely cosmetic and confined to the live streaming panel. ## Model Used - Claude Opus 4.7 (claude-opus-4-7), Anthropic, extended thinking + tool use via Claude Code ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../grok-local/src/server/parse.test.ts | 31 +++++++++++ .../adapters/grok-local/src/server/parse.ts | 4 +- .../src/shared/turn-boundary.test.ts | 51 ++++++++++++++++++ .../grok-local/src/shared/turn-boundary.ts | 54 +++++++++++++++++++ packages/adapters/grok-local/src/ui/index.ts | 2 +- .../grok-local/src/ui/parse-stdout.test.ts | 45 +++++++++++++++- .../grok-local/src/ui/parse-stdout.ts | 32 +++++++++-- ui/src/adapters/grok-local/index.ts | 3 +- 8 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 packages/adapters/grok-local/src/shared/turn-boundary.test.ts create mode 100644 packages/adapters/grok-local/src/shared/turn-boundary.ts diff --git a/packages/adapters/grok-local/src/server/parse.test.ts b/packages/adapters/grok-local/src/server/parse.test.ts index 5b31cf88..213f8c02 100644 --- a/packages/adapters/grok-local/src/server/parse.test.ts +++ b/packages/adapters/grok-local/src/server/parse.test.ts @@ -28,6 +28,37 @@ describe("parseGrokJsonl", () => { expect(parsed.errorMessage).toBe("Authentication required"); }); + + it("separates reasoning turns that grok streaming-json glues together", () => { + // PAPA-349: at turn boundaries grok drops the newline between turns; the + // aggregated thought should still read as two paragraphs. + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "thought", data: "The user uses `" }), + JSON.stringify({ type: "thought", data: "ls" }), + JSON.stringify({ type: "thought", data: "`" }), + JSON.stringify({ type: "thought", data: "The" }), + JSON.stringify({ type: "thought", data: " `" }), + JSON.stringify({ type: "thought", data: "ls" }), + JSON.stringify({ type: "thought", data: "`" }), + JSON.stringify({ type: "thought", data: " returned" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), + ].join("\n")); + + expect(parsed.thought).toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("preserves assistant `text` chunks verbatim (no boundary heuristic)", () => { + // PAPA-349 review feedback: the turn-boundary helper is scoped to the + // reasoning stream only. Final assistant text is stored unmodified so + // user-visible responses cannot be reshaped by the heuristic. + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "text", data: "Done." }), + JSON.stringify({ type: "text", data: "Next" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), + ].join("\n")); + + expect(parsed.summary).toBe("Done.Next"); + }); }); describe("isGrokUnknownSessionError", () => { diff --git a/packages/adapters/grok-local/src/server/parse.ts b/packages/adapters/grok-local/src/server/parse.ts index eb5ab92d..7fc415a2 100644 --- a/packages/adapters/grok-local/src/server/parse.ts +++ b/packages/adapters/grok-local/src/server/parse.ts @@ -1,4 +1,5 @@ import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { applyTurnBoundary, createTurnBoundaryState } from "../shared/turn-boundary.js"; export interface ParsedGrokJsonl { sessionId: string | null; @@ -32,6 +33,7 @@ export function parseGrokJsonl(stdout: string): ParsedGrokJsonl { let errorMessage: string | null = null; const thoughtParts: string[] = []; const textParts: string[] = []; + const thoughtBoundary = createTurnBoundaryState(); for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -43,7 +45,7 @@ export function parseGrokJsonl(stdout: string): ParsedGrokJsonl { const type = asString(event.type, "").trim(); if (type === "thought") { const text = asString(event.data, ""); - if (text) thoughtParts.push(text); + if (text) thoughtParts.push(applyTurnBoundary(thoughtBoundary, text)); continue; } diff --git a/packages/adapters/grok-local/src/shared/turn-boundary.test.ts b/packages/adapters/grok-local/src/shared/turn-boundary.test.ts new file mode 100644 index 00000000..1fc27acf --- /dev/null +++ b/packages/adapters/grok-local/src/shared/turn-boundary.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js"; + +function run(chunks: string[]): string { + const state = createTurnBoundaryState(); + return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join(""); +} + +describe("applyTurnBoundary", () => { + it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => { + expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"])) + .toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => { + expect(run(["returned", ":", "Confirmed", ":", " 4 files"])) + .toBe("returned:\nConfirmed: 4 files"); + }); + + it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => { + expect(run(["render `", "React", "` then "])) + .toBe("render `React` then "); + }); + + it("leaves natural token streams with proper whitespace alone", () => { + expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"])) + .toBe("The user wants me to:\n1. List"); + }); + + it("does not insert a separator when the next chunk starts with whitespace", () => { + expect(run(["function", ".", " They"])) + .toBe("function. They"); + }); + + it("does not insert a separator when the next chunk starts lowercase", () => { + expect(run(["`", "ls", "`"])) + .toBe("`ls`"); + }); + + it("does not insert a separator when the next chunk is a single character", () => { + expect(run([":", "A"])) + .toBe(":A"); + }); + + it("does not insert a separator after a self-contained backtick span in a single chunk", () => { + // Greptile review: a chunk like "`ls`" is a balanced span; the following + // capitalized word should be treated as a continuation, not a new turn. + expect(run(["`ls`", "Then"])) + .toBe("`ls`Then"); + }); +}); diff --git a/packages/adapters/grok-local/src/shared/turn-boundary.ts b/packages/adapters/grok-local/src/shared/turn-boundary.ts new file mode 100644 index 00000000..267b784e --- /dev/null +++ b/packages/adapters/grok-local/src/shared/turn-boundary.ts @@ -0,0 +1,54 @@ +// Grok's `--output-format streaming-json` mode emits `thought` and `text` events +// token-by-token. Between reasoning turns (around tool calls) it drops the `\n` +// separator that the non-streaming `--output-format json` mode includes in the +// aggregated `thought` field. This helper inserts a single `\n` when a new chunk +// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``). + +export interface TurnBoundaryState { + lastChunk: string; + backtickParity: 0 | 1; +} + +export function createTurnBoundaryState(): TurnBoundaryState { + return { lastChunk: "", backtickParity: 0 }; +} + +function countBackticks(text: string): number { + let count = 0; + for (const ch of text) if (ch === "`") count += 1; + return count; +} + +function endsWithSentenceClose(ch: string): boolean { + return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";"; +} + +export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string { + if (!incoming) return incoming; + + let output = incoming; + const prev = state.lastChunk; + if ( + prev && + !/\s$/.test(prev) && + !/^\s/.test(incoming) && + /^[A-Z]/.test(incoming) && + incoming.length >= 2 + ) { + const lastChar = prev[prev.length - 1]!; + // Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the + // stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a + // self-contained span and the following capitalized word is a continuation, + // not a new turn. + const closingLoneBacktick = + prev === "`" && state.backtickParity === 0; + const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick; + if (looksLikeNewTurn) { + output = `\n${incoming}`; + } + } + + state.lastChunk = incoming; + state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1; + return output; +} diff --git a/packages/adapters/grok-local/src/ui/index.ts b/packages/adapters/grok-local/src/ui/index.ts index a37e3d82..605accd8 100644 --- a/packages/adapters/grok-local/src/ui/index.ts +++ b/packages/adapters/grok-local/src/ui/index.ts @@ -1,2 +1,2 @@ -export { parseGrokStdoutLine } from "./parse-stdout.js"; +export { parseGrokStdoutLine, createGrokStdoutParser } from "./parse-stdout.js"; export { buildGrokLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.test.ts b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts index d60b5b61..96276149 100644 --- a/packages/adapters/grok-local/src/ui/parse-stdout.test.ts +++ b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseGrokStdoutLine } from "./parse-stdout.js"; +import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js"; describe("parseGrokStdoutLine", () => { const ts = "2026-05-15T00:00:00.000Z"; @@ -25,3 +25,46 @@ describe("parseGrokStdoutLine", () => { ]); }); }); + +describe("createGrokStdoutParser", () => { + const ts = "2026-05-15T00:00:00.000Z"; + + function thoughtTexts(chunks: string[]): string { + const parser = createGrokStdoutParser(); + return chunks + .map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts)) + .flat() + .map((entry) => entry.kind === "thinking" ? entry.text : "") + .join(""); + } + + it("inserts a newline between reasoning turns that grok streaming-json glues together", () => { + // Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned" + expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"])) + .toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => { + expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"])) + .toBe("returned:\nConfirmed: 4 files"); + }); + + it("resets state between independent transcript builds", () => { + const parser = createGrokStdoutParser(); + parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts); + parser.reset(); + expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([ + { kind: "thinking", ts, text: "Second", delta: true }, + ]); + }); + + it("does not modify assistant `text` chunks", () => { + // PAPA-349 review feedback: keep final assistant text streaming verbatim; + // the boundary heuristic is scoped to reasoning. + const parser = createGrokStdoutParser(); + parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts); + expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([ + { kind: "assistant", ts, text: "Next", delta: true }, + ]); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.ts b/packages/adapters/grok-local/src/ui/parse-stdout.ts index b82a36b4..476dd88a 100644 --- a/packages/adapters/grok-local/src/ui/parse-stdout.ts +++ b/packages/adapters/grok-local/src/ui/parse-stdout.ts @@ -1,4 +1,5 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { applyTurnBoundary, createTurnBoundaryState, type TurnBoundaryState } from "../shared/turn-boundary.js"; function safeJsonParse(text: string): unknown { try { @@ -24,7 +25,11 @@ function extractErrorText(value: unknown): string { return asString(record.message) || asString(record.detail) || asString(record.code); } -export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] { +function parseLineInternal( + line: string, + ts: string, + thoughtBoundary: TurnBoundaryState, +): TranscriptEntry[] { const parsed = asRecord(safeJsonParse(line)); if (!parsed) { return [{ kind: "stdout", ts, text: line }]; @@ -34,12 +39,14 @@ export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] if (type === "thought") { const text = asString(parsed.data); - return text ? [{ kind: "thinking", ts, text, delta: true }] : []; + if (!text) return []; + return [{ kind: "thinking", ts, text: applyTurnBoundary(thoughtBoundary, text), delta: true }]; } if (type === "text") { const text = asString(parsed.data); - return text ? [{ kind: "assistant", ts, text, delta: true }] : []; + if (!text) return []; + return [{ kind: "assistant", ts, text, delta: true }]; } if (type === "error") { @@ -59,3 +66,22 @@ export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }]; } + +export function createGrokStdoutParser() { + let thoughtBoundary = createTurnBoundaryState(); + return { + parseLine(line: string, ts: string): TranscriptEntry[] { + return parseLineInternal(line, ts, thoughtBoundary); + }, + reset() { + thoughtBoundary = createTurnBoundaryState(); + }, + }; +} + +// Stateless fallback for callers that haven't migrated to the stateful factory. +// Without state, consecutive thought chunks at reasoning-turn boundaries can +// still appear merged; prefer createGrokStdoutParser for live transcripts. +export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] { + return parseLineInternal(line, ts, createTurnBoundaryState()); +} diff --git a/ui/src/adapters/grok-local/index.ts b/ui/src/adapters/grok-local/index.ts index 13c579a0..31b7ffe2 100644 --- a/ui/src/adapters/grok-local/index.ts +++ b/ui/src/adapters/grok-local/index.ts @@ -1,5 +1,5 @@ import type { UIAdapterModule } from "../types"; -import { parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui"; +import { createGrokStdoutParser, parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui"; import { buildGrokLocalConfig } from "@paperclipai/adapter-grok-local/ui"; import { GrokLocalConfigFields } from "./config-fields"; @@ -7,6 +7,7 @@ export const grokLocalUIAdapter: UIAdapterModule = { type: "grok_local", label: "Grok Build (local)", parseStdoutLine: parseGrokStdoutLine, + createStdoutParser: createGrokStdoutParser, ConfigFields: GrokLocalConfigFields, buildAdapterConfig: buildGrokLocalConfig, }; From 93cd933f7929eb5c90f4af87685cd733b231f771 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 16 May 2026 19:32:24 -0500 Subject: [PATCH 17/53] docs: add v2026.517.0 release changelog (#6150) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - Stable releases need a reviewer-readable changelog artifact that summarizes what changed for operators and contributors. > - The last stable release tag is `v2026.513.0`, and the requested release date is 2026-05-17. > - The release changelog skill maps that date to stable version `2026.517.0` and requires the range `v2026.513.0..origin/master`. > - I reviewed the commits, PR metadata, migration/API surfaces, and contributor attribution in that range. > - This pull request adds the `releases/v2026.517.0.md` changelog for human release sign-off. > - The benefit is a stable release note artifact that is ready to review before publishing the release. ## What Changed - Added `releases/v2026.517.0.md` for the 2026-05-17 stable release. - Summarized user-facing highlights, improvements, fixes, and contributor attribution from `v2026.513.0..origin/master`. - Omitted Breaking Changes and Upgrade Guide sections after checking for destructive migrations, removed API surfaces, and breaking-change commit signals. ## Verification - `./scripts/release.sh stable --date 2026-05-17 --print-version` -> `2026.517.0`. - `git tag --list 'v*' --sort=-version:refname | head -10` confirmed `v2026.513.0` is the latest stable tag. - `git log v2026.513.0..origin/master --oneline --no-merges` reviewed the 16 release-range commits. - `git diff --name-only v2026.513.0..origin/master -- .changeset` returned no changeset files. - `git log v2026.513.0..origin/master --format='%s' | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true` returned no breaking-change signals. - `git diff --name-only v2026.513.0..origin/master -- packages/db/src/migrations packages/db/src/schema server/src/routes server/src/api server/src` reviewed the DB/API/server touchpoints; only an additive document-lock migration appeared in the DB schema/migration path. - `test -f releases/v2026.517.0.md` passed. - `rg -n -- '-canary|canary/' releases/v2026.517.0.md || true` returned no canary filename/title language. - `git diff --cached --check` passed before commit. ## Risks - Low risk: docs-only release changelog addition. - Changelog grouping is editorial; reviewers may want wording or prioritization changes before release publication. - Contributor attribution intentionally excludes bot accounts and Paperclip founders from the Contributors section per the release changelog skill. > Checked [`ROADMAP.md`](ROADMAP.md); this is release documentation, not new roadmap-level core feature work. ## Model Used - OpenAI Codex, GPT-5 coding agent via Paperclip `codex_local`, with shell, git, GitHub CLI, and repository file editing enabled. Exact backend sub-version and context window were not surfaced by the Paperclip runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- releases/v2026.517.0.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 releases/v2026.517.0.md diff --git a/releases/v2026.517.0.md b/releases/v2026.517.0.md new file mode 100644 index 00000000..5ec09546 --- /dev/null +++ b/releases/v2026.517.0.md @@ -0,0 +1,30 @@ +# v2026.517.0 + +> Released: 2026-05-17 + +## Highlights + +- **Grok Build is now a first-class local runtime** - Paperclip can configure and run `grok_local` through the built-in adapter registry, with server, UI, CLI, transcript parsing, session handling, diagnostics, and focused adapter coverage. ([#6087](https://github.com/paperclipai/paperclip/pull/6087), @devinfoley) +- **Issue documents can be locked for safer handoffs** - Board-managed document locks preserve approved snapshots, route agent writes to derived documents, expose lock state in the UI and API, and record lock activity. ([#6009](https://github.com/paperclipai/paperclip/pull/6009), @cryppadotta) +- **The Issues board scales better with large columns** - High-volume Kanban columns now support compact cards, collapsed cold lanes, per-column reveal limits, persisted density controls, and matching UI coverage. ([#5309](https://github.com/paperclipai/paperclip/pull/5309), @eibrahim) +- **Internationalization groundwork is in place** - The board UI now has an i18next foundation, locale validation, runtime package declarations, and a validated catalog of supported locale resources for future translated surfaces. ([#6058](https://github.com/paperclipai/paperclip/pull/6058), [#5943](https://github.com/paperclipai/paperclip/pull/5943), [#6070](https://github.com/paperclipai/paperclip/pull/6070), @cryppadotta) + +## Improvements + +- **Live issue document updates refresh open boards** - Document create, update, restore, and delete activity now invalidates the relevant list, active-document, and revision caches without forcing operators to reload the issue page. ([#6005](https://github.com/paperclipai/paperclip/pull/6005), @cryppadotta) +- **Cloudflare sandbox execution is more reliable** - The Cloudflare bridge now has larger execution capacity, SSE keepalives, safer stdout handling, sandbox-aware probe budgets, and a corrected Pi sandbox install path. ([#5967](https://github.com/paperclipai/paperclip/pull/5967), @devinfoley) +- **LLM Wiki packaging and plugin migrations are safer** - The packaged plugin now includes required assets, uses a visible bootstrap template, and validates and applies spaces migrations through explicit namespace-safe SQL. ([#6010](https://github.com/paperclipai/paperclip/pull/6010), @cryppadotta) +- **PR verification runs faster** - The GitHub PR workflow now fans out typecheck, release-registry checks, grouped general tests, and build work instead of serializing them behind one monolithic verify job. ([#6137](https://github.com/paperclipai/paperclip/pull/6137), @devinfoley) +- **Multilingual issue flows have regression coverage** - Board issue creation, server issue/comment/document round trips, and scoped wake payload rendering now cover Chinese, Japanese, and Hindi text preservation. ([#6069](https://github.com/paperclipai/paperclip/pull/6069), @cryppadotta) + +## Fixes + +- **Grok reasoning streams regain readable turn boundaries** - Paperclip now restores missing line breaks between streamed Grok reasoning chunks so the live Working panel no longer merges separate thoughts into run-on text. ([#6142](https://github.com/paperclipai/paperclip/pull/6142), @devinfoley) +- **Identifier-based wakeups bind to the right project workspace** - Heartbeat wakeup context now carries the resolved `projectId` and canonicalizes identifier-style issue references before workspace resolution. ([#6026](https://github.com/paperclipai/paperclip/pull/6026), @devinfoley) +- **Company export tolerates missing local run logs** - Optional comment attribution metadata now degrades gracefully when historical heartbeat run log files are missing, preserving exportable issue comments. ([#5960](https://github.com/paperclipai/paperclip/pull/5960), @cryppadotta) + +## Contributors + +Thank you to everyone who contributed to this release! + +@eibrahim From 7bbdfb69dfb8b073d0aebfb4798102914871e895 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 16 May 2026 20:34:28 -0500 Subject: [PATCH 18/53] [codex] Enable Grok adapter canary publishing (#6154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip publishes its CLI, server, UI, and adapter packages through the shared release workflow. > - Canary releases are driven by the GitHub release workflow on pushes to `master`. > - The release workflow does not publish every public package automatically; it uses `scripts/release-package-manifest.json` as the CI enrollment source of truth. > - The Grok adapter is public and already present in the manifest, but its `publishFromCi` flag was still disabled. > - Because of that flag, normal canary publishes skipped `@paperclipai/adapter-grok-local` even when the main package received a canary. > - This pull request enables Grok in the release manifest so future canary runs include it. > - The benefit is that Grok adapter canaries stay aligned with the rest of the package release set. ## What Changed - Set `packages/adapters/grok-local` / `@paperclipai/adapter-grok-local` to `publishFromCi: true` in `scripts/release-package-manifest.json`. ## Verification - `node ./scripts/release-package-map.mjs check` - `node ./scripts/release-package-map.mjs list | grep '@paperclipai/adapter-grok-local'` - `pnpm test:release-registry` ## Risks - Low risk: this is a release manifest-only change. - Future canary releases will attempt to publish `@paperclipai/adapter-grok-local`; this assumes the package remains publishable and trusted publishing/package access are correctly configured for the existing npm package. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent with shell, git, and GitHub tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- scripts/release-package-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 81bb12f2..effc2d54 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -37,7 +37,7 @@ { "dir": "packages/adapters/grok-local", "name": "@paperclipai/adapter-grok-local", - "publishFromCi": false + "publishFromCi": true }, { "dir": "packages/adapters/opencode-local", From 3e6610fb938d04638fa578a1fc0d119b434fa2e4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 16 May 2026 20:36:22 -0500 Subject: [PATCH 19/53] docs(skills): add release-changelog-discord-message skill (#6152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `.agents/skills/release-changelog-discord-message/SKILL.md` — the companion to `.agents/skills/release-changelog/`. The changelog skill produces `releases/vYYYY.MDD.P.md`; this one turns that into the single copy-pasteable Discord post in dotta's voice and attaches it as the `discord_announcement` document on the release issue. Includes: - dotta's instructions near-verbatim from PAP-3687 ("This is for discord — try to follow my format. If I have a section where I think about the future, pull from recent issues we're working on etc.") - The three previous Discord announcements (v2026.403.0, v2026.416.0, v2026.427.0) **verbatim** as canonical voice references - Format template + language tips (ALL CAPS for excitement, emoji-shortcode-per-highlight, first-person voice, opener/closer brand bookends) - Workflow tied to the existing release issue + `discord_announcement` document - Review checklist (version match, contributor list dedup, real "what's next" themes, no invented work) Resolves PAP-9524. ## Test plan - [ ] dotta eyeballs voice + structure against the three prior posts - [ ] On the next release, run this skill end-to-end and confirm the `discord_announcement` document on the release issue matches the format Co-authored-by: Paperclip --- .../SKILL.md | 406 ++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 .agents/skills/release-changelog-discord-message/SKILL.md diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md new file mode 100644 index 00000000..df6c8592 --- /dev/null +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -0,0 +1,406 @@ +--- +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. +--- + +# Release Discord Announcement Skill + +Write the Discord release announcement for the **stable** Paperclip release. + +This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill +generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into +a single copy-pasteable Discord block, in dotta's voice, and posts it as the +`discord_announcement` document on the release issue. + +## What dotta said + +> This is for discord — try to follow my format. If I have a section where I +> think about the future, pull from recent issues we're working on etc. + +The Discord announcement is **not** the changelog. The changelog is exhaustive; +the announcement is opinionated, in-voice, and built around the same handful of +shipped highlights plus a real "what's next" + "what's on my mind" pulled from +current Paperclip work — not invented. + +## When to use + +- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the + release worktree/PR. +- When the release issue (the one assigned by the release routine) asks for a + Discord announcement, or has a `discord_announcement` document that needs to + be refreshed for a new date/version. +- Never run this in isolation. The version, date, contributor list, and + highlight set MUST match the matching changelog file — if the changelog has + been updated, refresh this too. + +## Output + +A single fenced markdown code block, ready to paste into Discord. Attached as +issue document key `discord_announcement` on the release issue, and pasted +verbatim into a comment on that issue so the human can copy it out. + +```bash +PUT /api/issues/{releaseIssueId}/documents/discord_announcement +{ + "title": "Discord announcement", + "format": "markdown", + "body": "", + "baseRevisionId": "" +} +``` + +If the document already exists, fetch it first and pass the current +`baseRevisionId`. Never overwrite silently — if the version has changed since +the document was last written, mention what changed in the issue comment. + +## Format (follow this template) + +Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the +Unicode emoji. Discord renders the shortcodes; the changelog file uses prose. + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip: + +OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others + +## Highlights + +:emoji: **Feature Name** - one-sentence description in dotta's voice. +:emoji: **Feature Name** - … +:emoji: **Feature Name** - … + +... and a long tail of {flavor of the rest}. Read the [full release notes](). + +## WHATS NEXT (:motorway: Roadmap) + +* **Theme A** - one-line forward-looking blurb +* **Theme B** - … +* **Theme C** - … + +## What's on my mind + +* **Topic** - what's bugging dotta / what's queued / open questions +* **Topic** - … + +## PRESS (optional — only if there is real press) + +* **Outlet / Person** - what happened ([link]()) + +## WHAT I NEED FROM YOU (optional — only if there's a real ask) + +FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one +TELL ME if you're using Paperclip in your business - I want to meet you + +## Community + +Thank you to everyone who contributed to this release! + +``` +@username1, @username2, @username3 +``` + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a +thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md + +||@everyone|| +``` + +Notes on the template: + +- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are + part of the brand — keep them. +- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style + and stay consistent within a single post. +- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings + exactly once when the spoiler is removed by the poster. + +## Language tips + +These are extracted from how dotta has written the last several announcements. +Mimic this register; do not invent a "professional" tone. + +- **First person, conversational.** "I want to meet companies using Paperclip", + "what's on my mind", "if that's you let me know". Not "Paperclip is excited + to announce". +- **ALL CAPS for excitement and asks**, especially in the opener, the section + headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not + ALL-CAPS feature descriptions. +- **One emoji shortcode per highlight bullet**, picked to evoke the feature + (`:lock:` for secrets, `:brain:` for planning, `:mag:` for search, + `:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for + history/restore, `:thread:` for threads, etc.). +- **Highlight bullets are one sentence**, opinionated, told from the user's + perspective — "the cloud-secrets prereq is real now", not "added support + for…". +- **Tail line after highlights** wraps the rest in a single sentence and links + to the full release notes ("… and a long tail of {flavor}. Read the [full + release notes](url)."). +- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5 + bullets is the right size. Pull these from active goals, in-flight projects, + and recent issues the team is working on — do not invent themes. +- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps, + philosophical positioning ("we're the human control plane for ai labor"), + invitations ("if you've ever wanted to write about how you use Paperclip, + hit me up"). Pull real tensions from recent issues/comments; do not invent. +- **Press section** is optional. Only include it if there is real press in the + release window (a tweet, a podcast, a talk, a star milestone). No press → + drop the section entirely. +- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask + (follow the twitter, intros, beta sign-ups). No real ask → drop it. +- **Community** is the same contributors list that's in the changelog file, + fenced in a triple-backtick block, comma-separated `@username, @username`. + Exclude bots and Paperclip founders, same rules as the changelog skill. +- **The "In Summary" mission line** evolves slowly. Use the most recent + variant unless dotta tells you otherwise. Recent variants: + - "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK" + - "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY" + - "Paperclip will be _the_ control plane for AI agents in **every** company." +- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip: + :paperclip:`. Keep it. + +## Workflow + +1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`. + Use the version and contributor list from that file — never re-derive them. +2. Read the **release issue thread** (the one assigned to you that ran the + release routine) — comments + linked issues + recent issues in the company + are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes, + not invented ones. +3. Re-read the three verbatim examples below — they're the canonical voice. +4. Draft the announcement using the template above. +5. PUT it as the `discord_announcement` document on the release issue (see + "Output" above). If updating, send the latest `baseRevisionId`. +6. Post a comment on the release issue that includes the announcement inside a + single fenced markdown code block, so dotta can copy-paste it into Discord + without opening the document. + +Do not publish to Discord. This skill only prepares the artifact. + +## Verbatim previous examples + +Three previous Discord announcements from dotta, included **verbatim** as the +ground-truth examples for voice, structure, and emoji usage. When in doubt, +match these. + +### Example 1 — v2026.403.0 + +``` +CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip: + +## Highlights + +:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work +:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning +:page_with_curl: **Document revisions** - you can now restore old versions of your documents +:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables +:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings +:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy + +PLUS **tons** of improvements aound adapters, bugfixes, qol + +## COMMUNITY + +HUGE THANKS to the contributors with commits in this release: + +``` +@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai +``` + +## WHATS NEXT (ROADMAP) + +* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly +* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider + +PLUS: just dealing with the excellent PRs we have sitting in our inbox. + +**What's also on my mind (coming soonish)** + +* MAXIMIZER MODE - for when you've got a dream and tokens to burn +* Artifacts, work products, and deployments +* CEO Chat +* Stronger agent defaults + +## PRESS + +I've been doing my part to spread the word about Paperclip + +* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407) +* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345) +* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377) +* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613) +* ... and a couple others that will be released in a few days + +## SUCCESS STORIES + +* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing +* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment. +* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is. +* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it. +* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works. + +## IN SUMMARY + +I know there are still some rough edges, but + +Paperclip will be *the* control plane for AI agents in **every** company. + +and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES HERE + +https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0 + +||@everyone|| +``` + +### Example 2 — v2026.416.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip: + +## Highlights + +This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer + +:thread: Issue chat threads now are a conversation more than comments +:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task) +:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip +:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work + +And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0) + +## COMMUNITY + +INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release: + +``` +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6 +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Multi-human users** - in the last stages of testing, Paperclip is better with teams +* **Memory Infrastructure** - your agents will remember everything about yoru business +* **Sandbox execution** - run your agents anywhere + +## What's on my mind + +* I want to meet with companies who are using Paperclip in their business - if that's you let me know +* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already! +* We still need to get better at reviewing your PRs and we're improving our process every day +* "Zero-human company" language has to go - we're the human control plane for ai labor +* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon. + +## PRESS + +* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931) +* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) ! + +## IN SUMMARY + +PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY + +If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0 + +||@everyone|| +``` + +### Example 3 — v2026.427.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip: + +THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping + +## Highlights + +:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance +:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you +:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning +:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads +:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers +... and *tons* of other improvements and bugfixes. + +## Community + +Thank you to everyone who contributed to this release! + +``` +@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done +* **Secrets Service v2** - an important prereq for Paperclip cloud +* **Artifacts, memory, and knowledge** +* **Conference Room** aka CEO/Agent Chat + +## What's on my mind + +* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these +* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed +* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing +* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team + +## Press + +I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998) + +## WHAT I NEED FROM YOU + +FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md + +||@everyone|| +``` + +## Review checklist + +Before handing off: + +1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly. +2. Contributor list matches the changelog (same exclusions: bots, founders). +3. Highlights are a subset of the changelog Highlights — same shipped features, + not invented or pre-alpha work. +4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues / + active goals — not invented themes. +5. Section style (UPPERCASE vs Title Case) is internally consistent. +6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:` + and `||@everyone||` is the very last line. +7. Document `discord_announcement` is updated on the release issue, and the + announcement is also posted in a comment inside a fenced code block. + +This skill never posts to Discord. It only prepares the announcement artifact. From 705c1b8d8115654835e1d902f3d5d015302c13b5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 17 May 2026 16:30:34 -0500 Subject: [PATCH 20/53] [codex] Add routine env secrets support (#6212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Scheduled routines are the control-plane path for recurring agent work. > - Routines already had dispatch/history, but their runtime environment did not carry routine-owned secret bindings through execution. > - Operators need routine-specific secrets that can override project/agent env without exposing secret values in history, logs, or access events. > - This pull request adds the routine env runtime contract, wires it into execution, and makes the routine UI/history surfaces show safe secret metadata. > - The benefit is that routine executions can use scoped secret refs predictably while preserving company boundaries and auditability. ## What Changed - Added routine env persistence/runtime support, including `routines.env`, `routine_runs.routine_revision_id`, revision snapshots, and idempotent migration `0086_routine_env_runtime_contract`. - Resolved routine env during heartbeat adapter config assembly with precedence `agent < project < routine` and secret access events recorded against the routine consumer. - Added secret binding synchronization for routine create/update/restore flows and guarded cross-company, missing, disabled, and deleted secret cases. - Added a Secrets tab to routine detail, env/secret history diff rendering, and Storybook coverage for the new UI states. - Added server/UI regression tests, including an embedded-Postgres QA path for routine secret execution and restore behavior. - Updated implementation/database docs for routine env and secret-binding behavior. ## Verification - `pnpm install --frozen-lockfile` after rebasing onto `public-gh/master` to refresh workspace links for the newly-added upstream Grok adapter package. - `pnpm exec vitest run server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/routines-service.test.ts server/src/__tests__/secrets-service.test.ts server/src/__tests__/qa-routine-secrets-e2e.test.ts ui/src/components/RoutineHistoryTab.test.tsx` passed: 5 files, 92 tests. - `pnpm -r typecheck` passed across the workspace. - `pnpm build` passed. Vite emitted the existing large-chunk/dynamic-import warnings. - UI screenshots were captured locally during QA in `artifacts/pap-9521/` and `artifacts/pap-9522/`; generated screenshots are not committed to avoid adding binary artifacts to the repo. ## Risks - Migration risk is limited by `IF NOT EXISTS` guards for the new columns, FK, and index, and the migration is ordered as `0086` immediately after upstream `0085`. - Runtime behavior changes env precedence for routine executions by adding routine env as the highest-precedence layer; tests cover agent/project/routine precedence. - Secret handling is security-sensitive; tests cover value-free manifests/events/errors, disabled/missing/deleted secrets, and cross-company rejection. - UI history now renders routine env/secret diffs; tests and Storybook stories cover the main rendering paths. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with shell/tool use and medium reasoning effort. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DATABASE.md | 4 + doc/SPEC-implementation.md | 4 +- .../0086_routine_env_runtime_contract.sql | 8 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/routines.ts | 5 +- packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/routine.ts | 6 + packages/shared/src/validators/routine.ts | 3 + .../__tests__/heartbeat-project-env.test.ts | 132 ++++- .../__tests__/qa-routine-secrets-e2e.test.ts | 458 ++++++++++++++++++ server/src/__tests__/routines-service.test.ts | 88 ++++ server/src/__tests__/secrets-service.test.ts | 110 +++++ server/src/services/heartbeat.ts | 124 ++++- server/src/services/routines.ts | 80 ++- server/src/services/secrets.ts | 85 +++- ui/src/components/RoutineHistoryTab.test.tsx | 149 ++++++ ui/src/components/RoutineHistoryTab.tsx | 202 +++++++- ui/src/pages/RoutineDetail.tsx | 60 ++- .../stories/routine-secrets.stories.tsx | 257 ++++++++++ 20 files changed, 1736 insertions(+), 50 deletions(-) create mode 100644 packages/db/src/migrations/0086_routine_env_runtime_contract.sql create mode 100644 server/src/__tests__/qa-routine-secrets-e2e.test.ts create mode 100644 ui/storybook/stories/routine-secrets.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2e0ad661..887f8d9d 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -165,6 +165,10 @@ Paperclip stores secret metadata and versions in: - `company_secrets` - `company_secret_versions` +- `company_secret_bindings` +- `secret_access_events` + +Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`. For local/default installs, the active provider is `local_encrypted`: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 0f7152f9..7e2be87a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -207,6 +207,8 @@ Invariant: - project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected +Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. + ## 7.6 `issues` (core task entity) - `id` uuid pk @@ -400,7 +402,7 @@ The current implementation includes additional V1-control-plane tables beyond th - Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes. - Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables. -- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`. +- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. - Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos. ## 8. State Machines diff --git a/packages/db/src/migrations/0086_routine_env_runtime_contract.sql b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql new file mode 100644 index 00000000..55228ccb --- /dev/null +++ b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql @@ -0,0 +1,8 @@ +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "env" jsonb;--> statement-breakpoint +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "routine_revision_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routine_runs_routine_revision_id_routine_revisions_id_fk') THEN + ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_revision_id_routine_revisions_id_fk" FOREIGN KEY ("routine_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_revision_idx" ON "routine_runs" USING btree ("routine_revision_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 953d0a09..a47bae7c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1778787362162, "tag": "0085_tranquil_the_executioner", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1778976000000, + "tag": "0086_routine_env_runtime_contract", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index dfc48143..684d12d6 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,7 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; -import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; +import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( "routines", @@ -35,6 +35,7 @@ export const routines = pgTable( concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"), catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"), variables: jsonb("variables").$type().notNull().default([]), + env: jsonb("env").$type(), latestRevisionId: uuid("latest_revision_id"), latestRevisionNumber: integer("latest_revision_number").notNull().default(1), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), @@ -131,6 +132,7 @@ export const routineRuns = pgTable( source: text("source").notNull(), status: text("status").notNull().default("received"), triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), + routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), dispatchFingerprint: text("dispatch_fingerprint"), @@ -143,6 +145,7 @@ export const routineRuns = pgTable( }, (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), + routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8dd130c2..945dcd1c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -550,6 +550,8 @@ export type { CompanyPortabilityImportResult, CompanyPortabilityExportRequest, EnvBinding, + EnvPlainBinding, + EnvSecretRefBinding, AgentEnvConfig, CompanySecret, CompanySecretProviderConfig, @@ -576,6 +578,7 @@ export type { SecretVersionSelector, SecretVersionStatus, Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9cb46a2c..a2354757 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -279,6 +279,7 @@ export type { } from "./secrets.js"; export type { Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index c6f866dd..3e877153 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -8,6 +8,7 @@ import type { RoutineTriggerSigningMode, RoutineVariableType, } from "../constants.js"; +import type { EnvBinding } from "./secrets.js"; export interface RoutineProjectSummary { id: string; @@ -45,6 +46,8 @@ export interface RoutineVariable { options: string[]; } +export type RoutineEnvConfig = Record; + export interface Routine { id: string; companyId: string; @@ -59,6 +62,7 @@ export interface Routine { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env?: RoutineEnvConfig | null; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; @@ -98,6 +102,7 @@ export interface RoutineRevisionSnapshotRoutineV1 { concurrencyPolicy: RoutineConcurrencyPolicy; catchUpPolicy: RoutineCatchUpPolicy; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; } export interface RoutineRevisionSnapshotTriggerV1 { @@ -169,6 +174,7 @@ export interface RoutineRun { source: string; status: string; triggeredAt: Date; + routineRevisionId?: string | null; idempotencyKey: string | null; triggerPayload: Record | null; dispatchFingerprint: string | null; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index c3ae1b8a..a903ceb2 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -12,6 +12,7 @@ import { ISSUE_EXECUTION_WORKSPACE_PREFERENCES, issueExecutionWorkspaceSettingsSchema, } from "./issue.js"; +import { envConfigSchema } from "./secret.js"; const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]); @@ -60,6 +61,7 @@ export const createRoutineSchema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"), variables: z.array(routineVariableSchema).optional().default([]), + env: envConfigSchema.optional().nullable(), }); export type CreateRoutine = z.infer; @@ -83,6 +85,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), variables: z.array(routineVariableSchema), + env: envConfigSchema.nullable().default(null), }).strict(); export const routineRevisionSnapshotTriggerV1Schema = z.object({ diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 55653be3..7ab6ed24 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -7,7 +7,7 @@ import { } from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { - it("overlays project env on top of agent env and unions secret keys", async () => { + it("overlays project and routine env on top of agent env and unions secret keys", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { @@ -29,29 +29,51 @@ describe("resolveExecutionRunAdapterConfig", () => { }, ], }); - const resolveEnvBindings = vi.fn().mockResolvedValue({ - env: { - SHARED_KEY: "project", - PROJECT_ONLY: "project-only", - }, - secretKeys: new Set(["PROJECT_SECRET"]), - manifest: [ - { - configPath: "env.PROJECT_SECRET", - envKey: "PROJECT_SECRET", - secretId: "secret-project", - secretKey: "project-secret", - version: 1, - provider: "local_encrypted", - outcome: "success", + const resolveEnvBindings = vi + .fn() + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "project", + PROJECT_ONLY: "project-only", }, - ], - }); + secretKeys: new Set(["PROJECT_SECRET"]), + manifest: [ + { + configPath: "env.PROJECT_SECRET", + envKey: "PROJECT_SECRET", + secretId: "secret-project", + secretKey: "project-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }) + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "routine", + ROUTINE_ONLY: "routine-only", + }, + secretKeys: new Set(["ROUTINE_SECRET"]), + manifest: [ + { + configPath: "env.ROUTINE_SECRET", + envKey: "ROUTINE_SECRET", + secretId: "secret-routine", + secretKey: "routine-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }); const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", executionRunConfig: { env: { SHARED_KEY: "agent" } }, projectEnv: { SHARED_KEY: "project" }, + routineEnv: { SHARED_KEY: "routine" }, + routineId: "routine-1", secretsSvc: { resolveAdapterConfigForRuntime, resolveEnvBindings, @@ -61,18 +83,88 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(result.resolvedConfig).toMatchObject({ other: "value", env: { - SHARED_KEY: "project", + SHARED_KEY: "routine", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", }, }); - expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]); + expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([ "secret-agent", "secret-project", + "secret-routine", ]); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); + expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only"); + expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ + consumerType: "routine", + consumerId: "routine-1", + }); + }); + + it("drops Paperclip runtime-owned env before resolving agent, project, and routine overlays", async () => { + const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ + config: { + ...config, + env: { ...(config.env as Record) }, + }, + secretKeys: new Set(), + manifest: [], + })); + const resolveEnvBindings = vi.fn(async (_companyId, env: Record) => ({ + env: Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + secretKeys: new Set(), + manifest: [], + })); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + executionRunConfig: { + env: { + PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" }, + PAPERCLIP_AGENT_ID: "spoofed-agent", + AGENT_ONLY: "agent-only", + }, + }, + projectEnv: { + PAPERCLIP_API_KEY: "project-api-key", + PAPERCLIP_COMPANY_ID: "spoofed-company", + PROJECT_ONLY: "project-only", + }, + routineEnv: { + PAPERCLIP_API_KEY: "routine-api-key", + PAPERCLIP_RUN_ID: "spoofed-run", + ROUTINE_ONLY: "routine-only", + }, + routineId: "routine-1", + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + } as any, + }); + + expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({ + env: { + AGENT_ONLY: "agent-only", + }, + }); + expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + PROJECT_ONLY: "project-only", + }); + expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ + ROUTINE_ONLY: "routine-only", + }); + expect(result.resolvedConfig.env).toEqual({ + AGENT_ONLY: "agent-only", + PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", + }); + expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_"); }); it("skips project env resolution when the project has no bindings", async () => { diff --git a/server/src/__tests__/qa-routine-secrets-e2e.test.ts b/server/src/__tests__/qa-routine-secrets-e2e.test.ts new file mode 100644 index 00000000..43d7910a --- /dev/null +++ b/server/src/__tests__/qa-routine-secrets-e2e.test.ts @@ -0,0 +1,458 @@ +// QA validation for [PAP-9522](/PAP/issues/PAP-9522). Drives the routine-secret +// chain end-to-end against a real embedded Postgres: +// +// 1. Routine env reaches the heartbeat runtime via `resolveExecutionRunAdapterConfig` +// using `secretsSvc.resolveEnvBindings` with a `consumerType: "routine"` context, +// even when the executing agent has zero direct bindings for that secret. +// 2. Precedence: agent < project < routine for a shared key. +// 3. `secret_access_events` records routine consumption but NEVER the resolved value. +// 4. Restoring an older revision re-syncs `company_secret_bindings` to the snapshot env. +// 5. Legacy fallback: a routine_run with null `routine_revision_id` still resolves +// the routine's current env (matches the explicit acceptance criterion). +// 6. Disabled / missing / cross-company secret bindings fail clearly without +// echoing the value. + +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { eq, and } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companySecretBindings, + companySecrets, + companySecretVersions, + createDb, + projects, + routineRuns, + routines, + secretAccessEvents, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; +import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts"; + +const support = await getEmbeddedPostgresTestSupport(); +const describeEmbedded = support.supported ? describe : describe.skip; +if (!support.supported) { + console.warn(`Skipping QA e2e on this host: ${support.reason ?? "embedded pg unsupported"}`); +} + +describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-qa-routine-secrets-${randomUUID()}`); + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-qa-routine-secrets-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(routineRuns); + await db.delete(routines); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + async function seed() { + const companyId = randomUUID(); + const executorAgentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "QA Co", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + // Note: executor agent has NO secret bindings of its own — this is the + // whole point of routine env (the secret rides with the routine, not the agent). + await db.insert(agents).values({ + id: executorAgentId, + companyId, + name: "Executor", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: { env: {} }, + runtimeConfig: {}, + permissions: {}, + }); + return { companyId, executorAgentId }; + } + + const ROUTINE_VALUE = "super-sekret-routine-value"; + const PROJECT_VALUE = "project-overlay-value"; + const AGENT_VALUE = "agent-base-value"; + + it("resolves routine env for an executing agent that has no direct binding, with routine winning precedence and zero value in access events", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: ROUTINE_VALUE, + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "qa routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + SHARED: { type: "plain", value: "routine-overrides" }, + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Verify binding is owned by the routine, not the executing agent. + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { targetType: "routine", secretId: secret.id, configPath: "env.ROUTINE_API_KEY" }, + ]); + + // Drive the real heartbeat resolution path with the routine env. + // issueId/heartbeatRunId left null because secret_access_events has FK + // constraints on both — populating them would require seeding issue and + // heartbeat_run rows just for FK validity. The routine consumer fields are + // what this test cares about. + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { SHARED: AGENT_VALUE, AGENT_ONLY: AGENT_VALUE } }, + projectEnv: { SHARED: { type: "plain", value: PROJECT_VALUE } }, + routineEnv: routine.env, + secretsSvc: secrets, + }); + + expect(result.resolvedConfig.env).toMatchObject({ + AGENT_ONLY: AGENT_VALUE, + SHARED: "routine-overrides", // routine beats project beats agent + ROUTINE_API_KEY: ROUTINE_VALUE, + }); + expect(result.secretKeys.has("ROUTINE_API_KEY")).toBe(true); + expect(result.secretManifest.some((m) => m.envKey === "ROUTINE_API_KEY")).toBe(true); + // Manifest must not echo the resolved value. + expect(JSON.stringify(result.secretManifest)).not.toContain(ROUTINE_VALUE); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, secret.id)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + consumerType: "routine", + consumerId: routine.id, + actorType: "agent", + actorId: executorAgentId, + configPath: "env.ROUTINE_API_KEY", + outcome: "success", + }); + // No serialized field of the access event row can contain the secret value. + expect(JSON.stringify(events[0])).not.toContain(ROUTINE_VALUE); + }); + + it("rejects routine env that references a secret from a different company", async () => { + const { companyId } = await seed(); + const { companyId: otherCompanyId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const foreignSecret = await secrets.create(otherCompanyId, { + name: `foreign-${randomUUID()}`, + provider: "local_encrypted", + value: "cross-company-leak-bait", + }); + + await expect( + routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "cross company", + description: null, + assigneeAgentId: null, + priority: "medium", + status: "paused", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + BAD: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" }, + }, + }, + {}, + ), + ).rejects.toThrow(/same company/i); + }); + + it("surfaces a clear, value-free error when a routine secret is missing/deleted at resolution time", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `to-be-deleted-${randomUUID()}`, + provider: "local_encrypted", + value: "doomed-secret-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "doomed routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + DOOMED: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Hard delete the secret out from under the routine; the routine env now + // points at a vanished id. + await secrets.remove(secret.id); + + let caught: unknown = null; + try { + await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeTruthy(); + const message = String((caught as Error)?.message ?? caught); + expect(message).not.toContain("doomed-secret-value"); + }); + + it("restoring an older revision re-syncs company_secret_bindings to the snapshot env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secretA = await secrets.create(companyId, { + name: `a-${randomUUID()}`, + provider: "local_encrypted", + value: "val-a", + }); + const secretB = await secrets.create(companyId, { + name: `b-${randomUUID()}`, + provider: "local_encrypted", + value: "val-b", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "restore routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + }, + }, + {}, + ); + const rev1Id = routine.latestRevisionId!; + + await routines.update( + routine.id, + { + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + BETA: { type: "secret_ref", secretId: secretB.id, version: "latest" }, + }, + }, + {}, + ); + + let bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath).sort()).toEqual(["env.ALPHA", "env.BETA"]); + + await routines.restoreRevision(routine.id, rev1Id, {}); + + bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath)).toEqual(["env.ALPHA"]); + expect(bindings[0]?.secretId).toBe(secretA.id); + }); + + it("legacy run with null routine_revision_id falls back to the routine's current env (still resolves)", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `legacy-${randomUUID()}`, + provider: "local_encrypted", + value: "legacy-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "legacy routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + LEGACY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Simulate an old routine_run row (predating the migration) with no + // routine_revision_id. The fallback path in `getRoutineEnvForExecutionIssue` + // should still resolve to the routine's current env. Here we exercise the + // resolution layer directly with routine.env to mirror that behavior. + await db.insert(routineRuns).values({ + id: randomUUID(), + companyId, + routineId: routine.id, + triggerId: null, + source: "manual", + status: "issue_created", + triggeredAt: new Date(), + completedAt: new Date(), + routineRevisionId: null, + }); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toMatchObject({ LEGACY: "legacy-value" }); + }); + + it("routines created with null env (no Secrets tab interaction) still resolve normally with empty env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "null env routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + expect(routine.env ?? null).toBeNull(); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toHaveLength(0); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { AGENT_ONLY: "agent" } }, + projectEnv: null, + routineEnv: null, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent" }); + expect(result.secretKeys.size).toBe(0); + }); +}); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 70fe9d05..e449bd30 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -5,6 +5,7 @@ import { activityLog, agents, companies, + companySecretBindings, companySecrets, companySecretVersions, createDb, @@ -19,6 +20,7 @@ import { routineRuns, routines, routineTriggers, + secretAccessEvents, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -28,6 +30,7 @@ import { issueService } from "../services/issues.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import * as providerRegistry from "../secrets/provider-registry.ts"; import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -57,6 +60,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await db.delete(activityLog); await db.delete(issueInboxArchives); await db.delete(issueReadStates); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); await db.delete(routineRuns); await db.delete(routineTriggers); await db.delete(routines); @@ -331,6 +336,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); }); + it("stores routine env in revisions, syncs routine secret bindings, and stamps runs with the dispatch revision", async () => { + const { agentId, companyId, projectId, svc } = await seedFixture(); + const secrets = secretService(db); + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: "secret-value", + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "secret routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "always_enqueue", + catchUpPolicy: "skip_missed", + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "plain-value" }, + }, + }, + {}, + ); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const [initialRevision] = await svc.listRevisions(routine.id); + expect(initialRevision?.snapshot.routine.env).toEqual(routine.env); + + await db.delete(companySecretBindings).where(eq(companySecretBindings.targetId, routine.id)); + const repaired = await svc.update(routine.id, { env: routine.env }, {}); + expect(repaired).not.toBeNull(); + const repairedBindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(repairedBindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const currentRoutine = repaired ?? routine; + const runBefore = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runBefore.routineRevisionId).toBe(currentRoutine.latestRevisionId); + + const updated = await svc.update( + routine.id, + { + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "changed" }, + }, + }, + {}, + ); + expect(updated?.latestRevisionNumber).toBe(currentRoutine.latestRevisionNumber + 1); + + const runAfter = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId); + expect(runAfter.dispatchFingerprint).not.toBe(runBefore.dispatchFingerprint); + }); + it("rejects stale routine baseRevisionId updates", async () => { const { routine, svc } = await seedFixture(); const updated = await svc.update(routine.id, { description: "new description" }, {}); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 01f13041..d01b6777 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -205,6 +205,116 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(events)).not.toContain("runtime-secret"); }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-secret-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "routine", + consumerId: "routine-1", + actorType: "agent", + actorId: "agent-1", + }); + + expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret"); + expect(resolved.manifest).toEqual([ + expect.objectContaining({ + configPath: "env.ROUTINE_API_KEY", + envKey: "ROUTINE_API_KEY", + secretId: secret.id, + outcome: "success", + }), + ]); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "routine", + consumerId: "routine-1", + configPath: "env.ROUTINE_API_KEY", + actorType: "agent", + actorId: "agent-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + }); + + it("records stable redacted failure codes for routine env secret resolution", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-failure-codes-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + const context = { + consumerType: "routine" as const, + consumerId: "routine-1", + actorType: "agent" as const, + actorId: "agent-1", + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + await expect( + svc.resolveEnvBindings(companyId, env, { ...context, consumerId: "routine-2" }), + ).rejects.toThrow(/not bound/i); + + await db.update(companySecrets).set({ status: "disabled" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not active/i); + + await db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, secret.id)); + await expect( + svc.resolveSecretValue(companyId, secret.id, 999, { + ...context, + configPath: "env.ROUTINE_API_KEY", + }), + ).rejects.toThrow(/version not found/i); + + await db + .update(companySecretVersions) + .set({ status: "disabled" }) + .where(eq(companySecretVersions.secretId, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/version is not active/i); + + await db + .update(companySecretVersions) + .set({ status: "current" }) + .where(eq(companySecretVersions.secretId, secret.id)); + vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce( + new Error("provider leaked value routine-super-secret"), + ); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/provider leaked value/i); + + await db.update(companySecrets).set({ status: "deleted" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not found/i); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events.map((event) => event.errorCode).sort()).toEqual([ + "binding_missing", + "provider_error", + "secret_deleted", + "secret_inactive", + "version_inactive", + "version_missing", + ]); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + expect(JSON.stringify(events)).not.toContain("provider leaked value"); + }); + it("scopes env binding sync deletes to the env path prefix", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 248a51ed..06906e0f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,6 +18,7 @@ import { type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, + type RoutineRevisionSnapshotV1, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -40,6 +41,9 @@ import { issueWorkProducts, projects, projectWorkspaces, + routineRevisions, + routineRuns, + routines, workspaceOperations, } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; @@ -327,19 +331,44 @@ type RuntimeConfigSecretResolver = Pick< "resolveAdapterConfigForRuntime" | "resolveEnvBindings" >; +function isPaperclipRuntimeEnvKey(key: string) { + return key.startsWith("PAPERCLIP_"); +} + +function stripPaperclipRuntimeEnvBindings(envValue: unknown): Record | null { + const record = parseObject(envValue); + const filtered = Object.fromEntries( + Object.entries(record).filter(([key]) => !isPaperclipRuntimeEnvKey(key)), + ); + return Object.keys(filtered).length > 0 ? filtered : null; +} + +function stripPaperclipRuntimeEnvFromAdapterConfig(config: Record): Record { + if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; + return { + ...config, + env: stripPaperclipRuntimeEnvBindings(config.env) ?? {}, + }; +} + export async function resolveExecutionRunAdapterConfig(input: { companyId: string; agentId?: string | null; issueId?: string | null; heartbeatRunId?: string | null; projectId?: string | null; + routineId?: string | null; executionRunConfig: Record; projectEnv: unknown; + routineEnv?: unknown; secretsSvc: RuntimeConfigSecretResolver; }) { + const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig); + const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv); + const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv); const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime( input.companyId, - input.executionRunConfig, + executionRunConfig, input.agentId ? { consumerType: "agent", @@ -351,10 +380,10 @@ export async function resolveExecutionRunAdapterConfig(input: { } : undefined, ); - const projectEnvResolution = input.projectEnv + const projectEnvResolution = projectEnv ? await input.secretsSvc.resolveEnvBindings( input.companyId, - input.projectEnv, + projectEnv, input.projectId ? { consumerType: "project", @@ -376,10 +405,39 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const routineEnvResolution = routineEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + routineEnv, + input.routineId + ? { + consumerType: "routine", + consumerId: input.routineId, + actorType: "agent", + actorId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(routineEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...routineEnvResolution.env, + }; + for (const key of routineEnvResolution.secretKeys) { + secretKeys.add(key); + } + } return { resolvedConfig, secretKeys, - secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])], + secretManifest: [ + ...(manifest ?? []), + ...(projectEnvResolution.manifest ?? []), + ...(routineEnvResolution.manifest ?? []), + ], }; } @@ -2433,12 +2491,67 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionWorkspaceSettings: issues.executionWorkspaceSettings, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); } + async function getRoutineEnvForExecutionIssue( + companyId: string, + issueContext: Awaited> | null, + ) { + if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) { + return { routineId: null, env: null }; + } + + const routineRun = issueContext.originRunId + ? await db + .select({ + routineRevisionId: routineRuns.routineRevisionId, + }) + .from(routineRuns) + .where( + and( + eq(routineRuns.id, issueContext.originRunId), + eq(routineRuns.companyId, companyId), + eq(routineRuns.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + if (routineRun?.routineRevisionId) { + const revision = await db + .select({ + snapshot: routineRevisions.snapshot, + }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.id, routineRun.routineRevisionId), + eq(routineRevisions.companyId, companyId), + eq(routineRevisions.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null); + const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined; + if (snapshot?.version === 1) { + return { routineId: issueContext.originId, env: snapshot.routine.env ?? null }; + } + } + + const routine = await db + .select({ env: routines.env }) + .from(routines) + .where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return { routineId: issueContext.originId, env: routine?.env ?? null }; + } + async function getRuntimeState(agentId: string) { return db .select() @@ -6870,6 +6983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; + const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext); const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), isolatedWorkspacesEnabled, @@ -7074,8 +7188,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, heartbeatRunId: run.id, projectId: projectContext?.id ?? null, + routineId: routineEnvContext.routineId, executionRunConfig, projectEnv: projectContext?.env ?? null, + routineEnv: routineEnvContext.env, secretsSvc, }); if (secretManifest.length > 0) { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index b280119a..80e22176 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -366,6 +366,8 @@ function createRoutineDispatchFingerprint(input: { payload: Record | null; projectId: string | null; assigneeAgentId: string | null; + routineRevisionId: string | null; + routineEnvFingerprint: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: Record | null; @@ -376,6 +378,11 @@ function createRoutineDispatchFingerprint(input: { return crypto.createHash("sha256").update(canonical).digest("hex"); } +function createRoutineEnvFingerprint(env: unknown) { + const canonical = JSON.stringify(normalizeRoutineDispatchFingerprintValue(env ?? null)); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + function readManagedRoutineIssueTemplate(defaultsJson: Record | null | undefined) { const value = defaultsJson?.issueTemplate; if (!isPlainRecord(value)) return null; @@ -406,6 +413,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], variables: routine.variables ?? [], + env: routine.env ?? null, }; } @@ -686,6 +694,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -719,6 +728,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, @@ -1138,6 +1148,8 @@ export function routineService( payload: triggerPayload, projectId, assigneeAgentId, + routineRevisionId: input.routine.latestRevisionId, + routineEnvFingerprint: createRoutineEnvFingerprint(input.routine.env), executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -1183,6 +1195,7 @@ export function routineService( idempotencyKey: input.idempotencyKey ?? null, triggerPayload, dispatchFingerprint, + routineRevisionId: input.routine.latestRevisionId, }) .returning(); @@ -1430,6 +1443,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -1462,6 +1476,7 @@ export function routineService( idempotencyKey: run.idempotencyKey, triggerPayload: run.triggerPayload as Record | null, dispatchFingerprint: run.dispatchFingerprint, + routineRevisionId: run.routineRevisionId, linkedIssueId: run.linkedIssueId, coalescedIntoRunId: run.coalescedIntoRunId, failureReason: run.failureReason, @@ -1508,13 +1523,19 @@ export function routineService( await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); + const env = input.env === undefined || input.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(companyId, input.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const variables = syncRoutineVariablesWithTemplate( [input.title, input.description], sanitizeRoutineVariableInputs(input.variables), ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - return db.transaction(async (tx) => { + const createdRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; const [created] = await txDb .insert(routines) @@ -1531,6 +1552,7 @@ export function routineService( concurrencyPolicy: input.concurrencyPolicy, catchUpPolicy: input.catchUpPolicy, variables, + env, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, updatedByAgentId: actor.agentId ?? null, @@ -1540,8 +1562,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, created, actor, { changeSummary: "Created routine", }); + if (env) { + await secretsSvc.syncEnvBindingsForTarget( + companyId, + { targetType: "routine", targetId: routine.id }, + env, + { db: tx }, + ); + } return routine; }); + return createdRoutine; }, update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise => { @@ -1551,6 +1582,14 @@ export function routineService( const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; + const nextEnv = patch.env === undefined + ? existing.env + : patch.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, patch.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const requestedStatus = patch.status ?? existing.status; if (patch.status === "active") { assertRoutineCanEnable(patch.status, nextAssigneeAgentId); @@ -1582,7 +1621,7 @@ export function routineService( if (enabledScheduleTriggers) { assertScheduleCompatibleVariables(nextVariables); } - return db.transaction(async (tx) => { + const updatedRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`); const locked = await txDb @@ -1611,6 +1650,7 @@ export function routineService( concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, variables: nextVariables, + env: nextEnv, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, }; @@ -1633,6 +1673,14 @@ export function routineService( ) .then((rows) => rows[0] ?? null); if (latestRevision && snapshotsMatch(nextSnapshot, latestRevision.snapshot as RoutineRevisionSnapshotV1)) { + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + candidate.env, + { db: tx }, + ); + } return locked; } } @@ -1651,6 +1699,7 @@ export function routineService( concurrencyPolicy: candidate.concurrencyPolicy, catchUpPolicy: candidate.catchUpPolicy, variables: candidate.variables, + env: candidate.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: new Date(), @@ -1661,8 +1710,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, updated, actor, { changeSummary: "Updated routine", }); + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + routine.companyId, + { targetType: "routine", targetId: routine.id }, + routine.env, + { db: tx }, + ); + } return routine; }); + return updatedRoutine; }, createTrigger: async ( @@ -1770,7 +1828,7 @@ export function routineService( } } - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); const [updated] = await txDb @@ -1801,12 +1859,13 @@ export function routineService( }); return { trigger: updated as RoutineTrigger, revision: appended.revision }; }); + return result; }, deleteTrigger: async (id: string, actor: Actor = {}): Promise<{ deleted: boolean; revision: RoutineRevision | null }> => { const existing = await getTriggerById(id); if (!existing) return { deleted: false, revision: null }; - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id)); @@ -1821,6 +1880,7 @@ export function routineService( }); return { deleted: true, revision: appended.revision }; }); + return result; }, rotateTriggerSecret: async ( @@ -1912,7 +1972,7 @@ export function routineService( const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb @@ -1964,6 +2024,7 @@ export function routineService( concurrencyPolicy: routineSnapshot.concurrencyPolicy, catchUpPolicy: routineSnapshot.catchUpPolicy, variables: routineSnapshot.variables, + env: routineSnapshot.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: now, @@ -2033,6 +2094,12 @@ export function routineService( changeSummary: `Restored from revision ${targetRevision.revisionNumber}`, restoredFromRevisionId: targetRevision.id, }); + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + routineSnapshot.env, + { db: tx }, + ); return { routine: appended.routine, revision: appended.revision, @@ -2041,6 +2108,7 @@ export function routineService( secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial), }; }); + return result; }, runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => { @@ -2172,6 +2240,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -2204,6 +2273,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 17f13cef..ce9ee967 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -61,6 +61,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); +type DbTransaction = Parameters[0]>[0]; +type SecretBindingDb = Pick; function remoteProviderHttpError(error: unknown, context: { companyId: string; @@ -195,6 +197,14 @@ type RuntimeSecretResolution = { manifestEntry: RuntimeSecretManifestEntry; }; +type SecretResolutionErrorCode = + | "binding_missing" + | "secret_deleted" + | "secret_inactive" + | "version_missing" + | "version_inactive" + | "provider_error"; + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -238,6 +248,33 @@ function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderCo return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } +function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { + if (isSecretProviderClientError(error)) return "provider_error"; + if (error instanceof HttpError) { + const details = asRecord(error.details); + switch (details?.code) { + case "binding_missing": + case "secret_deleted": + case "secret_inactive": + case "version_missing": + case "version_inactive": + case "provider_error": + return details.code; + } + if (error.message === "Secret is not active") return "secret_inactive"; + if (error.message === "Secret version not found") return "version_missing"; + if (error.message === "Secret version is not active") return "version_inactive"; + if ( + error.message === "Secret resolution requires a binding config path" || + error.message.startsWith("Secret is not bound to ") + ) { + return "binding_missing"; + } + if (error.status >= 500) return "provider_error"; + } + return "provider_error"; +} + function assertSelectableProviderConfig(config: { provider: string; status: string; @@ -259,8 +296,8 @@ export function secretService(db: Db) { fieldPath?: string; }; - async function getById(id: string) { - return db + async function getById(id: string, source: Pick = db) { + return source .select() .from(companySecrets) .where(eq(companySecrets.id, id)) @@ -321,7 +358,7 @@ export function secretService(db: Db) { ) { if (!context) return; if (!context.configPath) { - throw unprocessable("Secret resolution requires a binding config path"); + throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" }); } const binding = await getBinding({ companyId, @@ -333,6 +370,7 @@ export function secretService(db: Db) { if (!binding) { throw unprocessable( `Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`, + { code: "binding_missing" }, ); } } @@ -365,8 +403,12 @@ export function secretService(db: Db) { }); } - async function assertSecretInCompany(companyId: string, secretId: string) { - const secret = await getById(secretId); + async function assertSecretInCompany( + companyId: string, + secretId: string, + source: Pick = db, + ) { + const secret = await getById(secretId, source); if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); @@ -495,19 +537,24 @@ export function secretService(db: Db) { version: number | "latest", context?: SecretConsumerContext, ): Promise { - const secret = await assertSecretInCompany(companyId, secretId); + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; const configPath = context?.configPath ?? null; try { + if (secret.status === "deleted") { + throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); + } if (secret.status !== "active") { - throw unprocessable("Secret is not active"); + throw unprocessable("Secret is not active", { code: "secret_inactive" }); } await assertBindingContext(companyId, secret.id, context); const versionRow = await getSecretVersion(secret.id, resolvedVersion); - if (!versionRow) throw notFound("Secret version not found"); + if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { - throw unprocessable("Secret version is not active"); + throw unprocessable("Secret version is not active", { code: "version_inactive" }); } const provider = getSecretProvider(providerId); const providerConfig = await getSelectableRuntimeProviderConfig({ @@ -555,7 +602,7 @@ export function secretService(db: Db) { }, }; } catch (err) { - const errorCode = err instanceof Error ? err.message.slice(0, 120) : "resolution_failed"; + const errorCode = secretResolutionErrorCode(err); await recordAccessEvent({ companyId, secretId: secret.id, @@ -1984,6 +2031,7 @@ export function secretService(db: Db) { companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, envValue: unknown, + options?: { db?: SecretBindingDb }, ) => { const record = asRecord(envValue) ?? {}; const refs: Array<{ @@ -1992,12 +2040,13 @@ export function secretService(db: Db) { versionSelector: SecretVersionSelector; }> = []; const pathPrefix = target.pathPrefix ?? "env"; + const bindingDb = options?.db ?? db; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") continue; - await assertSecretInCompany(companyId, binding.secretId); + await assertSecretInCompany(companyId, binding.secretId, bindingDb); refs.push({ secretId: binding.secretId, configPath: `${pathPrefix}.${key}`, @@ -2005,8 +2054,8 @@ export function secretService(db: Db) { }); } - await db.transaction(async (tx) => { - await tx + const writeBindings = async (targetDb: SecretBindingDb) => { + await targetDb .delete(companySecretBindings) .where( and( @@ -2017,7 +2066,7 @@ export function secretService(db: Db) { ), ); if (refs.length === 0) return; - await tx.insert(companySecretBindings).values( + await targetDb.insert(companySecretBindings).values( refs.map((ref) => ({ companyId, secretId: ref.secretId, @@ -2028,7 +2077,13 @@ export function secretService(db: Db) { required: true, })), ); - }); + }; + + if (options?.db) { + await writeBindings(options.db); + } else { + await db.transaction(async (tx) => writeBindings(tx)); + } return refs; }, diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index 56dd9c80..e55fb3e7 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -5,7 +5,9 @@ import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { + CompanySecret, Routine, + RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotV1, } from "@paperclipai/shared"; @@ -95,6 +97,7 @@ function snapshotV1(overrides?: Partial): concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, ...overrides, }, triggers: [], @@ -321,6 +324,152 @@ describe("RoutineHistoryTab", () => { expect(successCall).toBeTruthy(); }); + it("shows env summary on the revision preview and routes counts into restore dialog", async () => { + const env: RoutineEnvConfig = { + GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" }, + LOG_LEVEL: { type: "plain", value: "debug" }, + }; + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + snapshot: snapshotV1({ env }), + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: 3 } }, + }), + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + const secrets: CompanySecret[] = [ + { + id: "secret-1", + companyId: "company-1", + key: "gh_token", + name: "github-bot", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 4, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]; + await render({ secrets }); + expect(container.textContent).toContain("Env"); + expect(container.textContent).toContain("2 keys (1 secret ref)"); + + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const restoreButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent === "Restore as new revision", + ); + expect(restoreButtons.length).toBeGreaterThan(0); + await act(async () => { + restoreButtons[0].click(); + }); + await flush(); + expect(container.textContent).toContain("Routine secrets will revert"); + expect(container.textContent).toContain("1 key removed"); + expect(container.textContent).toContain("1 key changed"); + }); + + it("labels secret-ref env diffs by changed secret instead of binding kind", async () => { + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-2", version: "latest" } }, + }), + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" } }, + }), + }); + const secrets: CompanySecret[] = [ + { + id: "secret-1", + companyId: "company-1", + key: "old_token", + name: "old-token", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + { + id: "secret-2", + companyId: "company-1", + key: "new_token", + name: "new-token", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]; + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + await render({ secrets }); + + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const compareButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Compare with current", + ); + await act(async () => { + compareButton?.click(); + }); + await flush(); + + expect(container.textContent).toContain("Env GH_TOKEN secret"); + expect(container.textContent).not.toContain("Env GH_TOKEN binding kind"); + }); + it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => { const current = createRevision({ id: "revision-2", revisionNumber: 2 }); const old = createRevision({ diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx index 0bae2714..35bd4cfb 100644 --- a/ui/src/components/RoutineHistoryTab.tsx +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { History as HistoryIcon, RotateCcw, Search } from "lucide-react"; import type { + CompanySecret, + EnvBinding, + EnvSecretRefBinding, Routine, + RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotTriggerV1, RoutineVariable, + SecretVersionSelector, } from "@paperclipai/shared"; import { routinesApi, @@ -33,6 +38,7 @@ import { MarkdownBody } from "./MarkdownBody"; type AgentLookup = Map; type ProjectLookup = Map; +type SecretLookup = Map; type DirtyFieldDescriptor = { key: string; @@ -47,6 +53,7 @@ type Props = { onSaveEdits: () => void; agents: AgentLookup; projects: ProjectLookup; + secrets?: CompanySecret[]; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; @@ -59,9 +66,14 @@ export function RoutineHistoryTab({ onSaveEdits, agents, projects, + secrets, onRestoreSecretMaterials, onRestored, }: Props) { + const secretLookup = useMemo( + () => new Map((secrets ?? []).map((secret) => [secret.id, secret])), + [secrets], + ); const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [selectedRevisionId, setSelectedRevisionId] = useState(null); @@ -277,6 +289,10 @@ export function RoutineHistoryTab({ selectedRevision, currentRevision, )} + envDiffCounts={summarizeEnvDiffCounts( + currentRevision.snapshot.routine.env ?? null, + selectedRevision.snapshot.routine.env ?? null, + )} /> )} @@ -289,6 +305,7 @@ export function RoutineHistoryTab({ initialNewRevisionId={currentRevision.id} agents={agents} projects={projects} + secrets={secretLookup} onRestore={(rev) => { setSelectedRevisionId(rev.id); setDiffOpen(false); @@ -498,6 +515,10 @@ function RevisionPreview({ highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border" }`; + const envSummary = summarizeEnv(snapshot.env ?? null); + const envDiffers = !!currentSnapshot + && JSON.stringify(normalizeEnv(currentSnapshot.env ?? null)) + !== JSON.stringify(normalizeEnv(snapshot.env ?? null)); const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [ { key: "title", @@ -541,6 +562,12 @@ function RevisionPreview({ value: snapshot.catchUpPolicy.replaceAll("_", " "), differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy, }, + { + key: "env", + label: "Env", + value: envSummary, + differs: envDiffers, + }, ]; return ( @@ -670,6 +697,7 @@ function RestoreConfirmDialog({ onConfirm, pending, recreatedWebhookLabels, + envDiffCounts, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -680,6 +708,7 @@ function RestoreConfirmDialog({ onConfirm: () => void; pending: boolean; recreatedWebhookLabels: string[]; + envDiffCounts: EnvDiffCounts; }) { const newRevisionNumber = currentRevisionNumber + 1; return ( @@ -698,6 +727,12 @@ function RestoreConfirmDialog({ Routine field values, variables, and schedule cron will revert. + {envDiffCounts.total > 0 && ( +
  • + + Routine secrets will revert: {formatEnvDiffCounts(envDiffCounts)}. +
  • + )}
  • Previous run history is preserved. @@ -743,6 +778,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId, agents, projects, + secrets, onRestore, }: { open: boolean; @@ -752,6 +788,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId: string; agents: AgentLookup; projects: ProjectLookup; + secrets: SecretLookup; onRestore: (revision: RoutineRevision) => void; }) { const [leftId, setLeftId] = useState(initialOldRevisionId); @@ -767,8 +804,8 @@ function RoutineRevisionDiffModal({ const left = revisions.find((r) => r.id === leftId) ?? null; const right = revisions.find((r) => r.id === rightId) ?? null; const fieldChanges = useMemo( - () => (left && right ? computeFieldChanges(left, right, agents, projects) : []), - [left, right, agents, projects], + () => (left && right ? computeFieldChanges(left, right, agents, projects, secrets) : []), + [left, right, agents, projects, secrets], ); const descriptionDiff = useMemo( () => (left && right @@ -1003,6 +1040,7 @@ function computeFieldChanges( right: RoutineRevision, agents: AgentLookup, projects: ProjectLookup, + secrets: SecretLookup, ): Array<{ field: string; oldValue: string | null; newValue: string | null }> { const oldRoutine = left.snapshot.routine; const newRoutine = right.snapshot.routine; @@ -1042,10 +1080,170 @@ function computeFieldChanges( newValue: summarizeVariables(newRoutine.variables), }); } + compareEnv(oldRoutine.env ?? null, newRoutine.env ?? null, secrets, changes); compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes); return changes; } +function normalizeEnv(env: RoutineEnvConfig | null): Record { + if (!env) return {}; + return env; +} + +function envBindingKind(binding: EnvBinding): "plain" | "secret_ref" { + if (typeof binding === "string") return "plain"; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { + return "secret_ref"; + } + return "plain"; +} + +function asSecretRef(binding: EnvBinding): EnvSecretRefBinding | null { + if (typeof binding === "string") return null; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { + return binding; + } + return null; +} + +function formatVersionSelector(version: SecretVersionSelector | undefined): string { + if (version == null || version === "latest") return "latest"; + return `v${version}`; +} + +function describeSecretRef(ref: EnvSecretRefBinding, secrets: SecretLookup): string { + const secret = secrets.get(ref.secretId); + const name = secret?.name ?? ""; + return `${name} ${formatVersionSelector(ref.version)}`; +} + +function describeEnvBinding(binding: EnvBinding | undefined, secrets: SecretLookup): string { + if (binding === undefined) return "—"; + const ref = asSecretRef(binding); + if (ref) return `secret_ref → ${describeSecretRef(ref, secrets)}`; + return "plain (set)"; +} + +function summarizeEnv(env: RoutineEnvConfig | null): string { + const entries = Object.entries(normalizeEnv(env)); + if (entries.length === 0) return ""; + const secretCount = entries.filter(([, binding]) => envBindingKind(binding) === "secret_ref").length; + const keyLabel = entries.length === 1 ? "key" : "keys"; + if (secretCount === 0) return `${entries.length} ${keyLabel}`; + return `${entries.length} ${keyLabel} (${secretCount} secret ${secretCount === 1 ? "ref" : "refs"})`; +} + +type EnvDiffCounts = { + added: number; + removed: number; + changed: number; + total: number; +}; + +function summarizeEnvDiffCounts( + current: RoutineEnvConfig | null, + target: RoutineEnvConfig | null, +): EnvDiffCounts { + const currentRec = normalizeEnv(current); + const targetRec = normalizeEnv(target); + let added = 0; + let removed = 0; + let changed = 0; + const keys = new Set([...Object.keys(currentRec), ...Object.keys(targetRec)]); + for (const key of keys) { + const inCurrent = key in currentRec; + const inTarget = key in targetRec; + if (inTarget && !inCurrent) { + added += 1; + continue; + } + if (!inTarget && inCurrent) { + removed += 1; + continue; + } + if (JSON.stringify(currentRec[key]) !== JSON.stringify(targetRec[key])) { + changed += 1; + } + } + return { added, removed, changed, total: added + removed + changed }; +} + +function formatEnvDiffCounts(counts: EnvDiffCounts): string { + const parts: string[] = []; + if (counts.added > 0) parts.push(`${counts.added} ${counts.added === 1 ? "key" : "keys"} added`); + if (counts.removed > 0) parts.push(`${counts.removed} ${counts.removed === 1 ? "key" : "keys"} removed`); + if (counts.changed > 0) parts.push(`${counts.changed} ${counts.changed === 1 ? "key" : "keys"} changed`); + return parts.join(", "); +} + +function compareEnv( + oldEnv: RoutineEnvConfig | null, + newEnv: RoutineEnvConfig | null, + secrets: SecretLookup, + changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>, +) { + const oldRec = normalizeEnv(oldEnv); + const newRec = normalizeEnv(newEnv); + const keys = new Set([...Object.keys(oldRec), ...Object.keys(newRec)]); + const sortedKeys = [...keys].sort(); + for (const key of sortedKeys) { + const oldBinding = oldRec[key]; + const newBinding = newRec[key]; + const inOld = key in oldRec; + const inNew = key in newRec; + if (inNew && !inOld) { + changes.push({ + field: `Env added (${key})`, + oldValue: "—", + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + if (!inNew && inOld) { + changes.push({ + field: `Env removed (${key})`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: "—", + }); + continue; + } + if (JSON.stringify(oldBinding) === JSON.stringify(newBinding)) continue; + const oldKind = envBindingKind(oldBinding); + const newKind = envBindingKind(newBinding); + if (oldKind !== newKind) { + changes.push({ + field: `Env ${key} binding kind`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + if (newKind === "secret_ref") { + const oldRef = asSecretRef(oldBinding)!; + const newRef = asSecretRef(newBinding)!; + if (oldRef.secretId !== newRef.secretId) { + changes.push({ + field: `Env ${key} secret`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + changes.push({ + field: `Env ${key} version`, + oldValue: describeSecretRef(oldRef, secrets), + newValue: describeSecretRef(newRef, secrets), + }); + continue; + } + changes.push({ + field: `Env ${key} value`, + oldValue: "plain (set)", + newValue: "plain (changed)", + }); + } +} + function summarizeVariables(variables: RoutineVariable[]): string { if (variables.length === 0) return "(none)"; return variables diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index a8a0e9d0..a502feef 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -8,6 +8,7 @@ import { Clock3, Copy, History as HistoryIcon, + KeyRound, Play, RefreshCw, Repeat, @@ -18,6 +19,8 @@ import { } from "lucide-react"; import { ApiError } from "../api/client"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; +import { secretsApi } from "../api/secrets"; +import { EnvVarEditor } from "../components/EnvVarEditor"; import { RoutineHistoryTab, type RoutineHistoryDirtyFieldDescriptor, @@ -63,13 +66,19 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; +import type { + EnvBinding, + RoutineDetail as RoutineDetailType, + RoutineEnvConfig, + RoutineTrigger, + RoutineVariable, +} from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const triggerKinds = ["schedule", "webhook"]; const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; -const routineTabs = ["triggers", "runs", "activity", "history"] as const; +const routineTabs = ["triggers", "runs", "activity", "secrets", "history"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", always_enqueue: "Queue every trigger occurrence, even if several runs stack up.", @@ -141,12 +150,14 @@ function buildRoutineMutationPayload(input: { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }) { return { ...input, description: input.description.trim() || null, projectId: input.projectId || null, assigneeAgentId: input.assigneeAgentId || null, + env: input.env && Object.keys(input.env).length > 0 ? input.env : null, }; } @@ -304,6 +315,7 @@ export function RoutineDetail() { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }>({ title: "", description: "", @@ -313,6 +325,7 @@ export function RoutineDetail() { concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, }); const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]); @@ -366,6 +379,21 @@ export function RoutineDetail() { queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: availableSecrets = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], + queryFn: () => secretsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const createSecret = useMutation({ + mutationFn: (input: { name: string; value: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to create secrets"); + return secretsApi.create(selectedCompanyId, input); + }, + onSuccess: () => { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); + }, + }); const routineDefaults = useMemo( () => @@ -379,6 +407,7 @@ export function RoutineDetail() { concurrencyPolicy: routine.concurrencyPolicy, catchUpPolicy: routine.catchUpPolicy, variables: routine.variables, + env: routine.env ?? null, } : null, [routine], @@ -408,6 +437,9 @@ export function RoutineDetail() { if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) { result.push({ key: "variables", label: "the variables" }); } + if (JSON.stringify(editDraft.env ?? null) !== JSON.stringify(routineDefaults.env ?? null)) { + result.push({ key: "env", label: "the secrets" }); + } return result; }, [editDraft, routineDefaults]); const isEditDirty = dirtyFields.length > 0; @@ -1082,6 +1114,10 @@ export function RoutineDetail() { Activity + + + Secrets + History @@ -1226,6 +1262,24 @@ export function RoutineDetail() { )} + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + } + secrets={availableSecrets} + onCreateSecret={async (name, value) => { + const created = await createSecret.mutateAsync({ name, value }); + return created; + }} + onChange={(env) => + setEditDraft((current) => ({ ...current, env: env ?? null })) + } + /> +
    + { if (response.secretMaterials.length > 0) { setSecretMessage({ @@ -1277,6 +1332,7 @@ export function RoutineDetail() { concurrencyPolicy: response.routine.concurrencyPolicy, catchUpPolicy: response.routine.catchUpPolicy, variables: response.routine.variables, + env: response.routine.env ?? null, }); hydratedRoutineIdRef.current = response.routine.id; }} diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx new file mode 100644 index 00000000..612145cb --- /dev/null +++ b/ui/storybook/stories/routine-secrets.stories.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import type { + CompanySecret, + EnvBinding, + Routine, + RoutineEnvConfig, + RoutineRevision, + RoutineRevisionSnapshotV1, +} from "@paperclipai/shared"; +import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; + +const COMPANY_ID = "company-storybook"; + +if (typeof window !== "undefined") { + window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID); +} + +function StorybookRoutineFixtures({ + revisions, + children, +}: { + revisions: RoutineRevision[]; + children: ReactNode; +}) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); + queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets); + queryClient.setQueryData(queryKeys.routines.revisions("routine-storybook"), revisions); + + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useEffect(() => { + if (selectedCompanyId !== COMPANY_ID) { + setSelectedCompanyId(COMPANY_ID); + } + }, [selectedCompanyId, setSelectedCompanyId]); + + if (selectedCompanyId !== COMPANY_ID) return null; + return <>{children}; +} + +const meta: Meta = { + title: "Product/Routines · Secrets tab", + parameters: { + layout: "fullscreen", + a11y: { test: "off" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +function SecretsTabSurface({ + initial, + title, +}: { + initial: RoutineEnvConfig | null; + title: string; +}) { + const [env, setEnv] = useState>(() => (initial ?? {}) as Record); + return ( + + + + + {title} + + + The Secrets tab on a routine reuses the env-var editor and adds a one-line precedence helper. + + + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + ({ + ...storybookSecrets[0]!, + id: `secret-${Math.random().toString(36).slice(2, 8)}`, + name, + key: name.toLowerCase(), + description: `New routine secret ${name}`, + })} + onChange={(next) => setEnv((next ?? {}) as Record)} + /> +
    +
    + ); +} + +export const SecretsTabEmpty: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabConfigured: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabDisabledOrMissing: Story = { + render: () => ( +
    + +
    + ), +}; + +function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 { + return { + version: 1, + routine: { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env, + }, + triggers: [], + }; +} + +function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Routine { + return { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }).routine.env, + latestRevisionId, + latestRevisionNumber, + createdByAgentId: null, + createdByUserId: "user-board", + updatedByAgentId: null, + updatedByUserId: "user-board", + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + updatedAt: new Date("2026-05-04T12:00:00.000Z"), + }; +} + +export const HistoryDiffWithEnv: Story = { + name: "History diff — env keys added/removed/changed", + render: () => { + const revisions: RoutineRevision[] = [ + { + id: "rev-2", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 2, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }), + changeSummary: "Added STAGE plain value", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-04T12:00:00.000Z"), + }, + { + id: "rev-1", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 1, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: 2 }, + GH_TOKEN: { type: "plain", value: "legacy" }, + }), + changeSummary: "Created routine", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + }, + ]; + return ( + +
    + {}} + onSaveEdits={() => {}} + agents={new Map()} + projects={new Map()} + secrets={storybookSecrets as CompanySecret[]} + onRestoreSecretMaterials={() => {}} + /> +
    +
    + ); + }, +}; From d734bd43d1c79cc6e33f5a12bffacdb158021a50 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 17 May 2026 17:15:06 -0500 Subject: [PATCH 21/53] [codex] Roll up May 17 branch changes (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so agent work needs visible ownership, recovery, and operator controls. > - This local branch had accumulated several related control-plane reliability and operator-experience fixes across recovery actions, watchdog folding, model-profile defaults, mentions, markdown editing, plugin launchers, and small UI polish. > - The branch needed to be converted into a PR against the current `origin/master` without losing dirty work or including lockfile/workflow churn. > - The safest standalone shape is a single rollup PR because the recovery/server/UI files overlap heavily across the local commits and splitting would create avoidable conflicts. > - This pull request replays the local branch onto latest `origin/master`, preserves the uncommitted work as logical commits, and adds a Zod 4 validator compatibility fix found during verification. > - The benefit is that the May 17 local branch can be reviewed and merged as one coherent, conflict-free branch under the 100-file Greptile limit. ## What Changed - Rebased the local May 17 branch work onto current `origin/master` in a dedicated worktree. - Preserved and committed previously dirty changes for recovery retry handling, plugin/sidebar launcher polish, and `.herenow` ignores. - Added recovery-action behavior for returning source issues to `todo` when retrying source-scoped recovery. - Included the existing local recovery/liveness/watchdog fold, Codex cheap-profile, markdown/mention, duplicate-agent, and UI polish commits from the branch. - Normalized shared validator `z.record(...)` schemas to explicit string-key records for Zod 4 compatibility. - Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*` changes and stays below the 100-file Greptile limit. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `npm run install` in `node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the local native sqlite3 binding after installing with scripts disabled - `pnpm exec vitest run packages/shared/src/validators/issue.test.ts packages/shared/src/project-mentions.test.ts packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/heartbeat-model-profile.test.ts server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts server/src/__tests__/plugin-local-folders.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/SidebarAccountMenu.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/duplicate-agent-payload.test.ts ui/src/pages/Routines.test.tsx` - First pass: 13 files passed with 201 passing tests; 3 server files failed before sqlite3 native binding was built. - After rebuilding sqlite3: `server/src/__tests__/heartbeat-model-profile.test.ts`, `server/src/__tests__/issue-recovery-actions.test.ts`, and `server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts` passed/loaded; embedded Postgres tests were skipped by the local host guard. - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Medium risk: this is a broad rollup PR across recovery semantics, server tests, shared validators, and UI surfaces. - Some embedded Postgres tests skipped locally due the host guard, so CI should provide the stronger database-backed signal. - UI changes were covered by component tests, but no browser screenshot was captured in this PR creation pass. - This branch may overlap with existing recovery/liveness PR work; merge this PR independently or restack/close overlapping branches rather than merging duplicate implementations together. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository and GitHub workflow, medium reasoning effort. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .gitignore | 1 + doc/execution-semantics.md | 44 +- .../adapter-utils/src/command-redaction.ts | 47 +- .../adapter-utils/src/server-utils.test.ts | 5 +- packages/adapters/codex-local/src/index.ts | 3 +- .../plugins/plugin-llm-wiki/src/ui/app.tsx | 9 +- .../plugins/plugin-llm-wiki/src/wiki/core.ts | 6 +- packages/shared/src/index.ts | 5 + packages/shared/src/project-mentions.test.ts | 11 + packages/shared/src/project-mentions.ts | 40 ++ packages/shared/src/validators/agent.ts | 8 +- packages/shared/src/validators/approval.ts | 4 +- .../src/validators/company-portability.ts | 28 +- .../shared/src/validators/company-skill.ts | 2 +- packages/shared/src/validators/environment.ts | 12 +- .../src/validators/execution-workspace.ts | 6 +- .../src/validators/issue-tree-control.ts | 4 +- packages/shared/src/validators/issue.test.ts | 19 + packages/shared/src/validators/issue.ts | 20 +- packages/shared/src/validators/plugin.ts | 16 +- packages/shared/src/validators/project.ts | 14 +- packages/shared/src/validators/routine.ts | 4 +- packages/shared/src/validators/secret.ts | 12 +- .../shared/src/validators/work-product.ts | 2 +- ...environment-selection-route-guards.test.ts | 5 + ...at-accepted-plan-workspace-refresh.test.ts | 279 ++++++++++++ ...artbeat-active-run-output-watchdog.test.ts | 255 ++++++++++- ...eartbeat-issue-liveness-escalation.test.ts | 76 ++++ .../__tests__/heartbeat-model-profile.test.ts | 26 +- .../src/__tests__/heartbeat-run-log.test.ts | 17 + .../heartbeat-workspace-session.test.ts | 12 + .../issue-activity-events-routes.test.ts | 5 + ...ue-agent-mutation-ownership-routes.test.ts | 201 ++++++++- ...e-assigned-backlog-contract-routes.test.ts | 5 + .../__tests__/issue-attachment-routes.test.ts | 5 + .../issue-closed-workspace-routes.test.ts | 5 + .../issue-dependency-wakeups-routes.test.ts | 5 + .../__tests__/issue-recovery-actions.test.ts | 410 +++++++++++++++++ .../__tests__/issue-telemetry-routes.test.ts | 5 + .../issue-thread-interaction-routes.test.ts | 52 +++ .../issue-workspace-command-authz.test.ts | 5 + .../issues-goal-context-routes.test.ts | 5 + server/src/__tests__/issues-service.test.ts | 46 ++ .../__tests__/plugin-local-folders.test.ts | 8 + server/src/__tests__/redaction.test.ts | 4 + server/src/redaction.ts | 47 +- server/src/routes/issues.ts | 419 +++++++++++++++++- server/src/services/heartbeat.ts | 2 +- server/src/services/issues.ts | 24 +- server/src/services/plugin-local-folders.ts | 8 +- server/src/services/recovery/service.ts | 388 +++++++++++++++- ui/src/api/issues.ts | 2 +- ui/src/components/IssueProperties.test.tsx | 70 +++ ui/src/components/IssueProperties.tsx | 54 ++- .../IssueRecoveryActionCard.test.tsx | 8 +- ui/src/components/IssueRecoveryActionCard.tsx | 6 + ui/src/components/IssueRow.test.tsx | 1 + ui/src/components/IssueRow.tsx | 3 +- ui/src/components/IssueRunLedger.tsx | 4 + ui/src/components/IssuesList.tsx | 4 +- ui/src/components/MarkdownBody.test.tsx | 7 +- ui/src/components/MarkdownBody.tsx | 12 +- ui/src/components/MarkdownEditor.test.tsx | 32 +- ui/src/components/MarkdownEditor.tsx | 50 ++- ui/src/components/RoutineList.tsx | 2 +- ui/src/components/Sidebar.test.tsx | 25 +- ui/src/components/Sidebar.tsx | 11 +- ui/src/components/SidebarAccountMenu.test.tsx | 2 +- ui/src/components/SidebarAccountMenu.tsx | 2 +- ui/src/components/SourceResolvedFoldBadge.tsx | 33 ++ .../components/SourceResolvedFoldCallout.tsx | 177 ++++++++ ui/src/components/ui/dialog.tsx | 2 +- ui/src/context/EditorAutocompleteContext.tsx | 62 ++- ui/src/lib/activity-format.ts | 4 + ui/src/lib/duplicate-agent-payload.test.ts | 85 ++++ ui/src/lib/duplicate-agent-payload.ts | 78 ++++ ui/src/lib/mention-chips.ts | 14 + ui/src/lib/source-resolved-watchdog-fold.ts | 134 ++++++ ui/src/pages/AgentDetail.tsx | 107 +++++ ui/src/pages/IssueDetail.tsx | 5 +- ui/src/pages/Routines.test.tsx | 3 +- ui/src/plugins/launchers.tsx | 35 +- .../stories/source-resolved-fold.stories.tsx | 180 ++++++++ 83 files changed, 3675 insertions(+), 180 deletions(-) create mode 100644 server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts create mode 100644 ui/src/components/SourceResolvedFoldBadge.tsx create mode 100644 ui/src/components/SourceResolvedFoldCallout.tsx create mode 100644 ui/src/lib/duplicate-agent-payload.test.ts create mode 100644 ui/src/lib/duplicate-agent-payload.ts create mode 100644 ui/src/lib/source-resolved-watchdog-fold.ts create mode 100644 ui/storybook/stories/source-resolved-fold.stories.tsx diff --git a/.gitignore b/.gitignore index d594a1f1..2d8d5454 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ tests/release-smoke/playwright-report/ .superset/ .superpowers/ .claude/worktrees/ +.herenow diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 735c88a6..98fc21c2 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -184,7 +184,7 @@ A valid recovery action must name: - the wake, monitor, timeout, retry, or escalation policy that will move the action forward - the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled -A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue. +A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue. Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include: @@ -196,6 +196,14 @@ Use an issue-backed recovery action only when the recovery is genuinely independ A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome. +#### Recovery action freshness + +Source-scoped recovery actions are snapshots of the source issue's liveness state at the time the action was opened. They must be revalidated after newer durable source activity, including source issue status changes, assignee changes, blocker changes, execution policy or monitor changes, document or work-product updates that define a valid waiting path, and structured resume or disposition updates. + +When newer source activity restores a valid live or waiting path, the recovery action is stale and should be folded through the explicit recovery lifecycle instead of being hidden or deleted. Folding means resolving or cancelling the recovery action with a resolution outcome and note that preserve the audit trail. + +Plain comments alone do not make a recovery action stale. A comment can provide evidence, but the recovery action should remain visible when the source issue is still stalled and the comment does not create a valid action-path primitive such as a wake, monitor, interaction, approval, blocker, human owner, execution participant, terminal disposition, or delegated follow-up. + ### Agent-assigned `todo` This is dispatch state: ready to start, not yet actively claimed. @@ -326,14 +334,15 @@ This is an active-work continuity recovery. Startup recovery and periodic recovery are different from normal wakeup delivery. -On startup and on the periodic recovery loop, Paperclip now does four things in sequence: +On startup and on the periodic recovery loop, Paperclip now does five things in sequence: 1. reap orphaned `running` runs 2. resume persisted `queued` runs 3. reconcile stranded assigned work -4. scan silent active runs and create or update explicit watchdog recovery actions +4. scan silent active runs, revalidate their source issues, and either fold source-resolved watchdogs or create/update explicit watchdog recovery actions +5. reconcile productivity reviews -The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. +The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition. ## 10. Silent Active-Run Watchdog @@ -360,6 +369,33 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot. +### Source-aware watchdog folding + +Active-run watchdog work is source-aware. Before the watchdog creates, refreshes, escalates, or blocks on reviewer work, it must re-read the linked source issue and decide whether the watchdog signal is still about productive source work or only about stale run/process bookkeeping. + +Fold watchdog work when all of these are true: + +- the run is linked to a source issue in the same company +- the source issue is terminal (`done` or `cancelled`) +- durable source activity from the same run proves the source issue reached that terminal disposition after the stale-run or output-silence evidence point +- there is no independent evidence that the still-running or detached process is doing harmful work, still owns external cleanup that needs an operator decision, or needs a separate security/ownership review + +Folding means resolving or cancelling the watchdog recovery action or issue-backed evaluation through the explicit recovery lifecycle. It must preserve the run id, source issue, detected silence or detached-process evidence, terminal source activity, decision reason, and best-effort process cleanup result. It must be idempotent for the `(companyId, runId, sourceIssueId)` signal and must not recursively recover the watchdog evaluation issue itself. + +Do not fold watchdog work only because the run is quiet. The watchdog must still create or continue reviewer work when: + +- the source issue is still `todo` or `in_progress`, because productive work may still be happening or stuck +- the source issue remains `in_progress` after a successful run with no valid disposition, because the successful-run handoff path owns that bounded correction +- the run terminated or disappeared while the source issue remains `in_progress` without a live path, because stranded assigned recovery owns that continuity repair +- the source issue is terminal but there is no durable same-run terminal activity after the stale evidence point +- there is independent evidence that the process may still be mutating external state, leaking resources, crossing company or ownership boundaries, or otherwise needs operator review + +In the normal non-terminal case, critical silence can still create issue-backed evaluation work and block the source issue when blocking is necessary for correctness. In the source-resolved case, a completed source issue should not acquire a new manager review or blocker merely because an old run handle stayed active; only real unresolved work should block work. + +This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other. + +Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains. + ## 11. Auto-Recover vs Explicit Recovery vs Human Escalation Paperclip uses three different recovery outcomes, depending on how much it can safely infer. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 9a5f3716..86024755 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -1,20 +1,57 @@ export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***"; -const COMMAND_CLI_SECRET_OPTION_RE = - /(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi; -const COMMAND_ENV_SECRET_ASSIGNMENT_RE = - /(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi; +const SECRET_NAME_PATTERN = + String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; + +const COMMAND_CLI_SECRET_OPTION_RE = new RegExp( + String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`, + "gi", +); +const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp( + String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`, + "gi", +); const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g; const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g; const COMMAND_JWT_RE = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g; +const COMMAND_SECRET_HINTS = [ + "api", + "key", + "token", + "auth", + "bearer", + "secret", + "pass", + "credential", + "jwt", + "private", + "cookie", + "connectionstring", + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", +] as const; + +function maybeContainsSecretText(command: string) { + const lower = command.toLowerCase(); + return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes("."); +} export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string { + if (!maybeContainsSecretText(command)) return command; return command .replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) - .replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`) + .replace( + COMMAND_ENV_SECRET_ASSIGNMENT_RE, + (_match, prefix: string, quote: string | undefined) => + quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`, + ) .replace(COMMAND_OPENAI_KEY_RE, redactedValue) .replace(COMMAND_GITHUB_TOKEN_RE, redactedValue) .replace(COMMAND_JWT_RE, redactedValue); diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index aeb6d2a8..3224b3d8 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -53,13 +53,14 @@ describe("buildInvocationEnvForLogs", () => { const loggedEnv = buildInvocationEnvForLogs( { SAFE_VALUE: "visible" }, { - resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret", + resolvedCommand: + "env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret", }, ); expect(loggedEnv.SAFE_VALUE).toBe("visible"); expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe( - "env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***", + "env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***", ); }); }); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index d869dda8..a9c25fc0 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [ description: "Use the lowest-cost known Codex local model lane without changing the primary model.", adapterConfig: { model: "gpt-5.3-codex-spark", - modelReasoningEffort: "low", + // Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work. + modelReasoningEffort: "high", }, source: "adapter_default", }, diff --git a/packages/plugins/plugin-llm-wiki/src/ui/app.tsx b/packages/plugins/plugin-llm-wiki/src/ui/app.tsx index 8110e827..07ba8af6 100644 --- a/packages/plugins/plugin-llm-wiki/src/ui/app.tsx +++ b/packages/plugins/plugin-llm-wiki/src/ui/app.tsx @@ -155,6 +155,11 @@ type ManagedRoutine = { } | null; }; +type ManagedRoutineDefaultDrift = NonNullable; +type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & { + defaultDrift?: ManagedRoutineDefaultDrift | null; +}; + type ManagedSkill = { status: string; skillId?: string | null; @@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || ""; const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption; const currentEventPolicy = eventPolicy ?? data.eventIngestion; - const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => { + const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => { const fallback = routineFallbackFor(routine); const key = routine.resourceKey ?? routine.routineId ?? fallback.title; const status = managedRoutineStatus(routine); @@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) { if (!context.companyId || !routine.resourceKey) return; - const changedFields = routine.defaultDrift?.changedFields ?? []; + const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? []; const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults"; const confirmed = typeof window === "undefined" || window.confirm( `Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`, diff --git a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts index a969bd36..f4496fd2 100644 --- a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts +++ b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts @@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input return { projects, rootIssues: issues }; } -export async function updateEventIngestionSettings( - ctx: PluginContext, + export async function updateEventIngestionSettings( + ctx: PluginContext, input: { companyId: string; settings: WikiEventIngestionSettingsUpdate }, -): Promise { + ): Promise { await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId: normalizeWikiId(input.settings.wikiId), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 945dcd1c..dcd92bcf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1047,22 +1047,27 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + ROUTINE_MENTION_SCHEME, SKILL_MENTION_SCHEME, USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, + buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractRoutineMentionIds, extractSkillMentionIds, extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseRoutineMentionHref, parseSkillMentionHref, parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, + type ParsedRoutineMention, type ParsedSkillMention, type ParsedUserMention, } from "./project-mentions.js"; diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 709fd27d..1bb3bceb 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -2,14 +2,17 @@ import { describe, expect, it } from "vitest"; import { buildAgentMentionHref, buildProjectMentionHref, + buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractRoutineMentionIds, extractSkillMentionIds, extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseRoutineMentionHref, parseSkillMentionHref, parseUserMentionHref, } from "./project-mentions.js"; @@ -49,4 +52,12 @@ describe("project-mentions", () => { }); expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); }); + + it("round-trips routine mentions", () => { + const href = buildRoutineMentionHref("routine-123"); + expect(parseRoutineMentionHref(href)).toEqual({ + routineId: "routine-123", + }); + expect(extractRoutineMentionIds(`[/routine:Weekly review](${href})`)).toEqual(["routine-123"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index b2d3ee93..5d618893 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -2,6 +2,7 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; +export const ROUTINE_MENTION_SCHEME = "routine://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; @@ -11,6 +12,7 @@ const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; +const ROUTINE_MENTION_LINK_RE = /\[[^\]]*]\((routine:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -33,6 +35,10 @@ export interface ParsedSkillMention { slug: string | null; } +export interface ParsedRoutineMention { + routineId: string; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -169,6 +175,28 @@ export function parseSkillMentionHref(href: string): ParsedSkillMention | null { }; } +export function buildRoutineMentionHref(routineId: string): string { + return `${ROUTINE_MENTION_SCHEME}${routineId.trim()}`; +} + +export function parseRoutineMentionHref(href: string): ParsedRoutineMention | null { + if (!href.startsWith(ROUTINE_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "routine:") return null; + + const routineId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!routineId) return null; + + return { routineId }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -217,6 +245,18 @@ export function extractSkillMentionIds(markdown: string): string[] { return [...ids]; } +export function extractRoutineMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(ROUTINE_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseRoutineMentionHref(match[1]); + if (parsed) ids.add(parsed.routineId); + } + return [...ids]; +} + function normalizeAgentIcon(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim().toLowerCase(); diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index bfb66751..6cbf8823 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -31,7 +31,7 @@ export const upsertAgentInstructionsFileSchema = z.object({ export type UpsertAgentInstructionsFile = z.infer; -const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { +const adapterConfigSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => { const envValue = value.env; if (envValue === undefined) return; const parsed = envConfigSchema.safeParse(envValue); @@ -46,7 +46,7 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { export const createAgentInstructionsBundleSchema = z.object({ entryFile: z.string().trim().min(1).optional(), - files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, { + files: z.record(z.string(), z.string()).refine((files) => Object.keys(files).length > 0, { message: "instructionsBundle.files must contain at least one file", }), }); @@ -78,7 +78,7 @@ export const createAgentSchema = z.object({ defaultEnvironmentId: z.string().uuid().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), permissions: agentPermissionsSchema.optional(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export type CreateAgent = z.infer; @@ -126,7 +126,7 @@ export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), reason: z.string().optional().nullable(), - payload: z.record(z.unknown()).optional().nullable(), + payload: z.record(z.string(), z.unknown()).optional().nullable(), idempotencyKey: z.string().optional().nullable(), forceFreshSession: z.preprocess( (value) => (value === null ? undefined : value), diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index ca704795..d24efadd 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -5,7 +5,7 @@ import { multilineTextSchema } from "./text.js"; export const createApprovalSchema = z.object({ type: z.enum(APPROVAL_TYPES), requestedByAgentId: z.string().uuid().optional().nullable(), - payload: z.record(z.unknown()), + payload: z.record(z.string(), z.unknown()), issueIds: z.array(z.string().uuid()).optional(), }); @@ -24,7 +24,7 @@ export const requestApprovalRevisionSchema = z.object({ export type RequestApprovalRevision = z.infer; export const resubmitApprovalSchema = z.object({ - payload: z.record(z.unknown()).optional(), + payload: z.record(z.string(), z.unknown()).optional(), }); export type ResubmitApproval = z.infer; diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index a1eb5b1f..8b369ae8 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -67,11 +67,11 @@ export const portabilityAgentManifestEntrySchema = z.object({ capabilities: z.string().nullable(), reportsToSlug: z.string().min(1).nullable(), adapterType: z.string().min(1), - adapterConfig: z.record(z.unknown()), - runtimeConfig: z.record(z.unknown()), - permissions: z.record(z.unknown()), + adapterConfig: z.record(z.string(), z.unknown()), + runtimeConfig: z.record(z.string(), z.unknown()), + permissions: z.record(z.string(), z.unknown()), budgetMonthlyCents: z.number().int().nonnegative(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilitySkillManifestEntrySchema = z.object({ @@ -85,7 +85,7 @@ export const portabilitySkillManifestEntrySchema = z.object({ sourceRef: z.string().nullable(), trustLevel: z.string().nullable(), compatibility: z.string().nullable(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), fileInventory: z.array(z.object({ path: z.string().min(1), kind: z.string().min(1), @@ -102,7 +102,7 @@ export const portabilityProjectManifestEntrySchema = z.object({ targetDate: z.string().nullable(), color: z.string().nullable(), status: z.string().nullable(), - executionWorkspacePolicy: z.record(z.unknown()).nullable(), + executionWorkspacePolicy: z.record(z.string(), z.unknown()).nullable(), workspaces: z.array(z.object({ key: z.string().min(1), name: z.string().min(1), @@ -113,10 +113,10 @@ export const portabilityProjectManifestEntrySchema = z.object({ visibility: z.string().nullable(), setupCommand: z.string().nullable(), cleanupCommand: z.string().nullable(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), isPrimary: z.boolean(), })).default([]), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({ @@ -157,15 +157,15 @@ export const portabilityIssueManifestEntrySchema = z.object({ description: z.string().nullable(), recurring: z.boolean().default(false), routine: portabilityIssueRoutineManifestEntrySchema.nullable(), - legacyRecurrence: z.record(z.unknown()).nullable(), + legacyRecurrence: z.record(z.string(), z.unknown()).nullable(), status: z.string().nullable(), priority: z.string().nullable(), labelIds: z.array(z.string().min(1)).default([]), billingCode: z.string().nullable(), - executionWorkspaceSettings: z.record(z.unknown()).nullable(), - assigneeAdapterOverrides: z.record(z.unknown()).nullable(), + executionWorkspaceSettings: z.record(z.string(), z.unknown()).nullable(), + assigneeAdapterOverrides: z.record(z.string(), z.unknown()).nullable(), comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilityManifestSchema = z.object({ @@ -197,7 +197,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("inline"), rootPath: z.string().min(1).optional().nullable(), - files: z.record(portabilityFileEntrySchema), + files: z.record(z.string(), portabilityFileEntrySchema), }), z.object({ type: z.literal("github"), @@ -251,7 +251,7 @@ export type CompanyPortabilityPreview = z.infer; @@ -37,7 +37,7 @@ export const probeEnvironmentConfigSchema = z.object({ name: z.string().min(1).optional(), description: z.string().optional().nullable(), driver: environmentDriverSchema, - config: z.record(z.unknown()).optional().default({}), - metadata: z.record(z.unknown()).optional().nullable(), + config: z.record(z.string(), z.unknown()).optional().default({}), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type ProbeEnvironmentConfig = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index b3633833..98a06155 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -13,7 +13,7 @@ export const executionWorkspaceConfigSchema = z.object({ provisionCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); @@ -94,7 +94,7 @@ export const workspaceRuntimeServiceSchema = z.object({ lastUsedAt: z.coerce.date(), startedAt: z.coerce.date(), stoppedAt: z.coerce.date().nullable(), - stopPolicy: z.record(z.unknown()).nullable(), + stopPolicy: z.record(z.string(), z.unknown()).nullable(), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), configIndex: z.number().int().nonnegative().nullable().optional(), createdAt: z.coerce.date(), @@ -125,7 +125,7 @@ export const updateExecutionWorkspaceSchema = z.object({ cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupReason: z.string().optional().nullable(), config: executionWorkspaceConfigSchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type UpdateExecutionWorkspace = z.infer; diff --git a/packages/shared/src/validators/issue-tree-control.ts b/packages/shared/src/validators/issue-tree-control.ts index 48d78123..7144ef86 100644 --- a/packages/shared/src/validators/issue-tree-control.ts +++ b/packages/shared/src/validators/issue-tree-control.ts @@ -27,7 +27,7 @@ export const createIssueTreeHoldSchema = z mode: issueTreeControlModeSchema, reason: z.string().trim().min(1).max(1000).optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); @@ -37,7 +37,7 @@ export const releaseIssueTreeHoldSchema = z .object({ reason: z.string().trim().min(1).max(1000).optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 0d8374ec..8ade9897 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -73,6 +73,25 @@ describe("issue validators", () => { ).toBe(false); }); + it("allows restored recovery resolutions to return the source issue to todo", () => { + expect( + resolveIssueRecoveryActionSchema.parse({ + outcome: "restored", + sourceIssueStatus: "todo", + }), + ).toMatchObject({ + outcome: "restored", + sourceIssueStatus: "todo", + }); + + expect( + resolveIssueRecoveryActionSchema.safeParse({ + outcome: "false_positive", + sourceIssueStatus: "todo", + }).success, + ).toBe(false); + }); + it("allows cancelled recovery resolutions to atomically restore the source issue status", () => { expect( resolveIssueRecoveryActionSchema.parse({ diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b39fe7f8..e503ad2d 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -116,14 +116,14 @@ export const issueExecutionWorkspaceSettingsSchema = z mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); export const issueAssigneeAdapterOverridesSchema = z .object({ modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(), - adapterConfig: z.record(z.unknown()).optional(), + adapterConfig: z.record(z.string(), z.unknown()).optional(), useProjectWorkspace: z.boolean().optional(), }) .strict(); @@ -248,10 +248,10 @@ export const issueRecoveryActionReadModelSchema = z.object({ returnOwnerAgentId: z.string().uuid().nullable(), cause: z.string().min(1), fingerprint: z.string().min(1), - evidence: z.record(z.unknown()), + evidence: z.record(z.string(), z.unknown()), nextAction: z.string().min(1), - wakePolicy: z.record(z.unknown()).nullable(), - monitorPolicy: z.record(z.unknown()).nullable(), + wakePolicy: z.record(z.string(), z.unknown()).nullable(), + monitorPolicy: z.record(z.string(), z.unknown()).nullable(), attemptCount: z.number().int().nonnegative(), maxAttempts: z.number().int().positive().nullable(), timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(), @@ -275,14 +275,18 @@ const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [ export const resolveIssueRecoveryActionSchema = z.object({ actionId: z.string().uuid().optional(), outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES), - sourceIssueStatus: z.enum(["done", "in_review", "blocked"]), + sourceIssueStatus: z.enum(["todo", "done", "in_review", "blocked"]), resolutionNote: multilineTextSchema.optional().nullable(), }).strict().superRefine((value, ctx) => { if (value.outcome === "restored") { - if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") { + if ( + value.sourceIssueStatus !== "todo" && + value.sourceIssueStatus !== "done" && + value.sourceIssueStatus !== "in_review" + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Restored recovery actions must move the source issue to done or in_review", + message: "Restored recovery actions must move the source issue to todo, done, or in_review", path: ["sourceIssueStatus"], }); } diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 8d4016cd..cbc900cf 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -39,7 +39,7 @@ import { routineVariableSchema } from "./routine.js"; * * @see PLUGIN_SPEC.md §10.1 — Manifest shape */ -export const jsonSchemaSchema = z.record(z.unknown()).refine( +export const jsonSchemaSchema = z.record(z.string(), z.unknown()).refine( (val) => { // Must have a "type" field if non-empty, or be a valid JSON Schema object if (Object.keys(val).length === 0) return true; @@ -143,9 +143,9 @@ export const pluginManagedAgentDeclarationSchema = z.object({ capabilities: z.string().max(2000).nullable().optional(), adapterType: z.string().min(1).max(100).optional(), adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(), - adapterConfig: z.record(z.unknown()).optional(), - runtimeConfig: z.record(z.unknown()).optional(), - permissions: z.record(z.unknown()).optional(), + adapterConfig: z.record(z.string(), z.unknown()).optional(), + runtimeConfig: z.record(z.string(), z.unknown()).optional(), + permissions: z.record(z.string(), z.unknown()).optional(), status: z.enum(["idle", "paused"]).optional(), budgetMonthlyCents: z.number().int().min(0).optional(), instructions: z.object({ @@ -166,7 +166,7 @@ export const pluginManagedProjectDeclarationSchema = z.object({ description: z.string().max(2000).nullable().optional(), status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(), color: z.string().max(32).nullable().optional(), - settings: z.record(z.unknown()).optional(), + settings: z.record(z.string(), z.unknown()).optional(), }); export type PluginManagedProjectDeclarationInput = z.infer; @@ -373,7 +373,7 @@ const launcherBoundsByEnvironment: Record< export const pluginLauncherActionDeclarationSchema = z.object({ type: z.enum(PLUGIN_LAUNCHER_ACTIONS), target: z.string().min(1), - params: z.record(z.unknown()).optional(), + params: z.record(z.string(), z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.type === "performAction" && value.target.includes("/")) { ctx.addIssue({ @@ -993,7 +993,7 @@ export type InstallPlugin = z.infer; * the plugin's instanceConfigSchema is done at the service layer. */ export const upsertPluginConfigSchema = z.object({ - configJson: z.record(z.unknown()), + configJson: z.record(z.string(), z.unknown()), }); export type UpsertPluginConfig = z.infer; @@ -1003,7 +1003,7 @@ export type UpsertPluginConfig = z.infer; * Allows a partial merge of config values. */ export const patchPluginConfigSchema = z.object({ - configJson: z.record(z.unknown()), + configJson: z.record(z.string(), z.unknown()), }); export type PatchPluginConfig = z.infer; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index b7f1aaa8..af831e41 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -21,16 +21,16 @@ export const projectExecutionWorkspacePolicySchema = z defaultProjectWorkspaceId: z.string().uuid().optional().nullable(), environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), - branchPolicy: z.record(z.unknown()).optional().nullable(), - pullRequestPolicy: z.record(z.unknown()).optional().nullable(), - runtimePolicy: z.record(z.unknown()).optional().nullable(), - cleanupPolicy: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), + branchPolicy: z.record(z.string(), z.unknown()).optional().nullable(), + pullRequestPolicy: z.record(z.string(), z.unknown()).optional().nullable(), + runtimePolicy: z.record(z.string(), z.unknown()).optional().nullable(), + cleanupPolicy: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); export const projectWorkspaceRuntimeConfigSchema = z.object({ - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); @@ -51,7 +51,7 @@ const projectWorkspaceFields = { remoteProvider: z.string().optional().nullable(), remoteWorkspaceRef: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), }; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index a903ceb2..2db48fbb 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -146,8 +146,8 @@ export type UpdateRoutineTrigger = z.infer; export const runRoutineSchema = z.object({ triggerId: z.string().uuid().optional().nullable(), - payload: z.record(z.unknown()).optional().nullable(), - variables: z.record(routineVariableValueSchema).optional().nullable(), + payload: z.record(z.string(), z.unknown()).optional().nullable(), + variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(), projectId: z.string().uuid().optional().nullable(), assigneeAgentId: z.string().uuid().optional().nullable(), idempotencyKey: z.string().trim().max(255).optional().nullable(), diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index ae364617..1ee1952c 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -25,7 +25,7 @@ export const envBindingSchema = z.union([ envBindingSecretRefSchema, ]); -export const envConfigSchema = z.record(envBindingSchema); +export const envConfigSchema = z.record(z.string(), envBindingSchema); export const createSecretSchema = z.object({ name: z.string().min(1), @@ -36,7 +36,7 @@ export const createSecretSchema = z.object({ value: z.string().min(1).optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), providerVersionRef: z.string().optional().nullable(), }).superRefine((value, ctx) => { if ((value.managedMode ?? "paperclip_managed") === "external_reference") { @@ -83,7 +83,7 @@ export const updateSecretSchema = z.object({ providerConfigId: z.string().uuid().optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export type UpdateSecret = z.infer; @@ -198,7 +198,7 @@ export const createSecretProviderConfigSchema = z.object({ displayName: z.string().trim().min(1).max(120), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), isDefault: z.boolean().optional(), - config: z.record(z.unknown()).default({}), + config: z.record(z.string(), z.unknown()).default({}), }).superRefine((value, ctx) => { rejectSensitiveProviderConfigKeys(value.config, ctx); const parsed = secretProviderConfigPayloadSchema.safeParse({ @@ -236,7 +236,7 @@ export const updateSecretProviderConfigSchema = z.object({ displayName: z.string().trim().min(1).max(120).optional(), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), isDefault: z.boolean().optional(), - config: z.record(z.unknown()).optional(), + config: z.record(z.string(), z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.config !== undefined) { rejectSensitiveProviderConfigKeys(value.config, ctx); @@ -268,7 +268,7 @@ export const remoteSecretImportSelectionSchema = z.object({ key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(), description: z.string().trim().max(500).optional().nullable(), providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export const remoteSecretImportSchema = z.object({ diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index 839cc15a..b068b9c9 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -43,7 +43,7 @@ export const createIssueWorkProductSchema = z.object({ isPrimary: z.boolean().optional().default(false), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"), summary: z.string().optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), createdByRunId: z.string().uuid().optional().nullable(), }); diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 524d58ba..73606e05 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -84,6 +84,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), documentService: () => ({}), routineService: () => ({}), workProductService: () => ({}), diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts new file mode 100644 index 00000000..c09fb028 --- /dev/null +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -0,0 +1,279 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { eq, ne } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agentTaskSessions, + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + issues, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; + +const execFileAsync = promisify(execFile); + +const adapterExecute = vi.hoisted(() => vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + sessionParams: { sessionId: "fresh-session" }, + sessionDisplayId: "fresh-session", + summary: "Accepted plan workspace refresh test run.", + provider: "test", + model: "test-model", +}))); + +vi.mock("../adapters/index.js", () => ({ + getServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), + listAdapterModelProfiles: async () => [], + runningProcesses: new Map(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres accepted-plan workspace refresh tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function createGitRepo() { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-")); + await execFileAsync("git", ["init"], { cwd: repoRoot }); + await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot }); + await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot }); + await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n"); + await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot }); + await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot }); + return repoRoot; +} + +describeEmbeddedPostgres("accepted plan workspace refresh", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const tempRoots: string[] = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-workspace-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + adapterExecute.mockClear(); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns); + const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running"); + if (!hasActiveRun) { + idlePolls += 1; + if (idlePolls >= 5) break; + } else { + idlePolls = 0; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined); + } + }); + + afterAll(async () => { + await db.$client.end(); + await tempDb?.cleanup(); + }); + + it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const sharedExecutionWorkspaceId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Accepted Plan Workspace Refresh", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + cwd: repoRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: sharedExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared planning workspace", + status: "active", + cwd: repoRoot, + providerType: "local_fs", + providerRef: repoRoot, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId, + title: "Implement accepted plan", + status: "in_progress", + workMode: "planning", + priority: "medium", + assigneeAgentId: agentId, + identifier: "PAP-9122", + executionWorkspaceId: sharedExecutionWorkspaceId, + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agentTaskSessions).values({ + companyId, + agentId, + adapterType: "codex_local", + taskKey: issueId, + sessionParamsJson: { + sessionId: "stale-shared-session", + cwd: repoRoot, + workspaceId: projectWorkspaceId, + }, + sessionDisplayId: "stale-shared-session", + }); + adapterExecute.mockImplementationOnce(async () => { + await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { + exitCode: 0, + signal: null, + timedOut: false, + sessionParams: { sessionId: "fresh-session" }, + sessionDisplayId: "fresh-session", + summary: "Accepted plan workspace refresh test run.", + provider: "test", + model: "test-model", + }; + }); + + const heartbeat = heartbeatService(db); + const run = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }, + }); + + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await heartbeat.getRun(run!.id); + expect(latest?.status).toBe("succeeded"); + }, { timeout: 10_000 }); + + expect(adapterExecute).toHaveBeenCalledTimes(1); + const adapterInput = adapterExecute.mock.calls[0]?.[0] as { + runtime: { sessionId: string | null; sessionParams: Record | null }; + context: Record; + }; + expect(adapterInput.runtime.sessionId).toBeNull(); + expect(adapterInput.runtime.sessionParams).toBeNull(); + expect(adapterInput.context.paperclipWorkspace).toEqual(expect.objectContaining({ + mode: "isolated_workspace", + strategy: "git_worktree", + })); + expect((adapterInput.context.paperclipWorkspace as { cwd: string }).cwd).not.toBe(repoRoot); + + const refreshedIssue = await db + .select({ + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0]); + expect(refreshedIssue?.executionWorkspaceId).toBeTruthy(); + expect(refreshedIssue?.executionWorkspaceId).not.toBe(sharedExecutionWorkspaceId); + expect(refreshedIssue?.executionWorkspaceSettings).toMatchObject({ + mode: "isolated_workspace", + }); + + const isolatedRows = await db + .select() + .from(executionWorkspaces) + .where(ne(executionWorkspaces.id, sharedExecutionWorkspaceId)); + expect(isolatedRows).toHaveLength(1); + expect(isolatedRows[0]).toMatchObject({ + mode: "isolated_workspace", + strategyType: "git_worktree", + sourceIssueId: issueId, + }); + expect(isolatedRows[0]?.cwd).not.toBe(repoRoot); + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts index 166f8fc4..a5b3abb5 100644 --- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts +++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts @@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + activityLog, agents, companies, createDb, + heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, + issueComments, + issueRecoveryActions, issueRelations, issues, } from "@paperclipai/db"; @@ -94,7 +98,15 @@ describeEmbeddedPostgres("active-run output watchdog", () => { await tempDb?.cleanup(); }); - async function seedRunningRun(opts: { now: Date; ageMs: number; withOutput?: boolean; logChunk?: string }) { + async function seedRunningRun(opts: { + now: Date; + ageMs: number; + withOutput?: boolean; + logChunk?: string; + sourceStatus?: "in_progress" | "done" | "cancelled"; + sourceOriginKind?: string; + sameRunTerminalEvidence?: "activity" | "comment"; + }) { const companyId = randomUUID(); const managerId = randomUUID(); const coderId = randomUUID(); @@ -103,6 +115,8 @@ describeEmbeddedPostgres("active-run output watchdog", () => { const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const startedAt = new Date(opts.now.getTime() - opts.ageMs); const lastOutputAt = opts.withOutput ? new Date(opts.now.getTime() - 5 * 60 * 1000) : null; + const sourceStatus = opts.sourceStatus ?? "in_progress"; + const terminalEvidenceAt = new Date(startedAt.getTime() + 10 * 60 * 1000); await db.insert(companies).values({ id: companyId, @@ -139,11 +153,14 @@ describeEmbeddedPostgres("active-run output watchdog", () => { id: issueId, companyId, title: "Long running implementation", - status: "in_progress", + status: sourceStatus, priority: "medium", assigneeAgentId: coderId, issueNumber: 1, identifier: `${issuePrefix}-1`, + originKind: opts.sourceOriginKind ?? "manual", + completedAt: sourceStatus === "done" ? terminalEvidenceAt : null, + cancelledAt: sourceStatus === "cancelled" ? terminalEvidenceAt : null, updatedAt: startedAt, createdAt: startedAt, }); @@ -181,6 +198,35 @@ describeEmbeddedPostgres("active-run output watchdog", () => { .where(eq(heartbeatRuns.id, runId)); } await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + if (opts.sameRunTerminalEvidence === "activity") { + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: coderId, + agentId: coderId, + runId, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + details: { + identifier: `${issuePrefix}-1`, + status: sourceStatus, + _previous: { status: "in_progress" }, + }, + createdAt: terminalEvidenceAt, + }); + } else if (opts.sameRunTerminalEvidence === "comment") { + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: coderId, + authorType: "agent", + createdByRunId: runId, + body: "Completed and verified.", + createdAt: terminalEvidenceAt, + updatedAt: terminalEvidenceAt, + }); + } return { companyId, managerId, coderId, issueId, runId, issuePrefix }; } @@ -271,6 +317,211 @@ describeEmbeddedPostgres("active-run output watchdog", () => { expect(source?.status).toBe("blocked"); }); + it("folds terminal source issues with same-run durable evidence instead of creating watchdog work", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, coderId, issueId, runId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + sameRunTerminalEvidence: "activity", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 0, folded: 1, skipped: 0 }); + const evaluations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluations).toHaveLength(0); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("succeeded"); + expect(run?.errorCode).toBeNull(); + expect(run?.finishedAt?.toISOString()).toBe(now.toISOString()); + expect(run?.resultJson).toMatchObject({ + sourceResolvedWatchdogFold: { + sourceIssueId: issueId, + sourceIssueStatus: "done", + sameRunEvidenceKind: "activity", + evaluationIssueId: null, + evaluationIssueIdentifier: null, + cleanup: { outcome: "no_process_metadata" }, + }, + }); + + const [source] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(source?.executionRunId).toBeNull(); + const [agent] = await db.select().from(agents).where(eq(agents.id, coderId)); + expect(agent?.status).toBe("idle"); + const [decision] = await db + .select() + .from(heartbeatRunWatchdogDecisions) + .where(eq(heartbeatRunWatchdogDecisions.runId, runId)); + expect(decision?.decision).toBe("dismissed_false_positive"); + const [event] = await db + .select() + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + expect(event?.message).toContain("Source-resolved watchdog fold"); + }); + + it("still escalates terminal source issues without same-run terminal evidence", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, runId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 1, folded: 0 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("running"); + const [evaluation] = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluation?.originId).toBe(runId); + expect(evaluation?.parentId).toBeNull(); + }); + + it("still escalates when a same-run comment is followed by another actor marking the source done", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, issueId, runId, issuePrefix } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "in_progress", + sameRunTerminalEvidence: "comment", + }); + const completedAt = new Date(now.getTime() - 5 * 60_000); + await db + .update(issues) + .set({ status: "done", completedAt, updatedAt: completedAt }) + .where(eq(issues.id, issueId)); + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: "board-user", + agentId: null, + runId: null, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + details: { + identifier: `${issuePrefix}-1`, + status: "done", + _previous: { status: "in_progress" }, + }, + createdAt: completedAt, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 1, folded: 0 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("running"); + const [evaluation] = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluation?.originId).toBe(runId); + expect(evaluation?.parentId).toBeNull(); + }); + + it("folds existing evaluation and active watchdog recovery action idempotently", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, managerId, issueId, runId, issuePrefix } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + sameRunTerminalEvidence: "activity", + }); + const evaluationIssueId = randomUUID(); + await db.insert(issues).values({ + id: evaluationIssueId, + companyId, + title: "Existing stale evaluation", + status: "todo", + priority: "high", + assigneeAgentId: managerId, + issueNumber: 2, + identifier: `${issuePrefix}-2`, + originKind: "stale_active_run_evaluation", + originId: runId, + originRunId: runId, + originFingerprint: `stale_active_run:${companyId}:${runId}`, + }); + await db.insert(issueRelations).values({ + companyId, + issueId: evaluationIssueId, + relatedIssueId: issueId, + type: "blocks", + }); + await db.insert(issueRecoveryActions).values({ + companyId, + sourceIssueId: issueId, + recoveryIssueId: evaluationIssueId, + kind: "active_run_watchdog", + status: "active", + ownerType: "agent", + ownerAgentId: managerId, + cause: "active_run_watchdog", + fingerprint: `active-run-watchdog:${companyId}:${runId}:${issueId}`, + evidence: { runId }, + nextAction: "Review stale active run", + }); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.scanSilentActiveRuns({ now, companyId }); + const second = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(first).toMatchObject({ created: 0, folded: 1 }); + expect(second).toMatchObject({ scanned: 0, created: 0, folded: 0 }); + const [evaluation] = await db.select().from(issues).where(eq(issues.id, evaluationIssueId)); + expect(evaluation?.status).toBe("done"); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.resultJson).toMatchObject({ + sourceResolvedWatchdogFold: { + sourceIssueId: issueId, + sourceIssueStatus: "done", + evaluationIssueId, + evaluationIssueIdentifier: `${issuePrefix}-2`, + }, + }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + expect(action?.status).toBe("resolved"); + expect(action?.outcome).toBe("false_positive"); + const decisions = await db + .select() + .from(heartbeatRunWatchdogDecisions) + .where(eq(heartbeatRunWatchdogDecisions.runId, runId)); + expect(decisions).toHaveLength(1); + }); + + it("refuses recovery-on-recovery stale-run recursion", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceOriginKind: "stale_active_run_evaluation", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 0, skipped: 1 }); + const evaluations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluations).toHaveLength(1); + }); + it("skips snoozed runs and healthy noisy runs", async () => { const now = new Date("2026-04-22T20:00:00.000Z"); const stale = await seedRunningRun({ diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index e6b2dc99..300098ca 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -332,6 +332,82 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); }); + it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => { + await enableAutoRecovery(); + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const existingEscalationId = randomUUID(); + + await db.insert(issues).values({ + id: existingEscalationId, + companyId, + title: "Existing liveness unblock work", + status: "todo", + priority: "high", + parentId: blockerIssueId, + assigneeAgentId: managerId, + issueNumber: 5, + identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, + originKind: "harness_liveness_escalation", + originId: [ + "harness_liveness", + companyId, + blockedIssueId, + "in_review_without_action_path", + blockerIssueId, + ].join(":"), + }); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.findings).toBe(0); + expect(result.escalationsCreated).toBe(0); + expect(result.existingEscalations).toBe(0); + + const escalations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); + expect(escalations).toHaveLength(1); + }); + + it("keeps active invalid_review_participant recoveries from being retired", async () => { + await enableAutoRecovery(); + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const existingEscalationId = randomUUID(); + + await db.insert(issues).values({ + id: existingEscalationId, + companyId, + title: "Existing invalid review participant unblock work", + status: "todo", + priority: "high", + parentId: blockedIssueId, + assigneeAgentId: managerId, + issueNumber: 5, + identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, + originKind: "harness_liveness_escalation", + originId: [ + "harness_liveness", + companyId, + blockedIssueId, + "invalid_review_participant", + blockerIssueId, + ].join(":"), + }); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.findings).toBe(0); + expect(result.escalationsCreated).toBe(0); + expect(result.existingEscalations).toBe(0); + + const escalations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); + expect(escalations).toHaveLength(1); + }); + it("creates one manager escalation, preserves blockers, and records owner selection", async () => { await enableAutoRecovery(); const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); diff --git a/server/src/__tests__/heartbeat-model-profile.test.ts b/server/src/__tests__/heartbeat-model-profile.test.ts index 726436ca..5ac3c105 100644 --- a/server/src/__tests__/heartbeat-model-profile.test.ts +++ b/server/src/__tests__/heartbeat-model-profile.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { AdapterModelProfileDefinition } from "../adapters/index.js"; +import { + listAdapterModelProfiles, + type AdapterModelProfileDefinition, +} from "../adapters/index.js"; import { mergeModelProfileAdapterConfig, normalizeModelProfileWakeContext, @@ -17,6 +20,27 @@ const cheapProfile: AdapterModelProfileDefinition = { }; describe("heartbeat model profile application", () => { + it("uses the Codex local adapter cheap default when the agent has no runtime override", async () => { + const modelProfile = resolveModelProfileApplication({ + adapterModelProfiles: await listAdapterModelProfiles("codex_local"), + agentRuntimeConfig: {}, + issueModelProfile: "cheap", + contextSnapshot: {}, + }); + + expect(modelProfile).toMatchObject({ + requested: "cheap", + requestedBy: "issue_override", + applied: "cheap", + configSource: "adapter_default", + fallbackReason: null, + adapterConfig: { + model: "gpt-5.3-codex-spark", + modelReasoningEffort: "high", + }, + }); + }); + it("applies cheap profile patches before explicit issue adapter config overrides", () => { const modelProfile = resolveModelProfileApplication({ adapterModelProfiles: [cheapProfile], diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts index e88f976d..9eb6eda9 100644 --- a/server/src/__tests__/heartbeat-run-log.test.ts +++ b/server/src/__tests__/heartbeat-run-log.test.ts @@ -21,4 +21,21 @@ describe("compactRunLogChunk", () => { expect(compacted).toContain("[paperclip truncated run log chunk:"); expect(compacted.endsWith("tail")).toBe(true); }); + + it("redacts Paperclip credential shapes before persisting run-log chunks", () => { + const chunk = [ + "Authorization: Bearer live-bearer-token-value", + `export PAPERCLIP_API_KEY='paperclip-shell-secret'`, + `payload {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`, + "--paperclip-api-key=paperclip-flag-secret", + ].join("\n"); + + const compacted = compactRunLogChunk(chunk); + + expect(compacted).toContain("***REDACTED***"); + expect(compacted).not.toContain("live-bearer-token-value"); + expect(compacted).not.toContain("paperclip-shell-secret"); + expect(compacted).not.toContain("paperclip-json-secret"); + expect(compacted).not.toContain("paperclip-flag-secret"); + }); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index fe7e2bef..3c74475e 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -322,6 +322,18 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(true); }); + it("resets session context for accepted planning confirmations that refresh workspace selection", () => { + expect( + shouldResetTaskSessionForWake({ + wakeReason: "issue_commented", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }), + ).toBe(true); + }); + it("does not reset session context on mention wake comment", () => { expect( shouldResetTaskSessionForWake({ diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 6a6c3b92..c11e2a43 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -106,6 +106,11 @@ function registerModuleMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 407592d5..11053a52 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -8,6 +8,7 @@ const companyId = "22222222-2222-4222-8222-222222222222"; const ownerAgentId = "33333333-3333-4333-8333-333333333333"; const peerAgentId = "44444444-4444-4444-8444-444444444444"; const ownerRunId = "55555555-5555-4555-8555-555555555555"; +const recoveryActionId = "77777777-7777-4777-8777-777777777777"; const mockIssueService = vi.hoisted(() => ({ addComment: vi.fn(), @@ -62,6 +63,14 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({ })); const mockIssueRecoveryActionService = vi.hoisted(() => ({ getActiveForIssue: vi.fn(async () => null), + resolveActiveForIssue: vi.fn(async () => null), +})); +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), })); function registerRouteMocks() { @@ -109,13 +118,7 @@ function registerRouteMocks() { saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), + heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", @@ -189,13 +192,16 @@ async function createApp(actor: Record) { vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); + const fakeDb = { + transaction: async (callback: (tx: Record) => Promise) => callback({}), + }; const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor; next(); }); - app.use("/api", issueRoutes({} as any, mockStorageService as any)); + app.use("/api", issueRoutes(fakeDb as any, mockStorageService as any)); app.use(errorHandler); return app; } @@ -265,6 +271,45 @@ describe("agent issue mutation checkout ownership", () => { mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null); + mockIssueRecoveryActionService.resolveActiveForIssue.mockReset(); + mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + companyId, + sourceIssueId: issueId, + recoveryIssueId: null, + kind: "issue_graph_liveness", + status: "resolved", + ownerType: "agent", + ownerAgentId, + ownerUserId: null, + previousOwnerAgentId: null, + returnOwnerAgentId: null, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:test", + evidence: {}, + nextAction: "Restore a live execution path.", + wakePolicy: null, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: null, + timeoutAt: null, + lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"), + outcome: "restored", + resolutionNote: "Resolved by recovery owner", + resolvedAt: new Date("2026-05-13T18:05:00.000Z"), + createdAt: new Date("2026-05-13T17:55:00.000Z"), + updatedAt: new Date("2026-05-13T18:05:00.000Z"), + }); + mockHeartbeatService.wakeup.mockReset(); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockReset(); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockReset(); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockReset(); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockReset(); + mockHeartbeatService.cancelRun.mockResolvedValue(null); mockIssueService.remove.mockReset(); mockIssueService.removeAttachment.mockReset(); mockIssueService.update.mockReset(); @@ -415,6 +460,47 @@ describe("agent issue mutation checkout ownership", () => { ); }); + it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => { + const app = await createApp(ownerActor()); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .patch(`/api/issues/${issueId}`) + .send({ title: "Updated after commit" }) + .expect(200); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .post(`/api/issues/${issueId}/comments`) + .send({ body: "progress update" }) + .expect(201); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .put(`/api/issues/${issueId}/documents/plan`) + .send({ format: "markdown", body: "# updated" }) + .expect(200); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .patch("/api/work-products/product-1") + .send({ title: "Updated product" }) + .expect(200); + + expect(mockIssueService.update).toHaveBeenCalledWith( + issueId, + expect.objectContaining({ title: "Updated after commit" }), + ); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + issueId, + "progress update", + expect.any(Object), + expect.any(Object), + ); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); + expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }); + }); + it("preserves board mutations on active checkouts", async () => { const app = await createApp(boardActor()); @@ -477,4 +563,103 @@ describe("agent issue mutation checkout ownership", () => { title: "Claimable update", }); }); + + it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("rejects peer-agent recovery resolution on a board-owned source issue", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(peerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "done", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); + expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled(); + }); + + it("allows the named recovery owner to resolve a board-owned source issue", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ...patch, + })); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(ownerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "done", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled(); + }); + + it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), + ); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), + ...patch, + })); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(ownerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "todo", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ownerAgentId, + expect.objectContaining({ + reason: "issue_recovery_action_restored", + payload: expect.objectContaining({ + issueId, + recoveryActionId, + mutation: "recovery_action_resolution", + }), + }), + ); + }); }); diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index a251181e..2dc70626 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -76,6 +76,11 @@ vi.mock("../services/index.js", () => ({ syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({ diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 6a737903..39380a1b 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -81,6 +81,11 @@ function registerRouteMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 12181719..caf6445f 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -116,6 +116,11 @@ function registerServiceMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index 4abb18ee..d95d446c 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -65,6 +65,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({ diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index cc533998..a7e6de70 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -5,9 +5,13 @@ import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, + agentWakeupRequests, activityLog, companies, createDb, + environmentLeases, + environments, + heartbeatRuns, issueComments, issueRecoveryActions, issueRelations, @@ -130,7 +134,11 @@ describeEmbeddedPostgres("issue recovery actions", () => { afterEach(async () => { await db.delete(issueRecoveryActions); await db.delete(issueComments); + await db.delete(environmentLeases); await db.delete(activityLog); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(environments); await db.delete(issues); await db.delete(agents); await db.delete(companies); @@ -191,6 +199,24 @@ describeEmbeddedPostgres("issue recovery actions", () => { return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! }; } + async function seedHeartbeatRun(input: { + companyId: string; + agentId: string; + runId: string; + issueId?: string; + status?: string; + }) { + await db.insert(heartbeatRuns).values({ + id: input.runId, + companyId: input.companyId, + agentId: input.agentId, + invocationSource: "manual", + status: input.status ?? "running", + startedAt: new Date("2026-05-13T18:00:00.000Z"), + contextSnapshot: input.issueId ? { issueId: input.issueId } : undefined, + }); + } + function createApp(actor: any = { type: "board", source: "local_implicit" }) { const app = express(); app.use(express.json()); @@ -545,6 +571,390 @@ describeEmbeddedPostgres("issue recovery actions", () => { ); }); + it("resolves an active recovery action by returning the source issue to todo", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:try-again", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + const resolved = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "todo", + resolutionNote: "Try the source issue again.", + }) + .expect(200); + + expect(resolved.body.issue).toMatchObject({ + id: sourceIssueId, + status: "todo", + activeRecoveryAction: null, + }); + expect(resolved.body.recoveryAction).toMatchObject({ + id: action.id, + status: "resolved", + outcome: "restored", + resolutionNote: "Try the source issue again.", + }); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + }); + + it("marks a recovery action stale when a blocked source issue is manually moved to todo", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:manual-restore", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + const patched = await request(app) + .patch(`/api/issues/${sourceIssueId}`) + .send({ status: "todo" }) + .expect(200); + + expect(patched.body).toMatchObject({ + id: sourceIssueId, + status: "todo", + activeRecoveryAction: null, + }); + + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.", + }); + expect(actionRow?.resolvedAt).toBeTruthy(); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + expect(detail.body.activeRecoveryAction).toBeNull(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.map((row) => row.action)).toEqual( + expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]), + ); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "issue_update", + }); + }); + + it("folds stale recovery during read projection after the source issue reaches done", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:done-projection", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + await db.update(issues).set({ status: "done" }).where(eq(issues.id, sourceIssueId)); + const app = createApp(); + + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + + expect(detail.body).toMatchObject({ + id: sourceIssueId, + status: "done", + activeRecoveryAction: null, + }); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue reached done.", + }); + expect(actionRow?.resolvedAt).toBeTruthy(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "read_projection", + recoveryActionId: action.id, + }); + }); + + it("keeps active recovery visible when a plain comment does not create a live path", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:plain-comment", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + await request(app) + .post(`/api/issues/${sourceIssueId}/comments`) + .send({ body: "I am looking at this, but not changing the disposition." }) + .expect(201); + + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({ + id: action.id, + status: "active", + }); + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + expect(detail.body.activeRecoveryAction).toMatchObject({ id: action.id }); + }); + + it("folds stale recovery when a structured resume comment restores todo dispatch", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:resume-comment", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + await request(app) + .post(`/api/issues/${sourceIssueId}/comments`) + .send({ body: "Resume this now.", resume: true }) + .expect(201); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("todo"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.", + }); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "comment", + recoveryActionId: action.id, + }); + }); + + it("rejects peer-agent source issue updates that would hide another owner's recovery action", async () => { + const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:peer-status-update", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp({ + type: "agent", + agentId: coderId, + companyId, + runId: randomUUID(), + source: "agent_jwt", + }); + + await request(app) + .patch(`/api/issues/${sourceIssueId}`) + .send({ status: "todo" }) + .expect(403); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("blocked"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "active", + outcome: null, + resolvedAt: null, + }); + }); + + it("rejects peer-agent recovery action resolution on a board-owned source issue", async () => { + const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:peer-resolution", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp({ + type: "agent", + agentId: coderId, + companyId, + runId: randomUUID(), + source: "agent_jwt", + }); + + await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + resolutionNote: "Peer agent should not be able to clear this recovery.", + }) + .expect(403); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("blocked"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "active", + outcome: null, + resolvedAt: null, + }); + }); + + it("allows the named recovery owner to resolve a board-owned source recovery action", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:owner-resolution", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const runId = randomUUID(); + const app = createApp({ + type: "agent", + agentId: managerId, + companyId, + runId, + source: "agent_jwt", + }); + await seedHeartbeatRun({ + companyId, + agentId: managerId, + runId, + issueId: sourceIssueId, + }); + + const resolved = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + resolutionNote: "Recovery owner verified the work was intentionally completed.", + }) + .expect(200); + + expect(resolved.body.issue).toMatchObject({ + id: sourceIssueId, + status: "done", + activeRecoveryAction: null, + }); + expect(resolved.body.recoveryAction).toMatchObject({ + id: action.id, + status: "resolved", + outcome: "restored", + }); + }); + it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => { const { companyId, managerId, sourceIssueId } = await seedCompany(); const recoveryActionSvc = issueRecoveryActionService(db); diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 0fbf303c..634ff800 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -58,6 +58,11 @@ function registerModuleMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index a5d9acd8..ae5df023 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -106,6 +106,7 @@ function createIssue(overrides: Record = {}) { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", + workMode: "standard", priority: "medium", projectId: null, goalId: null, @@ -481,6 +482,57 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-plan", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: "confirmation:issue:plan:revision", + sourceCommentId: null, + sourceRunId: "run-plan", + payload: { + version: 1, + prompt: "Approve this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-plan/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + contextSnapshot: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-plan", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }), + }), + ); + }); + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "in_review", diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index e23c440d..c713bfa6 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -119,6 +119,11 @@ function registerRouteMocks() { getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 2bdd6b1a..5dc27d96 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -115,6 +115,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueReferenceService: () => mockIssueReferenceService, issueService: () => mockIssueService, logActivity: mockLogActivity, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 077332c6..22a5b3ce 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2408,6 +2408,52 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); + it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const sourceIssueId = randomUUID(); + const recoveryIssueId = randomUUID(); + await db.insert(issues).values([ + { + id: sourceIssueId, + companyId, + title: "Source issue", + status: "blocked", + priority: "medium", + }, + { + id: recoveryIssueId, + companyId, + title: "Liveness escalation issue", + status: "in_progress", + priority: "high", + originKind: "harness_liveness_escalation", + originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`, + }, + ]); + + await svc.update(sourceIssueId, { + blockedByIssueIds: [recoveryIssueId], + }); + await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({ + blockedBy: [expect.objectContaining({ id: recoveryIssueId })], + }); + + await svc.update(recoveryIssueId, { + status: "done", + }); + + await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({ + blockedBy: [], + }); + }); + it("rejects execution when unresolved blockers remain", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); diff --git a/server/src/__tests__/plugin-local-folders.test.ts b/server/src/__tests__/plugin-local-folders.test.ts index 073220c4..a1c354d9 100644 --- a/server/src/__tests__/plugin-local-folders.test.ts +++ b/server/src/__tests__/plugin-local-folders.test.ts @@ -219,6 +219,14 @@ describe("plugin local folders", () => { expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]); }); + it("creates missing nested parent directories for atomic writes", async () => { + const root = await makeRoot(); + + await writePluginLocalFolderTextAtomic(root, "cases/active/smoke/README.md", "hello"); + + await expect(readPluginLocalFolderText(root, "cases/active/smoke/README.md")).resolves.toBe("hello"); + }); + it("returns the real folder key after deleting a file", async () => { const root = await makeRoot(); await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8"); diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index e5b00cff..89c4e751 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -70,7 +70,9 @@ describe("redaction", () => { const input = [ "Authorization: Bearer live-bearer-token-value", `payload {"apiKey":"json-secret-value"}`, + `paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`, `escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`, + `export PAPERCLIP_API_KEY='paperclip-shell-secret'`, `GITHUB_TOKEN=${githubToken}`, `session=${jwt}`, ].join("\n"); @@ -80,7 +82,9 @@ describe("redaction", () => { expect(result).toContain(REDACTED_EVENT_VALUE); expect(result).not.toContain("live-bearer-token-value"); expect(result).not.toContain("json-secret-value"); + expect(result).not.toContain("paperclip-json-secret"); expect(result).not.toContain("escaped-json-secret"); + expect(result).not.toContain("paperclip-shell-secret"); expect(result).not.toContain(githubToken); expect(result).not.toContain(jwt); }); diff --git a/server/src/redaction.ts b/server/src/redaction.ts index f3877a8b..ebb1e663 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -1,19 +1,49 @@ import { redactCommandText } from "@paperclipai/adapter-utils"; -const SECRET_PAYLOAD_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const SECRET_FIELD_NAME_PATTERN = + String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; + +const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i"); const COMMAND_PAYLOAD_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; -const CLI_SECRET_FLAG_RE = - /^-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)$/i; -const JSON_SECRET_FIELD_TEXT_RE = - /((?:"|')?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:"|')?\s*:\s*(?:"|'))[^"'`\r\n]+((?:"|'))/gi; -const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = - /((?:\\")?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))/gi; +const CLI_SECRET_FLAG_RE = new RegExp(String.raw`^-{1,2}${SECRET_FIELD_NAME_PATTERN}$`, "i"); +const JSON_SECRET_FIELD_TEXT_RE = new RegExp( + String.raw`((?:"|')?${SECRET_FIELD_NAME_PATTERN}(?:"|')?\s*:\s*(?:"|'))[^"'` + "`" + String.raw`\r\n]+((?:"|'))`, + "gi", +); +const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = new RegExp( + String.raw`((?:\\")?${SECRET_FIELD_NAME_PATTERN}(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))`, + "gi", +); +const SECRET_TEXT_HINTS = [ + "api", + "key", + "token", + "auth", + "bearer", + "secret", + "pass", + "credential", + "jwt", + "private", + "cookie", + "connectionstring", + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", +] as const; export const REDACTED_EVENT_VALUE = "***REDACTED***"; +function maybeContainsSecretText(input: string) { + const lower = input.toLowerCase(); + return SECRET_TEXT_HINTS.some((hint) => lower.includes(hint)) || input.includes("."); +} + function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); @@ -94,6 +124,7 @@ export function redactEventPayload(payload: Record | null): Rec } export function redactSensitiveText(input: string): string { + if (!maybeContainsSecretText(input)) return input; return redactCommandText( input .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5de602a1..38576291 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -117,6 +117,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ type ParsedExecutionState = NonNullable>; type NormalizedExecutionPolicy = NonNullable>; +type IssueRouteSnapshot = typeof issueRows.$inferSelect; +type RecoveryRevalidationTrigger = + | "issue_update" + | "comment" + | "document" + | "work_product" + | "read_projection"; type CompanySearchService = { search(companyId: string, query: CompanySearchQuery): Promise; }; @@ -636,6 +643,8 @@ function queueResolvedInteractionContinuationWakeup(input: { }; actor: { actorType: "user" | "agent"; actorId: string }; source: string; + forceFreshSession?: boolean; + workspaceRefreshReason?: string | null; }) { if ( input.interaction.continuationPolicy !== "wake_assignee" @@ -648,6 +657,8 @@ function queueResolvedInteractionContinuationWakeup(input: { if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; + const forceFreshSession = input.forceFreshSession === true; + const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason); void input.heartbeat.wakeup(input.issue.assigneeAgentId, { source: "automation", triggerDetail: "system", @@ -673,6 +684,8 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, wakeReason: "issue_commented", source: input.source, + ...(forceFreshSession ? { forceFreshSession: true } : {}), + ...(workspaceRefreshReason ? { workspaceRefreshReason } : {}), }, }).catch((err) => logger.warn({ err, @@ -843,6 +856,7 @@ export function issueRoutes( const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const issueReferencesSvc = issueReferenceService(db); + const issueThreadInteractionsSvc = issueThreadInteractionService(db); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); @@ -857,6 +871,182 @@ export function issueRoutes( }; const feedbackExportService = opts?.feedbackExportService; const environmentsSvc = environmentService(db); + + async function classifySourceRecoveryRevalidation(input: { + issue: IssueRouteSnapshot; + trigger: RecoveryRevalidationTrigger; + statusChanged?: boolean; + assigneeChanged?: boolean; + blockersChanged?: boolean; + executionPolicyChanged?: boolean; + monitorChanged?: boolean; + documentChanged?: boolean; + workProductChanged?: boolean; + resumeRequested?: boolean; + reopened?: boolean; + blockedToTodoRecovery?: boolean; + }): Promise { + const { issue } = input; + if (issue.status === "done" || issue.status === "cancelled") { + return `Recovery action became stale because the source issue reached ${issue.status}.`; + } + if (input.blockedToTodoRecovery === true) { + return "Recovery action became stale because the source issue was manually moved from blocked to todo."; + } + + if (input.trigger === "read_projection") return null; + if ( + input.trigger === "comment" && + input.resumeRequested !== true && + input.reopened !== true && + input.statusChanged !== true + ) { + return null; + } + + const durableSourceChange = + input.statusChanged === true || + input.assigneeChanged === true || + input.blockersChanged === true || + input.executionPolicyChanged === true || + input.monitorChanged === true || + input.documentChanged === true || + input.workProductChanged === true || + input.resumeRequested === true || + input.reopened === true; + if (!durableSourceChange) return null; + + if (issue.status === "blocked") { + const readiness = await svc.getDependencyReadiness(issue.id); + if (readiness.unresolvedBlockerCount > 0) { + return "Recovery action became stale because the source issue now has unresolved first-class blockers."; + } + return null; + } + + if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") { + return "Recovery action became stale because the source issue now has a human owner."; + } + + if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) { + return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`; + } + + if (issue.status === "in_review") { + const executionState = parseIssueExecutionState(issue.executionState); + const participant = executionState?.status === "pending" ? executionState.currentParticipant : null; + if ( + (participant?.type === "agent" && readNonEmptyString(participant.agentId)) || + (participant?.type === "user" && readNonEmptyString(participant.userId)) + ) { + return "Recovery action became stale because the source issue now has a typed review participant."; + } + + const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id); + if (interactions.some((interaction) => interaction.status === "pending")) { + return "Recovery action became stale because the source issue now has a pending issue interaction."; + } + + const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id); + if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) { + return "Recovery action became stale because the source issue now has a pending approval."; + } + } + + const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)); + if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) { + return "Recovery action became stale because the source issue now has a scheduled monitor."; + } + + return null; + } + + async function revalidateActiveSourceRecovery(input: { + issue: IssueRouteSnapshot; + trigger: RecoveryRevalidationTrigger; + actor?: ReturnType | null; + activeRecoveryAction?: Awaited> | null; + statusChanged?: boolean; + assigneeChanged?: boolean; + blockersChanged?: boolean; + executionPolicyChanged?: boolean; + monitorChanged?: boolean; + documentChanged?: boolean; + workProductChanged?: boolean; + resumeRequested?: boolean; + reopened?: boolean; + blockedToTodoRecovery?: boolean; + }) { + const activeRecoveryAction = + input.activeRecoveryAction === undefined + ? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id) + : input.activeRecoveryAction; + if (!activeRecoveryAction) return null; + + const resolutionNote = await classifySourceRecoveryRevalidation(input); + if (!resolutionNote) return activeRecoveryAction; + + const resolved = await recoveryActionsSvc.resolveActiveForIssue({ + companyId: input.issue.companyId, + sourceIssueId: input.issue.id, + actionId: activeRecoveryAction.id, + status: "cancelled", + outcome: "cancelled", + resolutionNote, + }); + if (!resolved) return activeRecoveryAction; + + const actor = input.actor; + await logActivity(db, { + companyId: input.issue.companyId, + actorType: actor?.actorType ?? "system", + actorId: actor?.actorId ?? "system", + agentId: actor?.agentId ?? null, + runId: actor?.runId ?? null, + action: "issue.recovery_action_resolved", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier, + recoveryActionId: resolved.id, + recoveryActionStatus: resolved.status, + outcome: resolved.outcome, + sourceIssueStatus: input.issue.status, + resolutionNote: resolved.resolutionNote, + source: "source_revalidation", + trigger: input.trigger, + }, + }); + + return null; + } + + async function revalidateActiveSourceRecoveryForRead(input: Parameters[0]) { + try { + return await revalidateActiveSourceRecovery(input); + } catch (err) { + logger.warn( + { err, issueId: input.issue.id, trigger: input.trigger }, + "failed to revalidate recovery action during read projection", + ); + return input.activeRecoveryAction ?? null; + } + } + + async function revalidateActiveSourceRecoveryAfterCommittedWrite( + input: Parameters[0], + ) { + try { + return await revalidateActiveSourceRecovery(input); + } catch (err) { + logger.warn( + { err, issueId: input.issue.id, trigger: input.trigger }, + "failed to revalidate recovery action after committed issue write", + ); + return input.activeRecoveryAction ?? null; + } + } + function withContentPath(attachment: T) { return { ...attachment, @@ -1240,6 +1430,51 @@ export function issueRoutes( return false; } + async function assertRecoveryActionAuthority( + req: Request, + res: Response, + issue: { id: string; companyId: string; assigneeAgentId: string | null }, + activeRecoveryAction: Awaited>, + input: { source: "issue_update" | "recovery_action_resolution" }, + ) { + if (req.actor.type !== "agent") return true; + if (!activeRecoveryAction) return true; + + const actorAgentId = req.actor.agentId; + if (!actorAgentId) { + res.status(403).json({ error: "Agent authentication required" }); + return false; + } + if (issue.assigneeAgentId === actorAgentId) return true; + if ( + issue.assigneeAgentId && + await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId) + ) { + return true; + } + if (activeRecoveryAction.ownerAgentId === actorAgentId) return true; + if ( + activeRecoveryAction.ownerAgentId && + await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId) + ) { + return true; + } + + res.status(403).json({ + error: "Agent cannot resolve another owner's recovery action", + details: { + issueId: issue.id, + recoveryActionId: activeRecoveryAction.id, + actorAgentId, + assigneeAgentId: issue.assigneeAgentId, + recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId, + source: input.source, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"], + }, + }); + return false; + } + async function resolveActiveIssueRun(issue: { id: string; assigneeAgentId: string | null; @@ -1512,6 +1747,19 @@ export function issueRoutes( listSuccessfulRunHandoffStates(db, companyId, issueIds), recoveryActionsSvc.listActiveForIssues(companyId, issueIds), ]); + const actor = getActorInfo(req); + await Promise.all(result.map(async (issue) => { + const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null; + if (!activeRecoveryAction) return; + const revalidated = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor, + activeRecoveryAction, + }); + if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); + else recoveryActionByIssue.delete(issue.id); + })); res.json(result.map((issue) => ({ ...issue, successfulRunHandoff: handoffStates.get(issue.id) ?? null, @@ -1668,6 +1916,12 @@ export function issueRoutes( relations, recoveryActionsByRelationIssue, ); + const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + activeRecoveryAction, + }); res.json({ issue: { @@ -1680,7 +1934,7 @@ export function issueRoutes( ...(blockerAttention ? { blockerAttention } : {}), productivityReview, scheduledRetry, - activeRecoveryAction, + activeRecoveryAction: revalidatedActiveRecoveryAction, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, @@ -1786,6 +2040,12 @@ export function issueRoutes( relations, recoveryActionsByRelationIssue, ); + const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + activeRecoveryAction, + }); const mentionedProjects = mentionedProjectIds.length > 0 ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) : []; @@ -1801,7 +2061,7 @@ export function issueRoutes( productivityReview, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, scheduledRetry, - activeRecoveryAction, + activeRecoveryAction: revalidatedActiveRecoveryAction, blockedBy: relationsWithRecoveryActions.blockedBy, blocks: relationsWithRecoveryActions.blocks, relatedWork: referenceSummary, @@ -1823,7 +2083,11 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id); + const active = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + }); res.json({ active, actions: active ? [active] : [], @@ -1839,6 +2103,18 @@ export function issueRoutes( } assertCompanyAccess(req, existing.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; + const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id); + if ( + !(await assertRecoveryActionAuthority( + req, + res, + existing, + activeRecoveryAction, + { source: "recovery_action_resolution" }, + )) + ) { + return; + } const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body; if (outcome === "false_positive" || outcome === "cancelled") { @@ -1948,6 +2224,36 @@ export function issueRoutes( }, }); + if ( + sourceIssueStatus === "todo" && + existing.status !== result.issue.status && + result.issue.assigneeAgentId + ) { + void heartbeat.wakeup(result.issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_recovery_action_restored", + payload: { + issueId: result.issue.id, + recoveryActionId: result.recoveryAction.id, + mutation: "recovery_action_resolution", + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: result.issue.id, + taskId: result.issue.id, + wakeReason: "issue_recovery_action_restored", + source: "issue.recovery_action_resolution", + recoveryActionId: result.recoveryAction.id, + }, + }).catch((err) => + logger.warn( + { err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId }, + "failed to wake agent after recovery action restored issue", + )); + } + res.json({ issue: { ...result.issue, @@ -2087,6 +2393,13 @@ export function issueRoutes( }); } + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); + res.status(result.created ? 201 : 200).json(doc); }); @@ -2274,6 +2587,13 @@ export function issueRoutes( source: "issue.document_restored", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); + res.json(result.document); }, ); @@ -2344,6 +2664,12 @@ export function issueRoutes( actor, source: "issue.document_deleted", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); res.json({ ok: true }); }); @@ -2376,6 +2702,12 @@ export function issueRoutes( entityId: issue.id, details: { workProductId: product.id, type: product.type, provider: product.provider }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.status(201).json(product); }); @@ -2410,6 +2742,12 @@ export function issueRoutes( entityId: existing.issueId, details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.json(product); }); @@ -2444,6 +2782,12 @@ export function issueRoutes( entityId: existing.issueId, details: { workProductId: removed.id, type: removed.type }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.json(removed); }); @@ -2931,6 +3275,28 @@ export function issueRoutes( const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; + const recoveryRelevantSourceMutationRequested = + req.body.status !== undefined || + normalizedAssigneeAgentId !== undefined || + req.body.assigneeUserId !== undefined || + Array.isArray(req.body.blockedByIssueIds) || + req.body.executionPolicy !== undefined || + explicitMoveToTodoRequested; + const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested + ? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id) + : null; + if ( + recoveryRelevantSourceMutationRequested && + !(await assertRecoveryActionAuthority( + req, + res, + existing, + activeRecoveryActionBeforeUpdate, + { source: "issue_update" }, + )) + ) { + return; + } const effectiveMoveToTodoRequested = explicitMoveToTodoRequested || (!!commentBody && @@ -3207,6 +3573,7 @@ export function issueRoutes( let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown; + activeRecoveryAction?: unknown; relatedWork?: Awaited>; referencedIssueIdentifiers?: string[]; } = issue; @@ -3258,6 +3625,32 @@ export function issueRoutes( previous.status !== undefined && issue.status === "todo"; const reopenFromStatus = reopened ? existing.status : null; + const statusChangedFromBlockedToTodo = + existing.status === "blocked" && + issue.status === "todo" && + (req.body.status !== undefined || reopened); + const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "issue_update", + actor, + activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined, + statusChanged: existing.status !== issue.status, + assigneeChanged: + existing.assigneeAgentId !== issue.assigneeAgentId || + existing.assigneeUserId !== issue.assigneeUserId, + blockersChanged: Array.isArray(req.body.blockedByIssueIds), + executionPolicyChanged: req.body.executionPolicy !== undefined, + monitorChanged, + resumeRequested: resumeRequested === true, + reopened, + blockedToTodoRecovery: statusChangedFromBlockedToTodo, + }); + if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) { + issueResponse = { + ...issueResponse, + activeRecoveryAction: null, + }; + } await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -3531,10 +3924,6 @@ export function issueRoutes( existing.status === "backlog" && issue.status !== "backlog" && req.body.status !== undefined; - const statusChangedFromBlockedToTodo = - existing.status === "blocked" && - issue.status === "todo" && - (req.body.status !== undefined || reopened); const statusChangedFromClosedToTodo = isClosedIssueStatus(existing.status) && issue.status === "todo" && @@ -4126,12 +4515,18 @@ export function issueRoutes( }); } + const acceptedPlanConfirmation = + interaction.kind === "request_confirmation" && + interaction.status === "accepted" && + issue.workMode === "planning"; queueResolvedInteractionContinuationWakeup({ heartbeat, issue: continuationWakeIssue, interaction, actor, source: "issue.interaction.accept", + forceFreshSession: acceptedPlanConfirmation, + workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null, }); res.json(interaction); @@ -4630,6 +5025,16 @@ export function issueRoutes( source: "issue.comment", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue: currentIssue, + trigger: "comment", + actor, + statusChanged: reopened, + resumeRequested: resumeRequested === true, + reopened, + blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo", + }); + // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 06906e0f..466b7af9 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1000,7 +1000,7 @@ function redactInlineBase64ImageData(chunk: string) { } export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) { - const normalized = redactInlineBase64ImageData(chunk); + const normalized = redactSensitiveText(redactInlineBase64ImageData(chunk)); if (normalized.length <= maxChars) return normalized; const headChars = Math.max(0, Math.floor(maxChars * 0.6)); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index fb47582f..ae349906 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -73,7 +73,10 @@ import { issueTreeControlService, type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; -import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; +import { + parseIssueGraphLivenessIncidentKey, + RECOVERY_ORIGIN_KINDS, +} from "./recovery/origins.js"; import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -4515,6 +4518,25 @@ export function issueService(db: Db) { } } const [enriched] = await withIssueLabels(tx, [updated]); + if ( + (issueData.status === "done" || issueData.status === "cancelled") && + existing.status !== issueData.status && + existing.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation + ) { + const parsedIncident = parseIssueGraphLivenessIncidentKey(existing.originId); + if (parsedIncident?.issueId && parsedIncident.companyId === existing.companyId) { + await tx + .delete(issueRelations) + .where( + and( + eq(issueRelations.companyId, existing.companyId), + eq(issueRelations.issueId, existing.id), + eq(issueRelations.relatedIssueId, parsedIncident.issueId), + eq(issueRelations.type, "blocks"), + ), + ); + } + } return enriched; }; diff --git a/server/src/services/plugin-local-folders.ts b/server/src/services/plugin-local-folders.ts index 049b6e66..2243e970 100644 --- a/server/src/services/plugin-local-folders.ts +++ b/server/src/services/plugin-local-folders.ts @@ -486,8 +486,12 @@ export async function writePluginLocalFolderTextAtomic( contents: string, ) { const rootRealPath = await fs.realpath(rootPath); - const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath); - await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true }); + const normalized = normalizeRelativePath(relativePath); + const parentRelativePath = path.dirname(normalized); + if (parentRelativePath !== ".") { + await ensureDirectoryInsideRoot(rootRealPath, parentRelativePath); + } + const resolved = await resolvePluginLocalFolderPath(rootRealPath, normalized); await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath)); const tempPath = path.join( path.dirname(resolved.absolutePath), diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f94d5b52..47c6711e 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.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 { DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -11,11 +11,12 @@ import { agents, agentWakeupRequests, approvals, + activityLog, companies, - issueComments, heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, + issueComments, issueApprovals, issueRecoveryActions, issueRelations, @@ -26,6 +27,7 @@ import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js"; import { runningProcesses } from "../../adapters/index.js"; import { forbidden, notFound } from "../../errors.js"; import { logger } from "../../middleware/logger.js"; +import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local-service-supervisor.js"; import { redactCurrentUserText } from "../../log-redaction.js"; import { redactSensitiveText } from "../../redaction.js"; import { logActivity } from "../activity-log.js"; @@ -68,6 +70,15 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024; const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery; const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const SESSIONED_LOCAL_ADAPTERS = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "hermes_local", + "opencode_local", + "pi_local", +]); type RecoveryWakeupOptions = { source?: "timer" | "assignment" | "on_demand" | "automation"; @@ -673,6 +684,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return `stale_active_run:${companyId}:${runId}`; } + function isTerminalIssueStatus(status: string | null | undefined) { + return status === "done" || status === "cancelled"; + } + + function isRecoveryOriginIssue(issue: typeof issues.$inferSelect) { + return Object.values(RECOVERY_ORIGIN_KINDS).includes( + issue.originKind as typeof RECOVERY_ORIGIN_KINDS[keyof typeof RECOVERY_ORIGIN_KINDS], + ); + } + function silenceStartedAtForRun(run: Pick) { return run.lastOutputAt ?? run.processStartedAt ?? run.startedAt ?? run.createdAt ?? null; } @@ -798,6 +819,309 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return issue ?? null; } + async function latestSameRunSourceTerminalEvidence(input: { + run: typeof heartbeatRuns.$inferSelect; + sourceIssue: typeof issues.$inferSelect; + evidenceAfter: Date | null; + }) { + if (!isTerminalIssueStatus(input.sourceIssue.status)) return null; + const after = input.evidenceAfter ?? input.run.startedAt ?? input.run.createdAt ?? null; + const activityPredicates = [ + eq(activityLog.companyId, input.run.companyId), + eq(activityLog.runId, input.run.id), + eq(activityLog.action, "issue.updated"), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, input.sourceIssue.id), + sql`${activityLog.details} ->> 'status' = ${input.sourceIssue.status}`, + ]; + if (after) { + activityPredicates.push(gte(activityLog.createdAt, after)); + } + + const activity = await db + .select({ + id: activityLog.id, + createdAt: activityLog.createdAt, + action: activityLog.action, + }) + .from(activityLog) + .where(and(...activityPredicates)) + .orderBy(desc(activityLog.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (activity) { + return { + kind: "activity" as const, + id: activity.id, + createdAt: activity.createdAt, + action: activity.action, + }; + } + return null; + } + + async function nextRunEventSeq(runId: string) { + const [row] = await db + .select({ maxSeq: sql`max(${heartbeatRunEvents.seq})` }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + return Number(row?.maxSeq ?? 0) + 1; + } + + async function appendRecoveryRunEvent( + run: typeof heartbeatRuns.$inferSelect, + event: { + level: "info" | "warn" | "error"; + message: string; + payload?: Record; + }, + ) { + await db.insert(heartbeatRunEvents).values({ + companyId: run.companyId, + runId: run.id, + agentId: run.agentId, + seq: await nextRunEventSeq(run.id), + eventType: "lifecycle", + stream: "system", + level: event.level, + message: event.message, + payload: event.payload ?? null, + }); + } + + async function cleanupSourceResolvedRunProcess(input: { + run: typeof heartbeatRuns.$inferSelect; + runningAgent: typeof agents.$inferSelect; + }) { + if (!SESSIONED_LOCAL_ADAPTERS.has(input.runningAgent.adapterType)) { + return { + attempted: false, + outcome: "skipped_non_local_adapter", + adapterType: input.runningAgent.adapterType, + }; + } + + const running = runningProcesses.get(input.run.id); + const pid = running?.child.pid ?? input.run.processPid ?? null; + const processGroupId = running?.processGroupId ?? input.run.processGroupId ?? null; + if (typeof pid !== "number" && typeof processGroupId !== "number") { + return { + attempted: false, + outcome: "no_process_metadata", + adapterType: input.runningAgent.adapterType, + }; + } + + const wasAlive = + (typeof pid === "number" && isPidAlive(pid)) || + (typeof processGroupId === "number" && isProcessGroupAlive(processGroupId)); + if (!wasAlive) { + runningProcesses.delete(input.run.id); + return { + attempted: false, + outcome: "not_running", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + }; + } + + try { + await terminateLocalService( + { + pid: typeof pid === "number" && Number.isInteger(pid) && pid > 0 + ? pid + : (processGroupId ?? 0), + processGroupId: typeof processGroupId === "number" && Number.isInteger(processGroupId) && processGroupId > 0 + ? processGroupId + : null, + }, + running ? { forceAfterMs: Math.max(1, running.graceSec) * 1000 } : undefined, + ); + runningProcesses.delete(input.run.id); + const stillAlive = + (typeof pid === "number" && isPidAlive(pid)) || + (typeof processGroupId === "number" && isProcessGroupAlive(processGroupId)); + return { + attempted: true, + outcome: stillAlive ? "termination_sent_still_running" : "terminated", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + }; + } catch (error) { + return { + attempted: true, + outcome: "failed", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async function finalizeAgentAfterSourceResolvedRun(run: typeof heartbeatRuns.$inferSelect, status: "succeeded" | "cancelled") { + const [runningCountRow] = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.agentId, run.agentId), eq(heartbeatRuns.status, "running"))); + const runningCount = Number(runningCountRow?.count ?? 0); + const nextStatus = runningCount > 0 ? "running" : status === "succeeded" || status === "cancelled" ? "idle" : "error"; + await db + .update(agents) + .set({ + status: nextStatus, + lastHeartbeatAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(agents.id, run.agentId), notInArray(agents.status, ["paused", "terminated"]))); + } + + async function foldSourceResolvedStaleRun(input: { + run: typeof heartbeatRuns.$inferSelect; + runningAgent: typeof agents.$inferSelect; + sourceIssue: typeof issues.$inferSelect; + evidence: Awaited>; + existingEvaluation: Awaited>; + silenceStartedAt: Date | null; + silenceAgeMs: number | null; + now: Date; + }) { + if (!input.evidence) return { kind: "skipped" as const }; + const cleanup = await cleanupSourceResolvedRunProcess({ run: input.run, runningAgent: input.runningAgent }); + const finalRunStatus = input.sourceIssue.status === "cancelled" ? "cancelled" : "succeeded"; + const resultJson = { + ...parseObject(input.run.resultJson), + sourceResolvedWatchdogFold: { + sourceIssueId: input.sourceIssue.id, + sourceIssueIdentifier: input.sourceIssue.identifier, + sourceIssueStatus: input.sourceIssue.status, + sameRunEvidenceKind: input.evidence.kind, + sameRunEvidenceId: input.evidence.id, + sameRunEvidenceAt: input.evidence.createdAt.toISOString(), + silenceStartedAt: input.silenceStartedAt?.toISOString() ?? null, + silenceAgeMs: input.silenceAgeMs, + evaluationIssueId: input.existingEvaluation?.id ?? null, + evaluationIssueIdentifier: input.existingEvaluation?.identifier ?? null, + cleanup, + }, + }; + const finalizedRun = await db.transaction(async (tx) => { + const [updatedRun] = await tx + .update(heartbeatRuns) + .set({ + status: finalRunStatus, + finishedAt: input.now, + error: null, + errorCode: null, + resultJson, + updatedAt: input.now, + }) + .where(and(eq(heartbeatRuns.id, input.run.id), eq(heartbeatRuns.companyId, input.run.companyId), eq(heartbeatRuns.status, "running"))) + .returning(); + if (!updatedRun) return null; + + if (input.run.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ + status: finalRunStatus === "succeeded" ? "completed" : "cancelled", + finishedAt: input.now, + error: null, + updatedAt: input.now, + }) + .where(and(eq(agentWakeupRequests.id, input.run.wakeupRequestId), eq(agentWakeupRequests.companyId, input.run.companyId))); + } + + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: input.now, + }) + .where( + and( + eq(issues.id, input.sourceIssue.id), + eq(issues.companyId, input.run.companyId), + eq(issues.executionRunId, input.run.id), + ), + ); + + return updatedRun; + }); + if (!finalizedRun) return { kind: "skipped" as const }; + + if (input.existingEvaluation && !isTerminalIssueStatus(input.existingEvaluation.status)) { + await issuesSvc.update(input.existingEvaluation.id, { status: "done" }); + await issuesSvc.addComment(input.existingEvaluation.id, [ + "Source-resolved watchdog fold.", + "", + `- Source issue: ${input.sourceIssue.identifier ?? input.sourceIssue.id}`, + `- Run: \`${input.run.id}\``, + `- Same-run evidence: \`${input.evidence.kind}:${input.evidence.id}\` at ${input.evidence.createdAt.toISOString()}`, + "- Outcome: false positive; the source issue already reached a terminal disposition from this run.", + ].join("\n"), { runId: input.run.id }); + } + + const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(input.run.companyId, input.sourceIssue.id); + if (activeRecoveryAction?.kind === "active_run_watchdog") { + await recoveryActionsSvc.resolveActiveForIssue({ + companyId: input.run.companyId, + sourceIssueId: input.sourceIssue.id, + actionId: activeRecoveryAction.id, + status: "resolved", + outcome: "false_positive", + resolutionNote: "Source issue reached a terminal disposition through durable same-run activity; watchdog folded as source-resolved.", + }); + } + + const [decision] = await db + .insert(heartbeatRunWatchdogDecisions) + .values({ + companyId: input.run.companyId, + runId: input.run.id, + evaluationIssueId: input.existingEvaluation?.id ?? null, + decision: "dismissed_false_positive", + reason: "Source issue already reached a terminal disposition through durable same-run activity.", + createdByRunId: input.run.id, + }) + .returning(); + + await appendRecoveryRunEvent(finalizedRun, { + level: cleanup.outcome === "failed" ? "warn" : "info", + message: "Source-resolved watchdog fold finalized stale active run", + payload: resultJson.sourceResolvedWatchdogFold, + }); + await logActivity(db, { + companyId: input.run.companyId, + actorType: "system", + actorId: "system", + agentId: input.run.agentId, + runId: input.run.id, + action: "heartbeat.output_stale_source_resolved", + entityType: "heartbeat_run", + entityId: input.run.id, + details: { + source: "recovery.scan_silent_active_runs", + sourceIssueId: input.sourceIssue.id, + sourceIssueIdentifier: input.sourceIssue.identifier, + sourceIssueStatus: input.sourceIssue.status, + evaluationIssueId: input.existingEvaluation?.id ?? null, + watchdogDecisionId: decision.id, + sameRunEvidenceKind: input.evidence.kind, + sameRunEvidenceId: input.evidence.id, + sameRunEvidenceAt: input.evidence.createdAt.toISOString(), + cleanup, + }, + }); + await finalizeAgentAfterSourceResolvedRun(finalizedRun, finalRunStatus); + return { kind: "folded" as const, evaluationIssueId: input.existingEvaluation?.id ?? null }; + } + async function resolveStaleRunOwnerAgentId(input: { run: typeof heartbeatRuns.$inferSelect; runningAgent: typeof agents.$inferSelect; @@ -1030,6 +1354,47 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const runningAgent = await getAgent(input.run.agentId); if (!runningAgent || runningAgent.companyId !== input.run.companyId) return { kind: "skipped" as const }; const sourceIssue = await resolveStaleRunSourceIssue(input.run); + const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id); + if (sourceIssue && isRecoveryOriginIssue(sourceIssue)) { + await logActivity(db, { + companyId: input.run.companyId, + actorType: "system", + actorId: "system", + agentId: input.run.agentId, + runId: input.run.id, + action: "heartbeat.output_stale_recovery_recursion_refused", + entityType: "heartbeat_run", + entityId: input.run.id, + details: { + source: "recovery.scan_silent_active_runs", + sourceIssueId: sourceIssue.id, + sourceIssueIdentifier: sourceIssue.identifier, + sourceIssueOriginKind: sourceIssue.originKind, + existingEvaluationIssueId: existing?.id ?? null, + }, + }); + return { kind: "skipped" as const }; + } + const silenceStartedAt = silenceStartedAtForRun(input.run); + if (sourceIssue && isTerminalIssueStatus(sourceIssue.status)) { + const terminalEvidence = await latestSameRunSourceTerminalEvidence({ + run: input.run, + sourceIssue, + evidenceAfter: silenceStartedAt, + }); + if (terminalEvidence) { + return foldSourceResolvedStaleRun({ + run: input.run, + runningAgent, + sourceIssue, + evidence: terminalEvidence, + existingEvaluation: existing, + silenceStartedAt, + silenceAgeMs: silenceAgeMsForRun(input.run, input.now), + now: input.now, + }); + } + } const prefix = await getCompanyIssuePrefix(input.run.companyId); const evidence = await collectStaleRunEvidence({ run: input.run, @@ -1039,7 +1404,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) now: input.now, }); const level = (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious"; - const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id); if (existing) { if (level === "critical" && existing.priority !== "high") { await issuesSvc.update(existing.id, { @@ -1174,6 +1538,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) created: 0, existing: 0, escalated: 0, + folded: 0, snoozed: 0, skipped: 0, evaluationIssueIds: [] as string[], @@ -1188,6 +1553,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (outcome.kind === "created") result.created += 1; else if (outcome.kind === "existing") result.existing += 1; else if (outcome.kind === "escalated") result.escalated += 1; + else if (outcome.kind === "folded") result.folded += 1; else result.skipped += 1; if ("evaluationIssueId" in outcome && outcome.evaluationIssueId) { result.evaluationIssueIds.push(outcome.evaluationIssueId); @@ -2382,7 +2748,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) { const parsed = parseIssueGraphLivenessIncidentKey(row.originId); if (!parsed || parsed.companyId !== row.companyId) return []; - if (parsed.state !== "blocked_by_assigned_backlog_issue") return []; return [ { companyId: row.companyId, @@ -2575,6 +2940,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ) { continue; } + const sourceIssue = await db + .select({ + id: issues.id, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, parsed.companyId), eq(issues.id, parsed.issueId))) + .then((rows) => rows[0] ?? null); + if (sourceIssue && !["done", "cancelled"].includes(sourceIssue.status)) { + const blockerIds = await existingBlockerIssueIds(parsed.companyId, sourceIssue.id); + if (blockerIds.includes(recovery.id)) { + result.activeSkipped += 1; + continue; + } + } if (await removeRecoveryBlockerFromSource(recovery)) { result.blockerRelationsRemoved += 1; } diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 0bf061d6..14def41b 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -131,7 +131,7 @@ export const issuesApi = { data: { actionId?: string; outcome: "restored" | "false_positive" | "blocked" | "cancelled"; - sourceIssueStatus: "done" | "in_review" | "blocked"; + sourceIssueStatus: "todo" | "done" | "in_review" | "blocked"; resolutionNote?: string | null; }, ) => api.post(`/issues/${id}/recovery-actions/resolve`, data), diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 936c168c..be0de14d 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -121,6 +121,22 @@ async function flush() { }); } +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + + throw lastError; +} + function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", @@ -476,6 +492,60 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("searches all company issues when adding a blocker", async () => { + const onUpdate = vi.fn(); + const loadedIssue = createIssue({ id: "issue-3", identifier: "PAP-3", title: "Loaded issue", status: "todo" }); + const remoteIssue = createIssue({ id: "issue-99", identifier: "PAP-99", title: "Remote blocker", status: "in_progress" }); + mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => { + if (filters?.q === "remote") return Promise.resolve([remoteIssue]); + return Promise.resolve([loadedIssue]); + }); + + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const addButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Add blocker")); + expect(addButton).not.toBeUndefined(); + + await act(async () => { + addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const searchInput = container.querySelector('input[aria-label="Search issues to add as blockers"]') as HTMLInputElement | null; + expect(searchInput).not.toBeNull(); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + nativeSetter?.call(searchInput, "remote"); + searchInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "remote", limit: 50 }); + expect(container.textContent).toContain("PAP-99 Remote blocker"); + expect(container.textContent).not.toContain("PAP-3 Loaded issue"); + }); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-99 Remote blocker")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-99"] }); + + act(() => root.unmount()); + }); + it("removes a blocked-by issue from the chip remove action after confirmation", async () => { const onUpdate = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 50a64bd8..ac323878 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -145,6 +145,8 @@ interface IssuePropertiesProps { inline?: boolean; } +const ISSUE_BLOCKER_SEARCH_LIMIT = 50; + function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
    @@ -405,6 +407,7 @@ export function IssueProperties({ const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? ""); const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? ""); + const normalizedBlockedBySearch = blockedBySearch.trim(); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -443,10 +446,21 @@ export function IssueProperties({ enabled: !!companyId, }); - const { data: allIssues } = useQuery({ + const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), - enabled: !!companyId && (blockedByOpen || parentOpen), + enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)), + }); + + const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({ + queryKey: companyId + ? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT) + : ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT], + queryFn: () => issuesApi.list(companyId!, { + q: normalizedBlockedBySearch, + limit: ISSUE_BLOCKER_SEARCH_LIMIT, + }), + enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0, }); const createLabel = useMutation({ @@ -1648,27 +1662,28 @@ export function IssueProperties({ ); const blockingIssues = issue.blocks ?? []; - const blockerOptions = (allIssues ?? []) - .filter((candidate) => candidate.id !== issue.id) - .filter((candidate) => { - if (!blockedBySearch.trim()) return true; - const query = blockedBySearch.toLowerCase(); - return ( - (candidate.identifier ?? "").toLowerCase().includes(query) || - candidate.title.toLowerCase().includes(query) - ); - }) - .sort((a, b) => { + const blockerSearchActive = normalizedBlockedBySearch.length > 0; + const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues; + const blockerOptions = (blockerSourceIssues ?? []) + .filter((candidate) => candidate.id !== issue.id); + if (!blockerSearchActive) { + blockerOptions.sort((a, b) => { const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); return aLabel.localeCompare(bLabel); }); + } + const blockerOptionsLoading = blockedByOpen && ( + blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues + ); const toggleBlockedBy = (blockedByIssueId: string) => { const nextBlockedByIds = blockedByIds.includes(blockedByIssueId) ? blockedByIds.filter((candidate) => candidate !== blockedByIssueId) : [...blockedByIds, blockedByIssueId]; onUpdate({ blockedByIssueIds: nextBlockedByIds }); + setBlockedByOpen(false); + setBlockedBySearch(""); }; const removeBlockedBy = (blockedByIssueId: string) => { onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) }); @@ -1682,6 +1697,7 @@ export function IssueProperties({ value={blockedBySearch} onChange={(e) => setBlockedBySearch(e.target.value)} autoFocus={!inline} + aria-label="Search issues to add as blockers" />
    @@ -1709,9 +1729,15 @@ export function IssueProperties({ {candidate.identifier ? `${candidate.identifier} ` : ""} {candidate.title} + {selected &&
    ); diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 56953732..5b80ceca 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -165,19 +165,20 @@ describe("IssueRecoveryActionCard", () => { expect(node.textContent).toContain("Resolved as restored"); }); - it("calls resolve with done and does not offer delegated recovery", () => { + it("calls resolve with todo and does not offer delegated recovery", () => { const onResolve = vi.fn(); const node = render( , ); click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.textContent).toContain("Try again"); expect(document.body.textContent).toContain("Mark issue done"); expect(document.body.textContent).not.toContain("Mark blocked"); expect(document.body.textContent).not.toContain("Delegate follow-up issue"); - click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null); + click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Try again")) ?? null); - expect(onResolve).toHaveBeenCalledWith("done"); + expect(onResolve).toHaveBeenCalledWith("todo"); }); it("does not offer blocked recovery resolution without a blocker selection flow", () => { @@ -186,6 +187,7 @@ describe("IssueRecoveryActionCard", () => { ); click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.textContent).toContain("Try again"); expect(document.body.textContent).toContain("Mark issue done"); expect(document.body.textContent).toContain("Send for review"); expect(document.body.textContent).toContain("False positive, done"); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 88fdcebb..16ce3f5f 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -25,6 +25,7 @@ export type RecoveryCardCardState = RecoveryDisplayState; export const deriveRecoveryCardState = deriveRecoveryDisplayState; export type RecoveryResolveOutcome = + | "todo" | "done" | "in_review" | "false_positive_done" @@ -292,6 +293,11 @@ const RESOLVE_OPTIONS: Array<{ destructive?: boolean; boardOnly?: boolean; }> = [ + { + outcome: "todo", + label: "Try again", + description: "Dismiss recovery and return the source issue to todo.", + }, { outcome: "done", label: "Mark issue done", diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 222f1cf1..8b0e10b3 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -238,6 +238,7 @@ describe("IssueRow", () => { const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; expect(link).not.toBeNull(); expect(link?.textContent).toContain("Planning"); + expect(link?.textContent?.match(/Planning/g)).toHaveLength(1); act(() => { root.unmount(); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 83873bb6..546933c8 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -126,7 +126,6 @@ export function IssueRow({ {mobileLeading ?? } {productivityReviewIndicator} - {planningModeIndicator} {parkedBlockerIndicator} {recoveryIndicator} @@ -153,11 +152,11 @@ export function IssueRow({ {identifier} - {planningModeIndicator} {parkedBlockerIndicator} {recoveryIndicator} )} + {planningModeIndicator} {mobileMeta ? ( <>
    diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 84cdee0f..7335db49 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1250,7 +1250,9 @@ export function IssuesList({ } else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey; else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") { - const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey) ?? null; + const representativeIssue = group?.items.find((issue) => + issue.executionWorkspaceId === groupKey || issue.projectWorkspaceId === groupKey, + ) ?? null; const executionWorkspace = executionWorkspaceById.get(groupKey); if (executionWorkspace) { defaults.executionWorkspaceId = groupKey; diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 78c7e07e..72c4a2d9 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -8,6 +8,7 @@ import { buildAgentMentionHref, buildIssueReferenceHref, buildProjectMentionHref, + buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, } from "@paperclipai/shared"; @@ -92,12 +93,12 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); - it("renders user, agent, project, and skill mentions as chips", () => { + it("renders user, agent, project, skill, and routine mentions as chips", () => { const html = renderToStaticMarkup( - {`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} + {`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")}) [/routine:Weekly review](${buildRoutineMentionHref("routine-123")})`} , @@ -113,6 +114,8 @@ describe("MarkdownBody", () => { expect(html).toContain("--paperclip-mention-project-color:#336699"); expect(html).toContain('href="/skills/skill-789"'); expect(html).toContain('data-mention-kind="skill"'); + expect(html).toContain('href="/routines/routine-123"'); + expect(html).toContain('data-mention-kind="routine"'); }); it("sanitizes unsafe javascript markdown links", () => { diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c70e750c..75675a0f 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -586,11 +586,13 @@ export function MarkdownBody({ ? `/projects/${parsed.projectId}` : parsed.kind === "issue" ? `/issues/${parsed.identifier}` - : parsed.kind === "skill" - ? `/skills/${parsed.skillId}` - : parsed.kind === "user" - ? "/company/settings/access" - : `/agents/${parsed.agentId}`; + : parsed.kind === "skill" + ? `/skills/${parsed.skillId}` + : parsed.kind === "routine" + ? `/routines/${parsed.routineId}` + : parsed.kind === "user" + ? "/company/settings/access" + : `/agents/${parsed.agentId}`; return ( { expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); }); + it("keeps routine slash queries active across spaces", () => { + expect(findMentionMatch("/routine:Weekly release review", "/routine:Weekly release review".length)).toEqual({ + trigger: "skill", + marker: "/", + query: "routine:Weekly release review", + atPos: 0, + endPos: "/routine:Weekly release review".length, + }); + }); + it("does not treat Enter as skill autocomplete accept", () => { expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false); expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true); @@ -623,6 +633,26 @@ describe("MarkdownEditor", () => { expect(found).toBe(skillLink); }); + it("finds routine anchors by mention metadata instead of visible text", () => { + const editable = document.createElement("div"); + const routineLink = document.createElement("a"); + routineLink.setAttribute("href", buildRoutineMentionHref("routine-123")); + routineLink.textContent = "/routine:Weekly release review "; + editable.appendChild(routineLink); + + const found = findClosestAutocompleteAnchor(editable, { + id: "routine:routine-123", + kind: "routine", + routineId: "routine-123", + name: "Weekly release review", + status: "active", + href: buildRoutineMentionHref("routine-123"), + aliases: ["routine:Weekly release review", "Weekly release review"], + }); + + expect(found).toBe(routineLink); + }); + it("places the caret after the mention's trailing space when present", () => { const editable = document.createElement("div"); editable.contentEditable = "true"; diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 78fb82df..0a0c5067 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -31,8 +31,13 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared"; -import { Boxes, User } from "lucide-react"; +import { + buildAgentMentionHref, + buildProjectMentionHref, + buildRoutineMentionHref, + buildUserMentionHref, +} from "@paperclipai/shared"; +import { Boxes, CalendarClock, User } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; @@ -41,7 +46,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste"; import { normalizeMarkdown } from "../lib/normalize-markdown"; import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { cn } from "../lib/utils"; -import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext"; +import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext"; /* ---- Mention types ---- */ @@ -188,7 +193,7 @@ interface MentionState { endPos: number; } -type AutocompleteOption = MentionOption | SkillCommandOption; +type AutocompleteOption = MentionOption | SlashCommandOption; interface MentionMenuViewport { offsetLeft: number; @@ -260,7 +265,9 @@ export function findMentionMatch( if (atPos === -1) return null; const query = text.slice(atPos + 1, offset); - if (trigger === "skill" && /\s/.test(query)) return null; + if (trigger === "skill" && /\s/.test(query) && !query.toLowerCase().startsWith("routine:")) { + return null; + } return { trigger: trigger ?? "mention", @@ -423,12 +430,21 @@ function mentionMarkdown(option: MentionOption): string { return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } -function skillMarkdown(option: SkillCommandOption): string { +function slashCommandLabel(option: SlashCommandOption): string { + return option.kind === "routine" ? `/routine:${option.name}` : `/${option.slug}`; +} + +function slashCommandMarkdown(option: SlashCommandOption): string { + if (option.kind === "routine") { + return `[${slashCommandLabel(option)}](${buildRoutineMentionHref(option.routineId)}) `; + } return `[/${option.slug}](${option.href}) `; } function autocompleteMarkdown(option: AutocompleteOption): string { - return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); + return option.kind === "skill" || option.kind === "routine" + ? slashCommandMarkdown(option) + : mentionMarkdown(option); } export function shouldAcceptAutocompleteKey( @@ -461,6 +477,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string) if (option.kind === "skill") { return parsed.kind === "skill" && parsed.skillId === option.skillId; } + if (option.kind === "routine") { + return parsed.kind === "routine" && parsed.routineId === option.routineId; + } if (option.kind === "project" && option.projectId) { return parsed.kind === "project" && parsed.projectId === option.projectId; @@ -785,7 +804,7 @@ export const MarkdownEditor = forwardRef continue; } - if (parsed.kind === "skill") { + if (parsed.kind === "skill" || parsed.kind === "routine") { applyMentionChipDecoration(link, parsed); continue; } @@ -1256,7 +1275,9 @@ export const MarkdownEditor = forwardRef setMentionIndex(i); }} > - {option.kind === "skill" ? ( + {option.kind === "routine" ? ( + + ) : option.kind === "skill" ? ( ) : option.kind === "project" && option.projectId ? ( className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> )} - {option.kind === "skill" ? `/${option.slug}` : option.name} + + {option.kind === "skill" || option.kind === "routine" + ? slashCommandLabel(option) + : option.name} + {option.kind === "project" && option.projectId && ( Project @@ -1287,6 +1312,11 @@ export const MarkdownEditor = forwardRef Skill )} + {option.kind === "routine" && ( + + Routine + + )} ))}
  • , diff --git a/ui/src/components/RoutineList.tsx b/ui/src/components/RoutineList.tsx index 4fabb4f0..a4abe4cb 100644 --- a/ui/src/components/RoutineList.tsx +++ b/ui/src/components/RoutineList.tsx @@ -134,7 +134,7 @@ export function RoutineListRow({
    { event.preventDefault(); event.stopPropagation(); }}> {runNowButton ? ( +
    )} + {(() => { + const fold = readSourceResolvedWatchdogFold(run.resultJson); + if (!fold) return null; + if (run.status === "failed" || run.status === "timed_out") return null; + return ; + })()} + {/* Log viewer */} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b8e71f2e..2784a954 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1770,7 +1770,7 @@ export function IssueDetail() { mutationFn: (data: { actionId?: string; outcome: ResolveRecoveryActionOutcome; - sourceIssueStatus: "done" | "in_review" | "blocked"; + sourceIssueStatus: "todo" | "done" | "in_review" | "blocked"; resolutionNote?: string | null; }) => issuesApi.resolveRecoveryAction(issueId!, data), onSuccess: ({ issue: nextIssue }) => { @@ -3000,6 +3000,9 @@ export function IssueDetail() { const actionId = activeRecoveryActionId; if (!actionId) return; switch (outcome) { + case "todo": + void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" }); + return; case "done": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" }); return; diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 3d9e3f1f..88361995 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -456,7 +456,7 @@ describe("Routines page", () => { }); }); - it("shows a row-level run now button on the routines table", async () => { + it("shows an outlined row-level run now button on the routines table", async () => { routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]); issuesListMock.mockResolvedValue([]); @@ -489,6 +489,7 @@ describe("Routines page", () => { } expect(runNowButton).toBeTruthy(); + expect(runNowButton?.getAttribute("data-variant")).toBe("outline"); await act(async () => { root.unmount(); diff --git a/ui/src/plugins/launchers.tsx b/ui/src/plugins/launchers.tsx index 6b1bb682..4dfc447a 100644 --- a/ui/src/plugins/launchers.tsx +++ b/ui/src/plugins/launchers.tsx @@ -158,6 +158,32 @@ function focusFirstElement(container: HTMLElement | null): void { container.focus(); } +function resolveLauncherNavigationTarget(target: string, hostContext: PluginLauncherContext): string { + if (/^https?:\/\//.test(target) || target.startsWith("/") || target.startsWith("#") || target.startsWith(".") || target.startsWith("?")) { + return target; + } + const companyPrefix = hostContext.companyPrefix?.trim(); + return companyPrefix ? `/${companyPrefix}/${target}` : target; +} + +function launcherRoutePath(launcher: ResolvedPluginLauncher): string | null { + if (launcher.action.type !== "navigate" && launcher.action.type !== "deepLink") return null; + if (/^https?:\/\//.test(launcher.action.target)) return null; + const [pathOnly] = launcher.action.target.split(/[?#]/, 1); + const segment = pathOnly?.split("/").filter(Boolean).at(-1); + return segment ? segment.toLowerCase() : null; +} + +function launcherDisplayName(launcher: ResolvedPluginLauncher, contribution: PluginUiContribution | undefined): string { + if (launcher.placementZone !== "sidebar" || !contribution) return launcher.displayName; + const routePath = launcherRoutePath(launcher); + if (!routePath) return launcher.displayName; + const routeSidebar = contribution.slots.find((slot) => + slot.type === "routeSidebar" && slot.routePath?.toLowerCase() === routePath + ); + return routeSidebar?.displayName ?? launcher.displayName; +} + function trapFocus(container: HTMLElement, event: KeyboardEvent): void { if (event.key !== "Tab") return; const focusable = Array.from( @@ -652,13 +678,13 @@ export function PluginLauncherProvider({ children }: { children: ReactNode }) { ) => { switch (launcher.action.type) { case "navigate": - navigate(launcher.action.target); + navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext)); return; case "deepLink": if (/^https?:\/\//.test(launcher.action.target)) { window.open(launcher.action.target, "_blank", "noopener,noreferrer"); } else { - navigate(launcher.action.target); + navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext)); } return; case "performAction": @@ -725,10 +751,12 @@ export function usePluginLauncherRuntime(): PluginLauncherRuntimeContextValue { } function DefaultLauncherTrigger({ + displayName, launcher, placementZone, onClick, }: { + displayName?: string; launcher: ResolvedPluginLauncher; placementZone: PluginLauncherPlacementZone; onClick: (event: ReactMouseEvent) => void; @@ -741,7 +769,7 @@ function DefaultLauncherTrigger({ className={launcherTriggerClassName(placementZone)} onClick={onClick} > - {launcher.displayName} + {displayName ?? launcher.displayName} ); } @@ -786,6 +814,7 @@ export function PluginLauncherOutlet({ {launchers.map((launcher) => (
    { diff --git a/ui/storybook/stories/source-resolved-fold.stories.tsx b/ui/storybook/stories/source-resolved-fold.stories.tsx new file mode 100644 index 00000000..ff39164c --- /dev/null +++ b/ui/storybook/stories/source-resolved-fold.stories.tsx @@ -0,0 +1,180 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactNode } from "react"; +import { SourceResolvedFoldCallout } from "@/components/SourceResolvedFoldCallout"; +import { SourceResolvedFoldBadge } from "@/components/SourceResolvedFoldBadge"; +import type { SourceResolvedWatchdogFold } from "@/lib/source-resolved-watchdog-fold"; + +function StoryFrame({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( +
    +
    +
    +
    + Active-run watchdog · source-resolved fold +
    +

    {title}

    + {description ? ( +

    {description}

    + ) : null} +
    + {children} +
    +
    + ); +} + +function buildFold(overrides: Partial = {}): SourceResolvedWatchdogFold { + return { + sourceIssueId: "00000000-0000-0000-0000-000093220000", + sourceIssueIdentifier: "PAP-9322", + sourceIssueStatus: "done", + sameRunEvidenceKind: "activity", + sameRunEvidenceId: "f49d4f8b-c2ee-4b3d-9d24-32deadbeef01", + sameRunEvidenceAt: "2026-05-12T18:14:33.000Z", + silenceStartedAt: "2026-05-12T18:30:00.000Z", + silenceAgeMs: 18 * 60_000, + evaluationIssueId: null, + evaluationIssueIdentifier: null, + cleanup: { + attempted: true, + outcome: "terminated", + adapterType: "claude_local", + pid: 23912, + processGroupId: 23912, + error: null, + }, + ...overrides, + }; +} + +const finalizedAt = "2026-05-12T18:48:11.000Z"; + +function DefaultPanel() { + return ; +} + +const meta = { + title: "Paperclip/Source-resolved Fold", + component: DefaultPanel, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FoldCalloutFullEvidence: Story = { + render: () => ( + + + + ), +}; + +export const FoldCalloutWithEvaluationIssue: Story = { + render: () => ( + + + + ), +}; + +export const FoldCalloutCleanupFailed: Story = { + render: () => ( + + + + ), +}; + +export const FoldCalloutCancelledSource: Story = { + render: () => ( + + + + ), +}; + +export const RunRowBadgeContext: Story = { + render: () => ( + +
    +
    + Run + 7accd7a4 + by ClaudeCoder + succeeded + + Completed + + + 3m ago +
    +
    + Run + 2606404d + by ClaudeCoder + succeeded + + Completed + + 12m ago +
    +
    +
    + ), +}; From 734385102c96f062c7ccb7194bdc8f4cd473d6b1 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 17 May 2026 17:54:15 -0700 Subject: [PATCH 22/53] Fix new secret form textarea overflow (PAPA-348) (#6222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators manage per-company secrets through the Secrets page in the web UI > - A long secret value pasted into the "New secret" textarea blew out the form's width, which pushed the Create/Cancel buttons off-screen and made the form unusable > - Root cause: the shadcn `Textarea` primitive sets `w-full` but does not constrain `min-width`, so a flex parent honors the textarea's intrinsic content width when a long unbreakable string is present > - This pull request adds `min-w-0 max-w-full` to the shared `Textarea` primitive and `min-w-0 overflow-x-hidden break-all` on the secret-value usage so a long token wraps inside the form bounds > - The benefit is the Create/Cancel buttons stay reachable regardless of pasted token length, and every other `Textarea` consumer also gets the flexbox-friendly width constraint ## What Changed - `ui/src/components/ui/textarea.tsx`: added `min-w-0 max-w-full` to the base shadcn `Textarea` so it cannot exceed its flex parent - `ui/src/pages/Secrets.tsx`: added `min-w-0 overflow-x-hidden break-all` on the new-secret value `Textarea` so long opaque tokens wrap instead of pushing the form - `ui/src/pages/Secrets.render.test.tsx`: new regression test that opens the New Secret dialog and asserts the value textarea carries the width-constraint classes ## Verification - `cd ui && npx vitest run src/pages/Secrets.render.test.tsx` — 3/3 pass - Manual: open the Secrets page, click "New secret", paste a long unbroken string (e.g. a 500-char token) into the value field. The form stays within its dialog and the Create/Cancel buttons remain in view. Before: image After: Screenshot 2026-05-17 at 5 39 38 PM After: the value field wraps with `break-all` inside the dialog; Create/Cancel stay clickable. Covered by the new render test which asserts `min-w-0`, `overflow-x-hidden`, and `break-all` are present on `#new-secret-value`. ## Risks - Low risk. The base `Textarea` change adds `min-w-0 max-w-full`, which only affects layouts where a textarea was previously allowed to grow past its parent — those cases were already buggy. `break-all` on the secret-value textarea is the right behavior for opaque tokens; it would be wrong for prose, but this field is explicitly a secret token. ## Model Used - Provider: Anthropic Claude - Model: claude-opus-4-7 (Opus 4.7) - Mode: standard Claude Code agent, tool use enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/components/ui/textarea.tsx | 2 +- ui/src/pages/Secrets.render.test.tsx | 39 ++++++++++++++++++++++++++++ ui/src/pages/Secrets.tsx | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ui/textarea.tsx b/ui/src/components/ui/textarea.tsx index 7f21b5e7..33f75628 100644 --- a/ui/src/components/ui/textarea.tsx +++ b/ui/src/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {