From 213bcd8c7ab8e4a3839852ff7bd14f4383fc0bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DREISKI?= <49559255+clemsix6@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:41:40 +0200 Subject: [PATCH 01/16] fix: include routine-execution issues in agent inbox-lite (#3329) 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 > - Agents query their own inbox via `/agents/me/inbox-lite` to discover assigned work > - `issuesSvc.list()` excludes `routine_execution` issues by default, which is appropriate for the board UI > - But agents calling `inbox-lite` need to see **all** their assigned work, including routine-created issues > - Without `includeRoutineExecutions: true`, agents miss their own in-progress issues after the first delegation step > - This causes routine-driven pipelines to stall — agents report "Inbox empty" and exit > - This pull request adds `includeRoutineExecutions: true` to the `inbox-lite` query > - The benefit is routine-driven pipelines no longer stall after delegation ## What Changed - Added `includeRoutineExecutions: true` to the `issuesSvc.list()` call in the `/agents/me/inbox-lite` route (`server/src/routes/agents.ts`) ## Verification 1. Create a routine that assigns an issue to an agent 2. Trigger the routine — first run works via `issue_assigned` event injection 3. Agent delegates (creates a subtask) and exits 4. On next heartbeat, agent queries `inbox-lite` 5. **Before fix**: issue is invisible, agent reports "Inbox empty" 6. **After fix**: issue appears in inbox, agent continues working Tested on production instance — fix resolves the stall immediately. ## Risks Low risk — additive change, only affects agent-facing inbox endpoint. Board UI keeps its default behavior (routine executions hidden for clean view). ## Model Used Claude Opus 4.6 (`claude-opus-4-6`) via Claude Code CLI — high thinking effort, 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 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 Closes #3282 --- server/src/routes/agents.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 87f3411fc6d..11b47490640 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1105,6 +1105,7 @@ export function agentRoutes(db: Db) { const rows = await issuesSvc.list(req.actor.companyId, { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", + includeRoutineExecutions: true, }); res.json( From d0a8d4e08a5a7d55e0ade6c4906830ae5699b9cb Mon Sep 17 00:00:00 2001 From: Lempkey Date: Wed, 15 Apr 2026 12:42:24 +0100 Subject: [PATCH 02/16] fix(routines): include cronExpression and timezone in list trigger response (#3209) 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 > - Routines are recurring tasks that trigger agents on a schedule or via webhook > - Routine triggers store their schedule as a `cronExpression` + `timezone` in the database > - The `GET /companies/:companyId/routines` list endpoint is the primary way API consumers (and the UI) discover all routines and their triggers > - But the list endpoint was silently dropping `cronExpression` and `timezone` from each trigger object — the DB query fetched them, but the explicit object-construction mapping only forwarded seven other fields > - This PR fixes the mapping to include `cronExpression` and `timezone`, and extends the `RoutineListItem.triggers` type to match > - The benefit is that API consumers can now see the actual schedule from the list endpoint, and future UI components reading from the list cache will get accurate schedule data ## What Changed - **`server/src/services/routines.ts`** — Added `cronExpression` and `timezone` to the explicit trigger object mapping inside `routinesService.list()`. The DB query (`listTriggersForRoutineIds`) already fetched all columns via `SELECT *`; the values were being discarded during object construction. - **`packages/shared/src/types/routine.ts`** — Extended `RoutineListItem.triggers` `Pick` to include `cronExpression` and `timezone` so the TypeScript type contract matches the actual runtime shape. - **`server/src/__tests__/routines-e2e.test.ts`** — Added assertions to the existing schedule-trigger E2E test that verify both `cronExpression` and `timezone` are present in the `GET /companies/:companyId/routines` list response. ## Verification ```bash # Run the route + service unit tests npx vitest run server/src/__tests__/routines-routes.test.ts server/src/__tests__/routines-service.test.ts # → 21 tests pass # Confirm cronExpression appears in list response curl /api/companies/{id}/routines | jq '.[].triggers[].cronExpression' # → now returns the actual cron string instead of undefined ``` Manual reproduction per the issue: 1. Create a routine with a schedule trigger (`cronExpression: "47 14 * * *"`, `timezone: "America/Mexico_City"`) 2. `GET /api/companies/{id}/routines` — trigger object now includes `cronExpression` and `timezone` ## Risks Low risk. The change only adds two fields to an existing response shape — no fields removed, no behavior changed. The `cronExpression` is `null` for non-schedule trigger kinds (webhook, etc.), consistent with `RoutineTrigger.cronExpression: string | null`. No migration required. ## Model Used - **Provider:** Anthropic - **Model:** Claude Sonnet 4.6 (`claude-sonnet-4-6`) - **Context window:** 200k tokens - **Mode:** Extended thinking + tool use (agentic) - Secondary adversarial review: OpenAI Codex (via codex-companion plugin) ## 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 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 (API-only fix; no UI rendering change) - [ ] I have updated relevant documentation to reflect my changes (no doc changes needed) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/types/routine.ts | 2 +- server/src/__tests__/routines-e2e.test.ts | 8 ++++++++ server/src/services/routines.ts | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 4efdc748c64..c25ecb358e9 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -129,7 +129,7 @@ export interface RoutineExecutionIssueOrigin { } export interface RoutineListItem extends Routine { - triggers: Pick[]; + triggers: Pick[]; lastRun: RoutineRunSummary | null; activeIssue: RoutineIssueSummary | null; } diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index dbc9801cd82..5af5c4dfc1f 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -267,6 +267,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { expect(runRes.body.source).toBe("manual"); expect(runRes.body.linkedIssueId).toBeTruthy(); + const listRes = await request(app).get(`/api/companies/${companyId}/routines`); + expect(listRes.status).toBe(200); + const listed = listRes.body.find((r: { id: string }) => r.id === routineId); + expect(listed).toBeDefined(); + expect(listed.triggers).toHaveLength(1); + expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5"); + expect(listed.triggers[0].timezone).toBe("UTC"); + const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); expect(detailRes.body.triggers).toHaveLength(1); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index db117653b07..80dfd33a8fe 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -921,6 +921,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup kind: trigger.kind as RoutineListItem["triggers"][number]["kind"], label: trigger.label, enabled: trigger.enabled, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone, nextRunAt: trigger.nextRunAt, lastFiredAt: trigger.lastFiredAt, lastResult: trigger.lastResult, From b816809a1e84a2ed7a8fc57fa0223a61814f147d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 15 Apr 2026 07:43:48 -0400 Subject: [PATCH 03/16] fix(server): respect externally set PAPERCLIP_API_URL env var (#3472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip server starts up and sets internal `PAPERCLIP_API_URL` for downstream services and adapters > - The server startup code was unconditionally overwriting `PAPERCLIP_API_URL` with `http://localhost:3100` (or equivalent based on `config.host`) > - In Kubernetes deployments, `PAPERCLIP_API_URL` is set via a ConfigMap to the externally accessible load balancer URL (e.g. `https://paperclip.example.com`) > - Because the env var was unconditionally set after loading the ConfigMap value, the ConfigMap-provided URL was ignored and replaced with the internal localhost address > - This caused downstream services (adapter env building) to use the wrong URL, breaking external access > - This pull request makes the assignment conditional — only set if not already provided by the environment > - External deployments can now supply `PAPERCLIP_API_URL` and it will be respected; local development continues to work without setting it ## What Changed - `server/src/index.ts`: Wrapped `PAPERCLIP_API_URL` assignment in `if (!process.env.PAPERCLIP_API_URL)` guard so externally provided values are preserved - `server/src/__tests__/server-startup-feedback-export.test.ts`: Added tests verifying external `PAPERCLIP_API_URL` is respected and fallback behavior is correct - `docs/deploy/environment-variables.md`: Updated `PAPERCLIP_API_URL` description to clarify it can be externally provided and the load balancer/reverse proxy use case ## Verification - Run the existing test suite: `pnpm test:run server/src/__tests__/server-startup-feedback-export.test.ts` — all 3 tests pass - Manual verification: Set `PAPERCLIP_API_URL` to a custom value before starting the server and confirm it is not overwritten ## Risks - Low risk — purely additive conditional check; existing behavior for unset env var is unchanged ## Model Used MiniMax M2.7 — reasoning-assisted for tracing the root cause through the startup chain (`buildPaperclipEnv` → `startServer` → `config.host` → `HOST` env var) ## 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 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: Pawla Abdul (Bot) Co-authored-by: Paperclip --- docs/deploy/environment-variables.md | 3 ++- .../server-startup-feedback-export.test.ts | 24 +++++++++++++++++++ server/src/index.ts | 6 +++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 5ebef728266..b5b6486dfb7 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -18,6 +18,7 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | +| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. | ## Secrets @@ -35,7 +36,7 @@ These are set automatically by the server when invoking agents: |----------|-------------| | `PAPERCLIP_AGENT_ID` | Agent's unique ID | | `PAPERCLIP_COMPANY_ID` | Company ID | -| `PAPERCLIP_API_URL` | Paperclip API base URL | +| `PAPERCLIP_API_URL` | Paperclip API base URL (inherits the server-level value; see Server Configuration above) | | `PAPERCLIP_API_KEY` | Short-lived JWT for API auth | | `PAPERCLIP_RUN_ID` | Current heartbeat run ID | | `PAPERCLIP_TASK_ID` | Issue that triggered this wake | diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index f8589544e53..c950aa6ee6e 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -180,3 +180,27 @@ describe("startServer feedback export wiring", () => { }); }); }); + +describe("startServer PAPERCLIP_API_URL handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.BETTER_AUTH_SECRET = "test-secret"; + delete process.env.PAPERCLIP_API_URL; + }); + + it("uses the externally set PAPERCLIP_API_URL when provided", async () => { + process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; + + const started = await startServer(); + + expect(started.apiUrl).toBe("http://custom-api:3100"); + expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100"); + }); + + it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { + const started = await startServer(); + + expect(started.apiUrl).toBe("http://127.0.0.1:3210"); + expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210"); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 74199e36cc4..eb6ad4e477f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -554,7 +554,9 @@ export async function startServer(): Promise { : runtimeListenHost; process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); - process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + if (!process.env.PAPERCLIP_API_URL) { + process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + } setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, @@ -792,7 +794,7 @@ export async function startServer(): Promise { server, host: config.host, listenPort, - apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`, + apiUrl: process.env.PAPERCLIP_API_URL!, databaseUrl: activeDatabaseConnectionString, }; } From f6ce9765449db5ebcf1497530379eb87f135ce81 Mon Sep 17 00:00:00 2001 From: "Knife.D" Date: Wed, 15 Apr 2026 20:44:26 +0900 Subject: [PATCH 04/16] fix: Anthropic subscription quota always shows 100% used (#3589) 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 Costs > Providers tab displays live subscription quota from each adapter (Claude, Codex) > - The Claude adapter fetches utilization from the Anthropic OAuth usage API and converts it to a 0-100 percent via `toPercent()` > - The API changed to return utilization as 0-100 percentages (e.g. `34.0` = 34%), but `toPercent()` assumed 0-1 fractions and multiplied by 100 > - After `Math.min(100, ...)` clamping, every quota window displayed as 100% used regardless of actual usage > - Additionally, `extra_usage.used_credits` and `monthly_limit` are returned in cents but were formatted as dollars, showing $6,793 instead of $67.93 > - This PR applies the same `< 1` heuristic already proven in the Codex adapter and fixes the cents-to-dollars conversion > - The benefit is accurate quota display matching what users see on claude.ai/settings/usage ## What Changed - `toPercent()`: apply `< 1` heuristic to handle both legacy 0-1 fractions and current 0-100 percentage API responses (consistent with Codex adapter's `normalizeCodexUsedPercent()`) - `formatExtraUsageLabel()`: divide `used_credits` and `monthly_limit` by 100 to convert cents to dollars before formatting - Updated all `toPercent` and `fetchClaudeQuota` tests to use current API format (0-100 range) - Added backward-compatibility test for legacy 0-1 fraction values - Added test for enabled extra usage with utilization and cents-to-dollars conversion ## Verification - `toPercent(34.0)` → `34` (was `100`) - `toPercent(91.0)` → `91` (was `100`) - `toPercent(0.5)` → `50` (legacy format still works) - Extra usage `used_credits: 6793, monthly_limit: 14000` → `$67.93 / $140.00` (was `$6,793.00 / $14,000.00`) - Verified on a live instance with Claude Max subscription — Costs > Providers tab now shows correct percentages matching claude.ai/settings/usage ## Risks Low risk. The `< 1` heuristic is already battle-tested in the Codex adapter. The only edge case is a true utilization of exactly `1.0` which maps to `1%` instead of `100%` — this is consistent with the Codex adapter behavior and is an acceptable trade-off since 1% and 100% are distinguishable in practice (100% would be returned as `100.0` by the API). ## Model Used Claude Opus 4.6 (1M context) via Claude Code CLI — tool use, code analysis, and code generation ## 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 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 Closes #2188 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../adapters/claude-local/src/server/quota.ts | 8 +- server/src/__tests__/quota-windows.test.ts | 79 ++++++++++++++----- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/packages/adapters/claude-local/src/server/quota.ts b/packages/adapters/claude-local/src/server/quota.ts index 859a59c28e2..eac10e44eb0 100644 --- a/packages/adapters/claude-local/src/server/quota.ts +++ b/packages/adapters/claude-local/src/server/quota.ts @@ -187,13 +187,15 @@ function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null { ) { return null; } - return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`; + // API returns values in cents — convert to dollars for display + return `${formatCurrencyAmount(usedCredits / 100, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit / 100, extraUsage.currency)}`; } -/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */ +/** Convert a utilization value to a 0-100 integer percent. Returns null for null/undefined input. + * Handles both 0-1 fractions (legacy) and 0-100 percentages (current API). */ export function toPercent(utilization: number | null | undefined): number | null { if (utilization == null) return null; - return Math.min(100, Math.round(utilization * 100)); + return Math.min(100, Math.round(utilization < 1 ? utilization * 100 : utilization)); } /** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */ diff --git a/server/src/__tests__/quota-windows.test.ts b/server/src/__tests__/quota-windows.test.ts index c9d0fd92e27..e9eb26a9811 100644 --- a/server/src/__tests__/quota-windows.test.ts +++ b/server/src/__tests__/quota-windows.test.ts @@ -39,24 +39,34 @@ describe("toPercent", () => { expect(toPercent(0)).toBe(0); }); - it("converts 0.5 to 50", () => { + it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => { expect(toPercent(0.5)).toBe(50); }); - it("converts 1.0 to 100", () => { - expect(toPercent(1.0)).toBe(100); + it("treats values >= 1 as already-percentage (34 → 34%)", () => { + expect(toPercent(34.0)).toBe(34); + expect(toPercent(91.0)).toBe(91); + }); + + it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => { + // 1.0 is NOT < 1, so it is treated as already-percentage → 1% + expect(toPercent(1.0)).toBe(1); }); it("clamps overshoot to 100", () => { - // floating-point utilization can slightly exceed 1.0 - expect(toPercent(1.001)).toBe(100); - expect(toPercent(1.01)).toBe(100); + expect(toPercent(105)).toBe(100); + expect(toPercent(101)).toBe(100); }); - it("rounds to nearest integer", () => { + it("rounds to nearest integer for fractions", () => { expect(toPercent(0.333)).toBe(33); expect(toPercent(0.666)).toBe(67); }); + + it("rounds to nearest integer for percentages", () => { + expect(toPercent(48.52)).toBe(49); + expect(toPercent(23.4)).toBe(23); + }); }); // --------------------------------------------------------------------------- @@ -516,37 +526,48 @@ describe("fetchClaudeQuota", () => { expect(windows).toEqual([]); }); - it("parses five_hour window", async () => { - mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } }); + it("parses five_hour window with percentage-range utilization", async () => { + mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current session", - usedPercent: 40, + usedPercent: 34, resetsAt: "2026-01-01T00:00:00Z", }); }); - it("parses seven_day window", async () => { - mockFetch({ seven_day: { utilization: 0.75, resets_at: null } }); + it("parses seven_day window with percentage-range utilization", async () => { + mockFetch({ seven_day: { utilization: 91.0, resets_at: null } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current week (all models)", - usedPercent: 75, + usedPercent: 91, resetsAt: null, }); }); + it("still handles legacy 0-1 fraction utilization", async () => { + mockFetch({ five_hour: { utilization: 0.4, resets_at: null } }); + const windows = await fetchClaudeQuota("token"); + expect(windows[0]).toMatchObject({ + label: "Current session", + usedPercent: 40, + }); + }); + it("parses seven_day_sonnet and seven_day_opus windows", async () => { mockFetch({ - seven_day_sonnet: { utilization: 0.2, resets_at: null }, - seven_day_opus: { utilization: 0.9, resets_at: null }, + seven_day_sonnet: { utilization: 23.0, resets_at: null }, + seven_day_opus: { utilization: 85.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(2); expect(windows[0]!.label).toBe("Current week (Sonnet only)"); + expect(windows[0]!.usedPercent).toBe(23); expect(windows[1]!.label).toBe("Current week (Opus only)"); + expect(windows[1]!.usedPercent).toBe(85); }); it("sets usedPercent to null when utilization is absent", async () => { @@ -557,10 +578,10 @@ describe("fetchClaudeQuota", () => { it("includes all four windows when all are present", async () => { mockFetch({ - five_hour: { utilization: 0.1, resets_at: null }, - seven_day: { utilization: 0.2, resets_at: null }, - seven_day_sonnet: { utilization: 0.3, resets_at: null }, - seven_day_opus: { utilization: 0.4, resets_at: null }, + five_hour: { utilization: 10.0, resets_at: null }, + seven_day: { utilization: 20.0, resets_at: null }, + seven_day_sonnet: { utilization: 30.0, resets_at: null }, + seven_day_opus: { utilization: 40.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(4); @@ -571,6 +592,7 @@ describe("fetchClaudeQuota", () => { "Current week (Sonnet only)", "Current week (Opus only)", ]); + expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]); }); it("parses extra usage when the OAuth response includes it", async () => { @@ -591,6 +613,25 @@ describe("fetchClaudeQuota", () => { }, ]); }); + + it("formats extra usage credits from cents to dollars", async () => { + mockFetch({ + extra_usage: { + is_enabled: true, + monthly_limit: 14000, + used_credits: 6793, + utilization: 48.52, + }, + }); + const windows = await fetchClaudeQuota("token"); + expect(windows).toHaveLength(1); + expect(windows[0]).toMatchObject({ + label: "Extra usage", + usedPercent: 49, + valueLabel: "$67.93 / $140.00", + detail: "Monthly extra usage pool", + }); + }); }); // --------------------------------------------------------------------------- From 50cd76d8a3f2fc06d1a5af63840abd3d7a1bcd02 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 15 Apr 2026 08:10:52 -0400 Subject: [PATCH 05/16] feat(adapters): add capability flags to ServerAdapterModule (#3540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents via adapters (`claude_local`, `codex_local`, etc.) > - Each adapter type has different capabilities — instructions bundles, skill materialization, local JWT — but these were gated by 5 hardcoded type lists scattered across server routes and UI components > - External adapter plugins (e.g. a future `opencode_k8s`) cannot add themselves to those hardcoded lists without patching Paperclip source > - The existing `supportsLocalAgentJwt` field on `ServerAdapterModule` proves the right pattern already exists; it just wasn't applied to the other capability gates > - This pull request replaces the 4 remaining hardcoded lists with declarative capability flags on `ServerAdapterModule`, exposed through the adapter listing API > - The benefit is that external adapter plugins can now declare their own capabilities without any changes to Paperclip source code ## What Changed - **`packages/adapter-utils/src/types.ts`** — added optional capability fields to `ServerAdapterModule`: `supportsInstructionsBundle`, `instructionsPathKey`, `requiresMaterializedRuntimeSkills` - **`server/src/routes/agents.ts`** — replaced `DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES` and `ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS` hardcoded sets with capability-aware helper functions that fall back to the legacy sets for adapters that don't set flags - **`server/src/routes/adapters.ts`** — `GET /api/adapters` now includes a `capabilities` object per adapter (all four flags + derived `supportsSkills`) - **`server/src/adapters/registry.ts`** — all built-in adapters (`claude_local`, `codex_local`, `process`, `cursor`) now declare flags explicitly - **`ui/src/adapters/use-adapter-capabilities.ts`** — new hook that fetches adapter capabilities from the API - **`ui/src/pages/AgentDetail.tsx`** — replaced hardcoded `isLocal` allowlist with `capabilities.supportsInstructionsBundle` from the API - **`ui/src/components/AgentConfigForm.tsx`** / **`OnboardingWizard.tsx`** — replaced `NONLOCAL_TYPES` denylist with capability-based checks - **`server/src/__tests__/adapter-registry.test.ts`** / **`adapter-routes.test.ts`** — tests covering flag exposure, undefined-when-unset, and per-adapter values - **`docs/adapters/creating-an-adapter.md`** — new "Capability Flags" section documenting all flags and an example for external plugin authors ## Verification - Run `pnpm test --filter=@paperclip/server -- adapter-registry adapter-routes` — all new tests pass - Run `pnpm test --filter=@paperclip/adapter-utils` — existing tests still pass - Spin up dev server, open an agent with `claude_local` type — instructions bundle tab still visible - Create/open an agent with a non-local type — instructions bundle tab still hidden - Call `GET /api/adapters` and verify each adapter includes a `capabilities` object with the correct flags ## Risks - **Low risk overall** — all new flags are optional with backwards-compatible fallbacks to the existing hardcoded sets; no adapter behaviour changes unless a flag is explicitly set - Adapters that do not declare flags continue to use the legacy lists, so there is no regression risk for built-in adapters - The UI capability hook adds one API call to AgentDetail mount; this is a pre-existing endpoint, so no new latency path is introduced ## Model Used - Provider: Anthropic - Model: Claude Sonnet 4.6 (`claude-sonnet-4-6`) - Context: 200k token context window - Mode: Agentic tool use (code editing, bash, grep, file reads) ## 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 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: Pawla Abdul (Bot) Co-authored-by: Paperclip --- docs/adapters/creating-an-adapter.md | 37 ++++++++++ packages/adapter-utils/src/types.ts | 30 ++++++++ server/src/__tests__/adapter-registry.test.ts | 45 ++++++++++++ server/src/__tests__/adapter-routes.test.ts | 69 +++++++++++++++++++ server/src/adapters/registry.ts | 23 +++++++ server/src/routes/adapters.ts | 18 +++++ server/src/routes/agents.ts | 32 +++++++-- ui/src/adapters/use-adapter-capabilities.ts | 54 +++++++++++++++ ui/src/api/adapters.ts | 8 +++ ui/src/components/AgentConfigForm.tsx | 6 +- ui/src/components/OnboardingWizard.tsx | 6 +- ui/src/pages/AdapterManager.tsx | 6 ++ ui/src/pages/AgentDetail.tsx | 10 +-- 13 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 ui/src/adapters/use-adapter-capabilities.ts diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index ae5e4ccbe53..c81274f7559 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -203,6 +203,43 @@ export const sessionCodec: AdapterSessionCodec = { }; ``` +## Capability Flags + +Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.). + +| Flag | Type | Default | What it controls | +|------|------|---------|------------------| +| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent | +| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor | +| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path | +| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution | + +These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined). + +### Example + +```ts +export function createServerAdapter(): ServerAdapterModule { + return { + type: "my_k8s_adapter", + execute: myExecute, + testEnvironment: myTestEnvironment, + listSkills: myListSkills, + syncSkills: mySyncSkills, + + // Capability flags + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + }; +} +``` + +With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required. + +If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities. + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 534a5a59754..e02b9efe8de 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -328,6 +328,36 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; + + // --------------------------------------------------------------------------- + // Adapter capability flags + // + // These allow adapter plugins to declare what "local" capabilities they + // support, replacing hardcoded type lists in the server and UI. + // All flags are optional — when undefined, the server falls back to + // legacy hardcoded lists for built-in adapters. + // --------------------------------------------------------------------------- + + /** + * Adapter supports managed instructions bundle (AGENTS.md files). + * When true, the server uses instructionsPathKey (default "instructionsFilePath") + * to resolve the instructions config key, and the UI shows the bundle editor. + * Built-in local adapters default to true; external plugins must opt in. + */ + supportsInstructionsBundle?: boolean; + + /** + * The adapterConfig key that holds the instructions file path. + * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. + */ + instructionsPathKey?: string; + + /** + * Adapter needs runtime skill entries materialized (written to disk) + * before being passed via config. Used by adapters that scan a directory + * rather than reading config.paperclipRuntimeSkills. + */ + requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 6f7b0973991..4e473df5b17 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -95,6 +95,51 @@ describe("server adapter registry", () => { ]); }); + it("exposes capability flags from registered adapters", () => { + const adapterWithCaps: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass" as const, + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "customPathKey", + requiresMaterializedRuntimeSkills: true, + }; + + registerServerAdapter(adapterWithCaps); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBe(true); + expect(resolved!.instructionsPathKey).toBe("customPathKey"); + expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); + expect(resolved!.supportsLocalAgentJwt).toBe(true); + }); + + it("returns undefined for capability flags on adapters that do not set them", () => { + registerServerAdapter(externalAdapter); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBeUndefined(); + expect(resolved!.instructionsPathKey).toBeUndefined(); + expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); + }); + + it("built-in claude_local adapter declares capability flags", () => { + const adapter = findActiveServerAdapter("claude_local"); + expect(adapter).not.toBeNull(); + expect(adapter!.supportsInstructionsBundle).toBe(true); + expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); + expect(adapter!.supportsLocalAgentJwt).toBe(true); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 32bf991dd10..31168aecd3b 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -77,6 +77,75 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); + it("GET /api/adapters includes capabilities object for each adapter", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + // Every adapter should have a capabilities object + for (const adapter of res.body) { + expect(adapter.capabilities).toBeDefined(); + expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); + expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); + expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + } + }); + + it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // codex_local has instructions bundle + skills + jwt, no materialized skills + // (claude_local is overridden by beforeEach, so check codex_local instead) + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + + // process adapter should have no local capabilities + const processAdapter = res.body.find((a: any) => a.type === "process"); + expect(processAdapter).toBeDefined(); + expect(processAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); + + // cursor adapter should require materialized runtime skills + const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); + expect(cursorAdapter).toBeDefined(); + expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); + expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + }); + + it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // http adapter has no listSkills/syncSkills + const httpAdapter = res.body.find((a: any) => a.type === "http"); + expect(httpAdapter).toBeDefined(); + expect(httpAdapter.capabilities.supportsSkills).toBe(false); + + // codex_local has listSkills/syncSkills + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities.supportsSkills).toBe(true); + }); + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp(); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f674622721b..f6a1afca1e1 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -97,6 +97,9 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, listModels: listClaudeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -112,6 +115,9 @@ const codexLocalAdapter: ServerAdapterModule = { models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -127,6 +133,9 @@ const cursorLocalAdapter: ServerAdapterModule = { models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -140,6 +149,9 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -149,6 +161,8 @@ const openclawGatewayAdapter: ServerAdapterModule = { testEnvironment: openclawGatewayTestEnvironment, models: openclawGatewayModels, supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, }; @@ -163,6 +177,9 @@ const openCodeLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -177,6 +194,9 @@ const piLocalAdapter: ServerAdapterModule = { models: [], listModels: listPiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: piAgentConfigurationDoc, }; @@ -189,6 +209,9 @@ const hermesLocalAdapter: ServerAdapterModule = { syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: hermesAgentConfigurationDoc, detectModel: () => detectModelFromHermes(), }; diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 7a92f7835af..811060adabb 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -59,6 +59,13 @@ interface AdapterInstallRequest { version?: string; } +interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + interface AdapterInfo { type: string; label: string; @@ -66,6 +73,7 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -103,6 +111,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } +function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + return { + supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, + supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), + supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, + requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + }; +} + function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -112,6 +129,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), + capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 11b47490640..faf27f24e98 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -72,6 +72,8 @@ import { import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { + // Legacy hardcoded maps — used as fallback when adapter module does not + // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -83,6 +85,22 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); + + /** Check if an adapter supports the managed instructions bundle. */ + function adapterSupportsInstructionsBundle(adapterType: string): boolean { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; + return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); + } + + /** Resolve the adapter config key for the instructions file path. */ + function resolveInstructionsPathKey(adapterType: string): string | null { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; + if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath"; + if (adapter?.supportsInstructionsBundle === false) return null; + return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; + } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -557,7 +575,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } @@ -638,7 +656,9 @@ export function agentRoutes(db: Db) { }; } - const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ + // Legacy hardcoded set — used as fallback when adapter module does not + // declare requiresMaterializedRuntimeSkills explicitly. + const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", @@ -646,7 +666,11 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { + return adapter.requiresMaterializedRuntimeSkills; + } + return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( @@ -1617,7 +1641,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const defaultKey = resolveInstructionsPathKey(existing.adapterType); const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts new file mode 100644 index 00000000000..281d3adcc06 --- /dev/null +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi, type AdapterCapabilities } from "@/api/adapters"; +import { queryKeys } from "@/lib/queryKeys"; + +const ALL_FALSE: AdapterCapabilities = { + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, +}; + +/** + * Synchronous fallback for known built-in adapter types so capability checks + * return correct values on first render before the /api/adapters call resolves. + */ +const KNOWN_DEFAULTS: Record = { + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + hermes_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + openclaw_gateway: ALL_FALSE, +}; + +/** + * Returns a lookup function that resolves adapter capabilities by type. + * + * Capabilities are fetched from the server adapter listing API and cached + * via react-query. Before the data loads, known built-in adapter types + * return correct synchronous defaults to avoid cold-load regressions. + */ +export function useAdapterCapabilities(): (type: string) => AdapterCapabilities { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + const capMap = useMemo(() => { + const map = new Map(); + if (adapters) { + for (const a of adapters) { + map.set(a.type, a.capabilities); + } + } + return map; + }, [adapters]); + + return (type: string): AdapterCapabilities => + capMap.get(type) ?? KNOWN_DEFAULTS[type] ?? ALL_FALSE; +} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 86705bd4e75..18b154efedc 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,6 +4,13 @@ import { api } from "./client"; +export interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + export interface AdapterInfo { type: string; label: string; @@ -11,6 +18,7 @@ export interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 065405f9a2d..dcf9bf1148e 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -50,6 +50,7 @@ import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadat import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ @@ -269,8 +270,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocal = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 37509fee194..413c98e21d3 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,6 +25,7 @@ import { import { getUIAdapter } from "../adapters"; import { listUIAdapters } from "../adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; @@ -198,8 +199,9 @@ export function OnboardingWizard() { queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocalAdapter = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; // Build adapter grids dynamically from the UI registry + display metadata. // External/plugin adapters automatically appear with generic defaults. diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 0f303bcc2e2..acda68778f9 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -610,6 +610,12 @@ export function AdapterManager() { modelsCount: 0, loaded: true, disabled: virtual.menuDisabled, + capabilities: { + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }, }} canRemove={false} onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 354e4320744..2777cb07626 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -26,6 +26,7 @@ import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; @@ -1719,13 +1720,8 @@ function PromptsTab({ externalBundleRef.current = null; }, [agent.id]); - const isLocal = - agent.adapterType === "claude_local" || - agent.adapterType === "codex_local" || - agent.adapterType === "opencode_local" || - agent.adapterType === "pi_local" || - agent.adapterType === "hermes_local" || - agent.adapterType === "cursor"; + const getCapabilities = useAdapterCapabilities(); + const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle; const { data: bundle, isLoading: bundleLoading } = useQuery({ queryKey: queryKeys.agents.instructionsBundle(agent.id), From 32a9165ddf6308f3b46eae0653b6f583e502e538 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:41:15 -0500 Subject: [PATCH 06/16] [codex] harden authenticated routes and issue editor reliability (#3741) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The control plane depends on authenticated routes enforcing company boundaries and role permissions correctly > - This branch also touches the issue detail and markdown editing flows operators use while handling advisory and triage work > - Partial issue cache seeds and fragile rich-editor parsing could leave important issue content missing or blank at the moment an operator needed it > - Blocked issues becoming actionable again should wake their assignee automatically instead of silently staying idle > - This pull request rebases the advisory follow-up branch onto current `master`, hardens authenticated route authorization, and carries the issue-detail/editor reliability fixes forward with regression tests > - The benefit is tighter authz on sensitive routes plus more reliable issue/advisory editing and wakeup behavior on top of the latest base ## What Changed - Hardened authenticated route authorization across agent, activity, approval, access, project, plugin, health, execution-workspace, portability, and related server paths, with new cross-tenant and runtime-authz regression coverage. - Switched issue detail queries from `initialData` to placeholder-based hydration so list/quicklook seeds still refetch full issue bodies. - Normalized advisory-style HTML images before mounting the markdown editor and strengthened fallback behavior when the rich editor silently fails or rejects the content. - Woke assigned agents when blocked issues move back to `todo`, with route coverage for reopen and unblock transitions. - Rebasing note: this branch now sits cleanly on top of the latest `master` tip used for the PR base. ## Verification - `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx ui/src/components/MarkdownEditor.test.tsx server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts` - Confirmed `pnpm-lock.yaml` is not part of the PR diff. - Rebased the branch onto current `public-gh/master` before publishing. ## Risks - Broad authz tightening may expose existing flows that were relying on permissive board or agent access and now need explicit grants. - Markdown editor fallback changes could affect focus or rendering in edge-case content that mixes HTML-like advisory markup with normal markdown. - This verification was intentionally scoped to touched regressions and did not run the full repository suite. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment with tool use for terminal, git, and GitHub operations. The exact runtime model identifier is not exposed inside 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, it is behavior-only and does not need before/after screenshots - [x] I have updated relevant documentation to reflect my changes, or no documentation changes were needed for these internal fixes - [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 --- packages/shared/src/validators/approval.ts | 2 - server/src/__tests__/activity-routes.test.ts | 27 +- .../agent-cross-tenant-authz-routes.test.ts | 267 +++++++++++ .../agent-permissions-routes.test.ts | 190 ++++++++ .../app-private-hostname-gate.test.ts | 32 ++ .../approval-routes-idempotency.test.ts | 84 ++++ server/src/__tests__/cli-auth-routes.test.ts | 48 +- .../src/__tests__/company-portability.test.ts | 200 +++++++- server/src/__tests__/health.test.ts | 118 ++++- .../__tests__/invite-onboarding-text.test.ts | 1 + .../issue-comment-reopen-routes.test.ts | 30 +- .../issue-workspace-command-authz.test.ts | 165 +++++++ .../src/__tests__/plugin-routes-authz.test.ts | 168 +++++++ .../workspace-runtime-routes-authz.test.ts | 437 ++++++++++++++++++ .../workspace-runtime-service-authz.test.ts | 279 +++++++++++ server/src/app.ts | 16 +- server/src/routes/access.ts | 59 ++- server/src/routes/activity.ts | 3 +- server/src/routes/agents.ts | 192 ++++++-- server/src/routes/approvals.ts | 21 +- server/src/routes/authz.ts | 14 +- server/src/routes/execution-workspaces.ts | 18 + server/src/routes/health.ts | 34 +- server/src/routes/issues.ts | 6 + server/src/routes/plugins.ts | 15 +- server/src/routes/projects.ts | 30 ++ server/src/routes/workspace-command-authz.ts | 115 +++++ .../routes/workspace-runtime-service-authz.ts | 138 ++++++ server/src/services/agents.ts | 18 +- server/src/services/company-portability.ts | 160 ++++++- ui/src/components/InlineEditor.test.tsx | 56 +++ ui/src/components/InlineEditor.tsx | 11 +- ui/src/components/IssueLinkQuicklook.tsx | 8 +- ui/src/components/MarkdownBody.test.tsx | 7 + ui/src/components/MarkdownBody.tsx | 12 +- ui/src/components/MarkdownEditor.test.tsx | 21 +- ui/src/lib/issueDetailCache.ts | 14 + ui/src/lib/issueDetailQuery.test.tsx | 129 ++++++ ui/src/pages/IssueDetail.tsx | 22 +- 39 files changed, 3014 insertions(+), 153 deletions(-) create mode 100644 server/src/__tests__/agent-cross-tenant-authz-routes.test.ts create mode 100644 server/src/__tests__/app-private-hostname-gate.test.ts create mode 100644 server/src/__tests__/issue-workspace-command-authz.test.ts create mode 100644 server/src/__tests__/plugin-routes-authz.test.ts create mode 100644 server/src/__tests__/workspace-runtime-routes-authz.test.ts create mode 100644 server/src/__tests__/workspace-runtime-service-authz.test.ts create mode 100644 server/src/routes/workspace-command-authz.ts create mode 100644 server/src/routes/workspace-runtime-service-authz.ts create mode 100644 ui/src/lib/issueDetailQuery.test.tsx diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index d6a6cf0d575..ae6b2eb3b30 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -12,14 +12,12 @@ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type RequestApprovalRevision = z.infer; diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index e8b50dba1ed..974fcb2a093 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -28,7 +28,15 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); -async function createApp() { +async function createApp( + actor: Record = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, +) { const [{ errorHandler }, { activityRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/activity.js"), @@ -36,13 +44,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "user-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); app.use("/api", activityRoutes({} as any)); @@ -105,4 +107,13 @@ describe("activity routes", () => { expect(res.status).toBe(403); expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); }); + + it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => { + const app = await createApp({ type: "none", source: "none" }); + const res = await request(app).get("/api/heartbeat-runs/missing-run/issues"); + + expect(res.status).toBe(401); + expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts new file mode 100644 index 00000000000..34b7dec4f4d --- /dev/null +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -0,0 +1,267 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const keyId = "33333333-3333-4333-8333-333333333333"; + +const baseAgent = { + id: agentId, + companyId, + name: "Builder", + urlKey: "builder", + role: "engineer", + title: "Builder", + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), +}; + +const baseKey = { + id: keyId, + agentId, + companyId, + name: "exploit", + createdAt: new Date("2026-04-11T00:00:00.000Z"), + revokedAt: null, +}; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + terminate: vi.fn(), + remove: vi.fn(), + listKeys: vi.fn(), + createApiKey: vi.fn(), + getKeyById: vi.fn(), + revokeKey: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listPrincipalGrants: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + cancelActiveForAgent: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(), + resolveAdapterConfigForRuntime: vi.fn(), +})); + +const mockAgentInstructionsService = vi.hoisted(() => ({ + materializeManagedBundle: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), + }), +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("agent cross-tenant route authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockAgentService.getById.mockResolvedValue(baseAgent); + mockAgentService.pause.mockResolvedValue(baseAgent); + mockAgentService.resume.mockResolvedValue(baseAgent); + mockAgentService.terminate.mockResolvedValue(baseAgent); + mockAgentService.remove.mockResolvedValue(baseAgent); + mockAgentService.listKeys.mockResolvedValue([]); + mockAgentService.createApiKey.mockResolvedValue({ + id: keyId, + name: baseKey.name, + token: "pcp_test_token", + createdAt: baseKey.createdAt, + }); + mockAgentService.getKeyById.mockResolvedValue(baseKey); + mockAgentService.revokeKey.mockResolvedValue({ + ...baseKey, + revokedAt: new Date("2026-04-11T00:05:00.000Z"), + }); + mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects cross-tenant board pause before mutating the agent", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).post(`/api/agents/${agentId}/pause`).send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.pause).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key listing before reading any keys", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/agents/${agentId}/keys`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.listKeys).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key creation before minting a token", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "exploit" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.createApiKey).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key revocation before touching the key", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.getKeyById).not.toHaveBeenCalled(); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); + + it("requires the key to belong to the route agent before revocation", async () => { + mockAgentService.getKeyById.mockResolvedValue({ + ...baseKey, + agentId: "44444444-4444-4444-8444-444444444444", + }); + mockAccessService.canUser.mockResolvedValue(true); + + const app = createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Key not found"); + expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index af9f7658df9..bbb0c2a9da2 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -205,6 +205,196 @@ describe("agent permission routes", () => { mockLogActivity.mockResolvedValue(undefined); }); + it("redacts agent detail for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/agents/${agentId}`); + + expect(res.status).toBe(200); + expect(res.body.adapterConfig).toEqual({}); + expect(res.body.runtimeConfig).toEqual({}); + }); + + it("redacts company agent list for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/companies/${companyId}/agents`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ + id: agentId, + adapterConfig: {}, + runtimeConfig: {}, + }), + ]); + }); + + it("blocks agent updates for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ title: "Compromised" }); + + expect(res.status).toBe(403); + }); + + it("blocks api key creation for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "backdoor" }); + + expect(res.status).toBe(403); + }); + + it("blocks wakeups for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/wakeup`) + .send({}); + + expect(res.status).toBe(403); + }); + + it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated instructions-path updates", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}/instructions-path`) + .send({ path: "/etc/passwd" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated hires that set instructions bundle config", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agent-hires`) + .send({ + name: "Injected", + role: "engineer", + adapterType: "codex_local", + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("grants tasks:assign by default when board creates a new agent", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/app-private-hostname-gate.test.ts b/server/src/__tests__/app-private-hostname-gate.test.ts new file mode 100644 index 00000000000..cf60a1b542c --- /dev/null +++ b/server/src/__tests__/app-private-hostname-gate.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { shouldEnablePrivateHostnameGuard } from "../app.ts"; + +describe("shouldEnablePrivateHostnameGuard", () => { + it("enables the hostname guard for local_trusted private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for local_trusted public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "public", + })).toBe(false); + }); + + it("enables the hostname guard for authenticated private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for authenticated public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "public", + })).toBe(false); + }); +}); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 338bfe32797..d87561a9a0f 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -196,6 +196,90 @@ describe("approval routes idempotent retries", () => { expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); }); + it("derives approval attribution from the authenticated actor on approve", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + requestedByAgentId: null, + }); + mockApprovalService.approve.mockResolvedValue({ + approval: { + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: null, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-4/approve") + .send({ decidedByUserId: "forged-user", decisionNote: "ship it" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it"); + }); + + it("derives approval attribution from the authenticated actor on reject", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.reject.mockResolvedValue({ + approval: { + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-5/reject") + .send({ decidedByUserId: "forged-user", decisionNote: "not now" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now"); + }); + + it("derives approval attribution from the authenticated actor on request revision", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.requestRevision.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "revision_requested", + payload: {}, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-6/request-revision") + .send({ decidedByUserId: "forged-user", decisionNote: "Need changes" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.requestRevision).toHaveBeenCalledWith( + "approval-6", + "user-1", + "Need changes", + ); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 006321d2797..121d959d70a 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -45,7 +45,7 @@ function registerModuleMocks() { })); } -async function createApp(actor: any) { +async function createApp(actor: any, db: any = {} as any) { const [{ accessRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/access.js"), vi.importActual("../middleware/index.js"), @@ -58,7 +58,7 @@ async function createApp(actor: any) { }); app.use( "/api", - accessRoutes({} as any, { + accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", @@ -101,14 +101,56 @@ describe("cli auth routes", () => { expect(res.body).toMatchObject({ id: "challenge-1", token: "pcp_cli_auth_secret", - boardApiToken: "pcp_board_token", approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret", pollPath: "/cli-auth/challenges/challenge-1", expiresAt: "2026-03-23T13:00:00.000Z", }); + expect(res.body.boardApiToken).toBe("pcp_board_token"); expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret"); }); + it("rejects anonymous access to generic skill documents", async () => { + const app = await createApp({ type: "none", source: "none" }); + const [indexRes, skillRes] = await Promise.all([ + request(app).get("/api/skills/index"), + request(app).get("/api/skills/paperclip"), + ]); + + expect(indexRes.status).toBe(401); + expect(skillRes.status).toBe(401); + }); + + it("serves the invite-scoped paperclip skill anonymously for active invites", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date(Date.now() + 60_000), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([invite]), + })), + })), + }; + + const app = await createApp({ type: "none", source: "none" }, db); + const res = await request(app).get("/api/invites/token-123/skills/paperclip"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/markdown"); + expect(res.text).toContain("# Paperclip Skill"); + }); + it("marks challenge status as requiring sign-in for anonymous viewers", async () => { mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index b43f7b44829..f7a6e88be16 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -59,6 +59,11 @@ const assetSvc = { create: vi.fn(), }; +const secretSvc = { + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })), +}; + const agentInstructionsSvc = { exportFiles: vi.fn(), materializeManagedBundle: vi.fn(), @@ -96,6 +101,10 @@ vi.mock("../services/assets.js", () => ({ assetService: () => assetSvc, })); +vi.mock("../services/secrets.js", () => ({ + secretService: () => secretSvc, +})); + vi.mock("../services/agent-instructions.js", () => ({ agentInstructionsService: () => agentInstructionsSvc, })); @@ -117,6 +126,11 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); + secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ + config, + secretKeys: new Set(), + })); companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", @@ -127,6 +141,11 @@ describe("company portability", () => { logoUrl: null, requireBoardApprovalForNewAgents: true, }); + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + requireBoardApprovalForNewAgents: true, + }); agentSvc.list.mockResolvedValue([ { id: "agent-1", @@ -1509,7 +1528,7 @@ describe("company portability", () => { }), ]); - await portability.importBundle({ + const result = await portability.importBundle({ source: { type: "inline", rootPath: "paperclip-demo", files }, include: { company: true, agents: true, projects: true, issues: true, skills: false }, target: { mode: "new_company", newCompanyName: "Imported Paperclip" }, @@ -1520,12 +1539,15 @@ describe("company portability", () => { expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ projectId: "project-created", title: "Monday Review", - assigneeAgentId: "agent-created", + assigneeAgentId: null, priority: "high", status: "paused", concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); + expect(result.warnings).toContain( + "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", + ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({ kind: "schedule", @@ -2418,4 +2440,178 @@ describe("company portability", () => { expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/); expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); }); + + it("rejects dangerous adapter types on agent-safe imports", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await expect(portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + adapterOverrides: { + claudecoder: { + adapterType: "process", + adapterConfig: { + command: "/bin/sh", + args: ["-c", "id"], + }, + }, + }, + }, "user-1", { + mode: "agent_safe", + sourceCompanyId: "company-1", + })).rejects.toThrow('Adapter type "process" is not allowed in safe imports'); + + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + + it("imports new agents through approval and adapter-config normalization", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: true, + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "secret-1", + version: "latest", + }, + }, + }); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "agent-created", + name: String(input.name), + adapterType: input.adapterType, + adapterConfig: input.adapterConfig, + status: input.status, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-imported", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterType: "claude_local", + adapterConfig: expect.objectContaining({ + normalized: true, + }), + status: "pending_approval", + })); + }); + + it("normalizes adapter config on replace imports before updating existing agents", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: "updated", + }); + agentSvc.update.mockImplementation(async (id: string, patch: Record) => ({ + id, + name: "ClaudeCoder", + adapterType: patch.adapterType, + adapterConfig: patch.adapterConfig, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "replace", + adapterOverrides: { + claudecoder: { + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + }, + }, + }, + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-1", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ + adapterType: "codex_local", + adapterConfig: { + normalized: "updated", + }, + })); + }); }); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index ce3c002a0ea..e941776ee84 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -34,7 +34,7 @@ describe("GET /health", () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); - }); + }, 15_000); it("returns 200 when the database probe succeeds", async () => { const db = { @@ -63,4 +63,120 @@ describe("GET /health", () => { error: "database_unreachable", }); }); + + it("redacts detailed metadata for anonymous requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "board", userId: "user-1", source: "session" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + version: serverVersion, + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + bootstrapStatus: "ready", + bootstrapInviteActive: false, + features: { + companyDeletionEnabled: false, + }, + }); + }); }); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 8ba30115bca..fae84a63718 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); + expect(text).toContain("/api/invites/token-123/skills/paperclip"); expect(text).toContain("Suggested Paperclip base URLs to try"); expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 1c887138f55..2ef33886348 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -148,7 +148,7 @@ async function normalizePolicy(input: { return normalizeIssueExecutionPolicy(input); } -function makeIssue(status: "todo" | "done") { +function makeIssue(status: "todo" | "done" | "blocked") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", @@ -430,6 +430,34 @@ describe("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); }); + + it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { + const issue = makeIssue("blocked"); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "todo" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + source: "automation", + triggerDetail: "system", + reason: "issue_status_changed", + payload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + mutation: "update", + }), + }), + ); + }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts new file mode 100644 index 00000000000..cf9a7847b01 --- /dev/null +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -0,0 +1,165 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const mockIssueService = vi.hoisted(() => ({ + addComment: vi.fn(), + assertCheckoutOwner: vi.fn(), + create: vi.fn(), + findMentionedAgents: vi.fn(), + getByIdentifier: vi.fn(), + getById: vi.fn(), + getRelationSummaries: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + update: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({ + getById: vi.fn(async () => null), + }), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + 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), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "issue-1", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: "board-user", + identifier: "PAP-1000", + title: "Workspace authz", + executionPolicy: null, + executionState: null, + executionWorkspaceId: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue workspace command authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.addComment.mockResolvedValue(null); + mockIssueService.create.mockResolvedValue(makeIssue()); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getByIdentifier.mockResolvedValue(null); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.update.mockResolvedValue(makeIssue()); + }); + + it("rejects agent callers that create issue workspace provision commands", async () => { + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "Exploit", + executionWorkspaceSettings: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue()); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/issues/issue-1") + .send({ + assigneeAdapterOverrides: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "rm -rf /tmp/paperclip-rce", + }, + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts new file mode 100644 index 00000000000..3150a182257 --- /dev/null +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -0,0 +1,168 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRegistry = vi.hoisted(() => ({ + getById: vi.fn(), + getByKey: vi.fn(), +})); + +const mockLifecycle = vi.hoisted(() => ({ + load: vi.fn(), + upgrade: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, +})); + +vi.mock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), +})); + +vi.mock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), +})); + +async function createApp(actor: Record, loaderOverrides: Record = {}) { + const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/plugins.js"), + import("../middleware/index.js"), + ]); + + const loader = { + installPlugin: vi.fn(), + ...loaderOverrides, + }; + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as typeof req.actor; + next(); + }); + app.use("/api", pluginRoutes({} as never, loader as never)); + app.use(errorHandler); + + return { app, loader }; +} + +describe("plugin install and upgrade authz", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("rejects plugin installation for non-admin board users", async () => { + const { app, loader } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "paperclip-plugin-example" }); + + expect(res.status).toBe(403); + expect(loader.installPlugin).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to install plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const pluginKey = "paperclip.example"; + const discovered = { + manifest: { + id: pluginKey, + }, + }; + + mockRegistry.getByKey.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "paperclip-plugin-example", + version: "1.0.0", + }); + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "paperclip-plugin-example", + version: "1.0.0", + }); + mockLifecycle.load.mockResolvedValue(undefined); + + const { app, loader } = await createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }, + { installPlugin: vi.fn().mockResolvedValue(discovered) }, + ); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "paperclip-plugin-example" }); + + expect(res.status).toBe(200); + expect(loader.installPlugin).toHaveBeenCalledWith({ + packageName: "paperclip-plugin-example", + version: undefined, + }); + expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId); + }, 20_000); + + it("rejects plugin upgrades for non-admin board users", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({}); + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to upgrade plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + }); + mockLifecycle.upgrade.mockResolvedValue({ + id: pluginId, + version: "1.1.0", + }); + + const { app } = await createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({ version: "1.1.0" }); + + expect(res.status).toBe(200); + expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0"); + }, 20_000); +}); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts new file mode 100644 index 00000000000..de5b490c912 --- /dev/null +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -0,0 +1,437 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockProjectService = vi.hoisted(() => ({ + create: vi.fn(), + createWorkspace: vi.fn(), + getById: vi.fn(), + listWorkspaces: vi.fn(), + resolveByReference: vi.fn(), + update: vi.fn(), + updateWorkspace: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeEnvBindingsForPersistence: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); +const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); + +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + cleanupExecutionWorkspaceArtifacts: vi.fn(), + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForExecutionWorkspace: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); + + vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({ + assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, + assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, + })); +} + +async function createProjectApp(actor: Record) { + const { projectRoutes } = await import("../routes/projects.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", projectRoutes({} as any)); + app.use(errorHandler); + return app; +} + +async function createExecutionWorkspaceApp(actor: Record) { + const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +function buildProject(overrides: Record = {}) { + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "backlog", + leadAgentId: null, + targetDate: null, + color: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function buildExecutionWorkspace(overrides: Record = {}) { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + sourceIssueId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Workspace", + status: "active", + cwd: "/tmp/workspace", + repoUrl: null, + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("workspace runtime service route authorization", () => { + const projectId = "11111111-1111-4111-8111-111111111111"; + const workspaceId = "22222222-2222-4222-8222-222222222222"; + const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; + + beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); + vi.clearAllMocks(); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.create.mockResolvedValue(buildProject()); + mockProjectService.update.mockResolvedValue(buildProject()); + mockProjectService.createWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockProjectService.listWorkspaces.mockResolvedValue([{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }]); + mockProjectService.updateWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace()); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined); + }); + + it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockProjectService.getById.mockResolvedValue(buildProject({ + id: projectId, + workspaces: [{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }], + })); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that create project execution workspace commands", async () => { + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Exploit", + executionWorkspacePolicy: { + enabled: true, + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that update project workspace cleanup commands", async () => { + mockProjectService.getById.mockResolvedValue(buildProject()); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/projects/${projectId}/workspaces/${workspaceId}`) + .send({ + cleanupCommand: "rm -rf /tmp/paperclip-rce", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("allows board callers through the project workspace runtime auth gate", async () => { + mockProjectService.getById.mockResolvedValue(null); + const app = await createProjectApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Project not found"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + }); + + it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that patch execution workspace command config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + config: { + cleanupCommand: "rm -rf /tmp/paperclip-rce", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + metadata: { + config: { + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("allows board callers through the execution workspace runtime auth gate", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(null); + const app = await createExecutionWorkspaceApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Execution workspace not found"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + }); +}); diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts new file mode 100644 index 00000000000..87460487df6 --- /dev/null +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + assertCanManageExecutionWorkspaceRuntimeServices, + assertCanManageProjectWorkspaceRuntimeServices, +} from "../routes/workspace-runtime-service-authz.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("workspace runtime service authz helper", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-authz-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `PAP-${companyId.slice(0, 8)}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function seedProjectWorkspace(companyId: string) { + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace authz", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/paperclip-authz-project", + isPrimary: true, + }); + return { projectId, projectWorkspaceId }; + } + + async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) { + const executionWorkspaceId = randomUUID(); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-authz-execution", + }); + return executionWorkspaceId; + } + + async function seedAgent( + companyId: string, + input: { role?: string; reportsTo?: string | null; name?: string } = {}, + ) { + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: input.name ?? "Agent", + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + }); + return agentId; + } + + it("allows board actors to manage project workspace runtime services", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "board", + userId: "board-1", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows CEO agents to manage any project workspace runtime services in their company", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: ceoAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows agents with a non-terminal assigned issue in the target project workspace", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Use this workspace", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" }); + const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Use execution workspace", + status: "in_progress", + priority: "medium", + assigneeAgentId: reportId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: managerId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("rejects unrelated same-company agents without matching workspace assignments", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const assignedAgentId = await seedAgent(companyId, { name: "Assigned" }); + const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: assignedAgentId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: unrelatedAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); + + it("rejects completed workspace assignments so stale issues do not keep access alive", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Completed issue", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 7daf24c1ec6..317b87993d0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -86,6 +86,16 @@ function shouldServeViteDevHtml(req: ExpressRequest): boolean { return req.accepts(["html"]) === "html"; } +export function shouldEnablePrivateHostnameGuard(opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; +}): boolean { + return ( + opts.deploymentExposure === "private" && + (opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated") + ); +} + export async function createApp( db: Db, opts: { @@ -123,8 +133,10 @@ export async function createApp( }, })); app.use(httpLogger); - const privateHostnameGateEnabled = - opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; + const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + }); const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index e4893765fbc..0be3adab1fd 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -48,7 +48,7 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; -import { assertCompanyAccess } from "./authz.js"; +import { assertAuthenticated, assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, inspectBoardClaimChallenge @@ -863,6 +863,7 @@ function toInviteSummaryResponse( const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const skillIndexPath = `/api/invites/${token}/skills/index`; const inviteMessage = extractInviteMessage(invite); return { id: invite.id, @@ -877,10 +878,10 @@ function toInviteSummaryResponse( onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, - skillIndexPath: "/api/skills/index", + skillIndexPath, skillIndexUrl: baseUrl - ? `${baseUrl}/api/skills/index` - : "/api/skills/index", + ? `${baseUrl}${skillIndexPath}` + : skillIndexPath, inviteMessage }; } @@ -1004,7 +1005,7 @@ function buildInviteOnboardingManifest( } ) { const baseUrl = requestBaseUrl(req); - const skillPath = "/api/skills/paperclip"; + const skillPath = `/api/invites/${token}/skills/paperclip`; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; const registrationEndpointPath = `/api/invites/${token}/accept`; const registrationEndpointUrl = baseUrl @@ -1906,11 +1907,13 @@ export function accessRoutes( return company?.name ?? null; } - router.get("/skills/available", (_req, res) => { + router.get("/skills/available", (req, res) => { + assertAuthenticated(req); res.json({ skills: listAvailableSkills() }); }); - router.get("/skills/index", (_req, res) => { + router.get("/skills/index", (req, res) => { + assertAuthenticated(req); res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, @@ -1927,6 +1930,7 @@ export function accessRoutes( }); router.get("/skills/:skillName", (req, res) => { + assertAuthenticated(req); const skillName = (req.params.skillName as string).trim().toLowerCase(); const markdown = readSkillMarkdown(skillName); if (!markdown) throw notFound("Skill not found"); @@ -2100,6 +2104,47 @@ export function accessRoutes( ); }); + router.get("/invites/:token/skills/index", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + res.json({ + skills: [ + { + name: "paperclip", + path: `/api/invites/${token}/skills/paperclip`, + }, + ], + }); + }); + + router.get("/invites/:token/skills/:skillName", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + const skillName = (req.params.skillName as string).trim().toLowerCase(); + if (skillName !== "paperclip") throw notFound("Skill not found"); + const markdown = readSkillMarkdown(skillName); + if (!markdown) throw notFound("Skill not found"); + res.type("text/markdown").send(markdown); + }); + router.get("/invites/:token/test-resolution", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 20f2ed534ed..53083b1b608 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; -import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; @@ -81,6 +81,7 @@ export function activityRoutes(db: Db) { }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { + assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index faf27f24e98..9511d6d0daa 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,4 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; @@ -46,6 +46,10 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectAgentAdapterWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { detectAdapterModel, findActiveServerAdapter, @@ -231,10 +235,33 @@ export function agentRoutes(db: Db) { return actorAgent; } + async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + } + async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } + async function getAccessibleAgent(req: Request, res: Response, id: string) { + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return null; + } + assertCompanyAccess(req, agent.companyId); + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); + } + return agent; + } + async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -317,7 +344,10 @@ export function agentRoutes(db: Db) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -339,7 +369,10 @@ export function agentRoutes(db: Db) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertCanReadConfigurations(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -610,19 +643,24 @@ export function agentRoutes(db: Db) { async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { - throw forbidden("Agent key cannot access another company"); + if (req.actor.type !== "board") { + throw forbidden( + "Only board-authenticated callers can manage instructions path or bundle configuration", + ); } - if (actorAgent.id === targetAgent.id) return; + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + } - const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); - if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; - - throw forbidden("Only the target agent or an ancestor manager can update instructions path"); + function assertNoAgentInstructionsConfigMutation( + req: Request, + adapterConfig: Record | null | undefined, + ) { + if (req.actor.type !== "agent" || !adapterConfig) return; + const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); + if (changedSensitiveKeys.length === 0) return; + throw forbidden( + `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, + ); } function summarizeAgentUpdateDetails(patch: Record) { @@ -997,7 +1035,7 @@ export function agentRoutes(db: Db) { } const result = await svc.list(companyId); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); - if (canReadConfigs || req.actor.type === "board") { + if (canReadConfigs) { res.json(result); return; } @@ -1173,12 +1211,13 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); - if (!canRead) { - res.json(await buildAgentDetail(agent, { restricted: true })); - return; - } + const isSelf = req.actor.type === "agent" && req.actor.agentId === id; + const canReadSensitiveDetail = isSelf + ? true + : await actorCanReadConfigurationsForCompany(req, agent.companyId); + if (!canReadSensitiveDetail) { + res.json(await buildAgentDetail(agent, { restricted: true })); + return; } res.json(await buildAgentDetail(agent)); }); @@ -1266,6 +1305,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const state = await heartbeat.getRuntimeState(id); @@ -1280,6 +1320,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const sessions = await heartbeat.listTaskSessions(id); @@ -1299,6 +1340,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const taskKey = @@ -1331,6 +1373,14 @@ export function agentRoutes(db: Db) { ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), + ); + assertNoAgentInstructionsConfigMutation( + req, + (hireInput.adapterConfig ?? {}) as Record, + ); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), @@ -1590,6 +1640,8 @@ export function agentRoutes(db: Db) { res.status(403).json({ error: "Only CEO can manage permissions" }); return; } + } else { + await assertBoardCanManageAgentsForCompany(req, existing.companyId); } const agent = await svc.updatePermissions(id, req.body); @@ -1890,10 +1942,15 @@ export function agentRoutes(db: Db) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } - const changingInstructionsPath = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), + assertNoAgentInstructionsConfigMutation(req, adapterConfig); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(adapterConfig), ); - if (changingInstructionsPath) { + const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => + KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), + ); + if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; @@ -1994,6 +2051,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.pause(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2017,6 +2077,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/resume", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.resume(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2038,6 +2101,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.terminate(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2061,6 +2127,9 @@ export function agentRoutes(db: Db) { router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.remove(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2082,6 +2151,10 @@ export function agentRoutes(db: Db) { router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const keys = await svc.listKeys(id); res.json(keys); }); @@ -2089,32 +2162,56 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const key = await svc.createApiKey(id, req.body.name); - const agent = await svc.getById(id); - if (agent) { - await logActivity(db, { - companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "agent.key_created", - entityType: "agent", - entityId: agent.id, - details: { keyId: key.id, name: key.name }, - }); - } + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_created", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); + const id = req.params.id as string; const keyId = req.params.keyId as string; - const revoked = await svc.revokeKey(keyId); + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } + + const key = await svc.getKeyById(keyId); + if (!key || key.agentId !== agent.id) { + res.status(404).json({ error: "Key not found" }); + return; + } + + const revoked = await svc.revokeKey(agent.id, keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_revoked", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); + res.json({ ok: true }); }); @@ -2127,9 +2224,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.wakeup(id, { @@ -2177,9 +2278,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.invoke( @@ -2225,6 +2330,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); if (agent.adapterType !== "claude_local") { res.status(400).json({ error: "Login is only supported for claude_local agents" }); diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 3ed20374700..d1743cc050c 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.approve( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote); if (applied) { const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); @@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.reject( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote); if (applied) { await logActivity(db, { @@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const approval = await svc.requestRevision( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote); await logActivity(db, { companyId: approval.companyId, diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index a881d4ff052..e9017c93a3b 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,6 +1,12 @@ import type { Request } from "express"; import { forbidden, unauthorized } from "../errors.js"; +export function assertAuthenticated(req: Request) { + if (req.actor.type === "none") { + throw unauthorized(); + } +} + export function assertBoard(req: Request) { if (req.actor.type !== "board") { throw forbidden("Board access required"); @@ -16,9 +22,7 @@ export function assertInstanceAdmin(req: Request) { } export function assertCompanyAccess(req: Request, companyId: string) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } @@ -31,9 +35,7 @@ export function assertCompanyAccess(req: Request, companyId: string) { } export function getActorInfo(req: Request) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent") { return { actorType: "agent" as const, diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 963be2ed7be..faabd53c50b 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -23,6 +23,11 @@ import { stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectExecutionWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -96,6 +101,12 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); + await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { + companyId: existing.companyId, + executionWorkspaceId: existing.id, + sourceIssueId: existing.sourceIssueId, + }); + const workspaceCwd = existing.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); @@ -428,6 +439,13 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectExecutionWorkspaceCommandPaths({ + config: req.body.config, + metadata: req.body.metadata, + }), + ); const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 795eb9a5cb4..d5216e72d89 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -7,6 +7,14 @@ import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-se import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; +function shouldExposeFullHealthDetails( + actorType: "none" | "board" | "agent" | null | undefined, + deploymentMode: DeploymentMode, +) { + if (deploymentMode !== "authenticated") return true; + return actorType === "board" || actorType === "agent"; +} + export function healthRoutes( db?: Db, opts: { @@ -23,9 +31,19 @@ export function healthRoutes( ) { const router = Router(); - router.get("/", async (_req, res) => { + router.get("/", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + const exposeFullDetails = shouldExposeFullHealthDetails( + actorType, + opts.deploymentMode, + ); + if (!db) { - res.json({ status: "ok", version: serverVersion }); + res.json( + exposeFullDetails + ? { status: "ok", version: serverVersion } + : { status: "ok", deploymentMode: opts.deploymentMode }, + ); return; } @@ -70,7 +88,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType | undefined; - if (persistedDevServerStatus) { + if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db @@ -85,6 +103,16 @@ export function healthRoutes( }); } + if (!exposeFullDetails) { + res.json({ + status: "ok", + deploymentMode: opts.deploymentMode, + bootstrapStatus, + bootstrapInviteActive, + }); + return; + } + res.json({ status: "ok", version: serverVersion, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4f49c31d722..c5d2d40d122 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -48,6 +48,10 @@ import { import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectIssueWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, @@ -1323,6 +1327,7 @@ export function issueRoutes( router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } @@ -1373,6 +1378,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 95215d899af..a6368be9c65 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -11,7 +11,8 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * - * All routes require board-level authentication (assertBoard middleware). + * All routes require board-level authentication, and sensitive instance-wide + * mutations such as install/upgrade require instance-admin privileges. * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification @@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ @@ -583,6 +584,9 @@ export function pluginRoutes( * * Install a plugin from npm or a local filesystem path. * + * Instance-wide plugin installation is restricted to instance admins because + * the install flow fetches and inspects package contents on the host. + * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional) @@ -601,7 +605,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation @@ -1450,6 +1454,9 @@ export function pluginRoutes( * * Upgrade a plugin to a newer version. * + * Upgrades are restricted to instance admins because they fetch and inspect + * new package contents on the host before activation. + * * Request body (optional): * - version: Target version (defaults to latest) * @@ -1461,7 +1468,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 71cd792b0a6..f4d3e84863b 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -22,6 +22,12 @@ import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace, } from "../services/workspace-runtime.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectProjectExecutionWorkspaceCommandPaths, + collectProjectWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { @@ -93,6 +99,13 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; + assertNoAgentHostWorkspaceCommandMutation( + req, + [ + ...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy), + ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), + ], + ); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, @@ -144,6 +157,10 @@ export function projectRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const body = { ...req.body }; + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), + ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } @@ -200,6 +217,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -238,6 +259,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); @@ -290,6 +315,11 @@ export function projectRoutes(db: Db) { return; } + await assertCanManageProjectWorkspaceRuntimeServices(db, req, { + companyId: project.companyId, + projectWorkspaceId: workspace.id, + }); + const workspaceCwd = workspace.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" }); diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts new file mode 100644 index 00000000000..e7999d11d5a --- /dev/null +++ b/server/src/routes/workspace-command-authz.ts @@ -0,0 +1,115 @@ +import type { Request } from "express"; +import { forbidden } from "../errors.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function prefixPath(prefix: string, key: string) { + return prefix.length > 0 ? `${prefix}.${key}` : key; +} + +function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + return paths; +} + +function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + if (hasOwn(raw, "cleanupCommand")) { + paths.push(prefixPath(prefix, "cleanupCommand")); + } + return paths; +} + +export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) { + if (req.actor.type !== "agent" || paths.length === 0) return; + throw forbidden( + `Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`, + ); +} + +export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { + if (!isRecord(adapterConfig)) return []; + return collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "adapterConfig.workspaceStrategy", + ); +} + +export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] { + if (!isRecord(policy)) return []; + return collectWorkspaceStrategyCommandPaths( + policy.workspaceStrategy, + "executionWorkspacePolicy.workspaceStrategy", + ); +} + +export function collectProjectWorkspaceCommandPaths( + workspacePatch: unknown, + prefix = "", +): string[] { + if (!isRecord(workspacePatch)) return []; + return hasOwn(workspacePatch, "cleanupCommand") + ? [prefixPath(prefix, "cleanupCommand")] + : []; +} + +export function collectIssueWorkspaceCommandPaths(input: { + executionWorkspaceSettings?: unknown; + assigneeAdapterOverrides?: unknown; +}): string[] { + const paths: string[] = []; + if (isRecord(input.executionWorkspaceSettings)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + input.executionWorkspaceSettings.workspaceStrategy, + "executionWorkspaceSettings.workspaceStrategy", + ), + ); + } + if (isRecord(input.assigneeAdapterOverrides)) { + const adapterConfig = input.assigneeAdapterOverrides.adapterConfig; + if (isRecord(adapterConfig)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "assigneeAdapterOverrides.adapterConfig.workspaceStrategy", + ), + ); + } + } + return paths; +} + +export function collectExecutionWorkspaceCommandPaths(input: { + config?: unknown; + metadata?: unknown; +}): string[] { + const paths: string[] = []; + if (input.config !== undefined) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config")); + } + if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config")); + } + return paths; +} diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts new file mode 100644 index 00000000000..16cff277be5 --- /dev/null +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -0,0 +1,138 @@ +import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, issues } from "@paperclipai/db"; +import type { Request } from "express"; +import { forbidden } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", +]; + +async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) { + const companyAgents = await db + .select({ + id: agents.id, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + + const reportsByManager = new Map(); + for (const agent of companyAgents) { + if (!agent.reportsTo) continue; + const reports = reportsByManager.get(agent.reportsTo) ?? []; + reports.push(agent.id); + reportsByManager.set(agent.reportsTo, reports); + } + + const visited = new Set([actorAgentId]); + const queue = [actorAgentId]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const reports = reportsByManager.get(current) ?? []; + for (const reportId of reports) { + if (visited.has(reportId)) continue; + visited.add(reportId); + queue.push(reportId); + } + } + + return [...visited]; +} + +async function assertAgentCanManageRuntimeServicesForWorkspace( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId?: string | null; + executionWorkspaceId?: string | null; + sourceIssueId?: string | null; + }, +) { + if (req.actor.type !== "agent" || !req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + }) + .from(agents) + .where(eq(agents.id, req.actor.agentId)) + .then((rows) => rows[0] ?? null); + + if (!actorAgent || actorAgent.companyId !== input.companyId) { + throw forbidden("Agent key cannot access another company"); + } + + if (actorAgent.role === "ceo") { + return; + } + + const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); + const workspaceScopeConditions = [ + input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null, + input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null, + input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null, + ].filter((condition): condition is NonNullable => condition !== null); + + if (workspaceScopeConditions.length === 0) { + throw forbidden("Missing permission to manage workspace runtime services"); + } + + const linkedIssue = await db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), + inArray(issues.assigneeAgentId, eligibleAgentIds), + workspaceScopeConditions.length === 1 + ? workspaceScopeConditions[0]! + : or(...workspaceScopeConditions), + )) + .then((rows) => rows[0] ?? null); + + if (linkedIssue) { + return; + } + + throw forbidden("Missing permission to manage workspace runtime services"); +} + +export async function assertCanManageProjectWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId: string; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} + +export async function assertCanManageExecutionWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + executionWorkspaceId: string; + sourceIssueId?: string | null; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index abba741491c..6cacb441f50 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -619,11 +619,25 @@ export function agentService(db: Db) { .from(agentApiKeys) .where(eq(agentApiKeys.agentId, id)), - revokeKey: async (keyId: string) => { + getKeyById: async (keyId: string) => + db + .select({ + id: agentApiKeys.id, + agentId: agentApiKeys.agentId, + companyId: agentApiKeys.companyId, + name: agentApiKeys.name, + createdAt: agentApiKeys.createdAt, + revokedAt: agentApiKeys.revokedAt, + }) + .from(agentApiKeys) + .where(eq(agentApiKeys.id, keyId)) + .then((rows) => rows[0] ?? null), + + revokeKey: async (agentId: string, keyId: string) => { const rows = await db .update(agentApiKeys) .set({ revokedAt: new Date() }) - .where(eq(agentApiKeys.id, keyId)) + .where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId))) .returning(); return rows[0] ?? null; }, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index ff29a180b2d..305f6a761b5 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -47,7 +47,9 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { notFound, unprocessable } from "../errors.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; +import { findServerAdapter } from "../adapters/index.js"; +import { forbidden, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; @@ -62,6 +64,7 @@ import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { routineService } from "./routines.js"; +import { secretService } from "./secrets.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { @@ -117,6 +120,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; +const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]); const execFileAsync = promisify(execFile); let bundledSkillsCommitPromise: Promise | null = null; @@ -2747,6 +2751,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projects = projectService(db); const issues = issueService(db); const companySkills = companySkillService(db); + const secrets = secretService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + + function assertKnownImportAdapterType(type: string | null | undefined): string { + const adapterType = typeof type === "string" ? type.trim() : ""; + if (!adapterType) { + throw unprocessable("Adapter type is required"); + } + if (!findServerAdapter(adapterType)) { + throw unprocessable(`Unknown adapter type: ${adapterType}`); + } + return adapterType; + } + + async function assertImportAdapterConfigConstraints( + companyId: string, + adapterType: string, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } + } + + async function prepareImportedAgentAdapter( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + desiredSkills: string[], + mode: ImportMode, + ) { + const effectiveAdapterType = assertKnownImportAdapterType(adapterType); + if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) { + throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`); + } + const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills); + delete nextAdapterConfig.promptTemplate; + delete nextAdapterConfig.bootstrapPromptTemplate; + delete nextAdapterConfig.instructionsFilePath; + delete nextAdapterConfig.instructionsBundleMode; + delete nextAdapterConfig.instructionsRootPath; + delete nextAdapterConfig.instructionsEntryFile; + const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence( + companyId, + nextAdapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig); + return { + adapterType: effectiveAdapterType, + adapterConfig: normalizedAdapterConfig, + }; + } + + function resolveImportedAssigneeAgentId( + assigneeSlug: string | null | undefined, + importedSlugToAgentId: Map, + existingSlugToAgentId: Map, + agentStatusById: Map, + warnings: string[], + subjectLabel: string, + ) { + if (!assigneeSlug) return null; + const assigneeAgentId = + importedSlugToAgentId.get(assigneeSlug) + ?? existingSlugToAgentId.get(assigneeSlug) + ?? null; + if (!assigneeAgentId) return null; + const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null; + if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") { + warnings.push( + `${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`, + ); + return null; + } + return assigneeAgentId; + } async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { @@ -3856,7 +3948,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const warnings = [...plan.preview.warnings]; const include = plan.include; - let targetCompany: { id: string; name: string } | null = null; + let targetCompany: { + id: string; + name: string; + requireBoardApprovalForNewAgents?: boolean | null; + } | null = null; let companyAction: "created" | "updated" | "unchanged" = "unchanged"; if (input.target.mode === "new_company") { @@ -3977,9 +4073,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); + const agentStatusById = new Map(); const existingAgents = await agents.list(targetCompany.id); for (const existing of existingAgents) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); + agentStatusById.set(existing.id, existing.status); } const importedSlugToProjectId = new Map(); const importedProjectWorkspaceIdByProjectSlug = new Map>(); @@ -4049,22 +4147,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // Apply adapter overrides from request if present const adapterOverride = input.adapterOverrides?.[planAgent.slug]; - const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; const baseAdapterConfig = adapterOverride?.adapterConfig ? { ...adapterOverride.adapterConfig } : { ...manifestAgent.adapterConfig } as Record; const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); - const adapterConfigWithSkills = writePaperclipSkillSyncPreference( + const normalizedAdapter = await prepareImportedAgentAdapter( + targetCompany.id, + adapterOverride?.adapterType ?? manifestAgent.adapterType, baseAdapterConfig, desiredSkills, + mode, ); - delete adapterConfigWithSkills.promptTemplate; - delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated - delete adapterConfigWithSkills.instructionsFilePath; - delete adapterConfigWithSkills.instructionsBundleMode; - delete adapterConfigWithSkills.instructionsRootPath; - delete adapterConfigWithSkills.instructionsEntryFile; const patch = { name: planAgent.plannedName, role: manifestAgent.role, @@ -4072,8 +4166,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { icon: manifestAgent.icon, capabilities: manifestAgent.capabilities, reportsTo: null, - adapterType: effectiveAdapterType, - adapterConfig: adapterConfigWithSkills, + adapterType: normalizedAdapter.adapterType, + adapterConfig: normalizedAdapter.adapterConfig, runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, @@ -4102,6 +4196,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); importedSlugToAgentId.set(planAgent.slug, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); resultAgents.push({ @@ -4114,7 +4209,17 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - let created = await agents.create(targetCompany.id, patch); + const requiresApproval = + typeof targetCompany.requireBoardApprovalForNewAgents === "boolean" + ? targetCompany.requireBoardApprovalForNewAgents + : include.company + ? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true) + : true; + const createdStatus = requiresApproval ? "pending_approval" : "idle"; + let created = await agents.create(targetCompany.id, { + ...patch, + status: createdStatus, + }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -4133,6 +4238,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(created.id, created.status ?? createdStatus); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ @@ -4275,11 +4381,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; const description = parsed?.body || manifestIssue.description || null; - const assigneeAgentId = manifestIssue.assigneeAgentSlug - ? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? null - : null; + const assigneeAgentId = resolveImportedAssigneeAgentId( + manifestIssue.assigneeAgentSlug, + importedSlugToAgentId, + existingSlugToAgentId, + agentStatusById, + warnings, + `Task ${manifestIssue.slug}`, + ); const projectId = manifestIssue.projectSlug ? importedSlugToProjectId.get(manifestIssue.projectSlug) ?? existingProjectSlugToId.get(manifestIssue.projectSlug) @@ -4292,8 +4401,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); } if (manifestIssue.recurring) { - if (!projectId || !assigneeAgentId) { - throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); + if (!projectId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`); } const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); if (resolvedRoutine.errors.length > 0) { @@ -4373,15 +4482,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } continue; } + let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ISSUE_STATUSES[number] + : "backlog"; + if (!assigneeAgentId && issueStatus === "in_progress") { + warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`); + issueStatus = "todo"; + } await issues.create(targetCompany.id, { projectId, projectWorkspaceId, title: manifestIssue.title, description, assigneeAgentId, - status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) - ? manifestIssue.status as typeof ISSUE_STATUSES[number] - : "backlog", + status: issueStatus, priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] : "medium", diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx index 1f474f7b1fd..830d2cedc6d 100644 --- a/ui/src/components/InlineEditor.test.tsx +++ b/ui/src/components/InlineEditor.test.tsx @@ -164,6 +164,62 @@ describe("InlineEditor", () => { }); outside.remove(); }); + + it("syncs a new multiline value while focused when the user has not edited locally", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe(""); + + act(() => { + textarea!.focus(); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Loaded description"); + + act(() => { + root.unmount(); + }); + }); + + it("preserves focused multiline local edits when the prop value changes underneath them", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + + act(() => { + textarea!.focus(); + }); + act(() => { + setNativeTextareaValue(textarea!, "Local draft"); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Local draft"); + + act(() => { + root.unmount(); + }); + }); }); describe("queueContainedBlurCommit", () => { diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index f509fe7aa62..2c878b3fa22 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -54,6 +54,7 @@ export function InlineEditor({ const [editing, setEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); + const lastPropValueRef = useRef(value); const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); @@ -66,8 +67,14 @@ export function InlineEditor({ } = useAutosaveIndicator(); useEffect(() => { - if (multiline && multilineFocused) return; - setDraft(value); + const previousValue = lastPropValueRef.current; + lastPropValueRef.current = value; + setDraft((currentDraft) => { + if (multiline && multilineFocused && currentDraft !== previousValue) { + return currentDraft; + } + return value; + }); }, [value, multiline, multilineFocused]); useEffect(() => { diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index b6311eff1af..1d66b2d0f79 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -6,8 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; import { - fetchIssueDetail, - getCachedIssueDetail, + getIssueDetailQueryOptions, ISSUE_DETAIL_STALE_TIME_MS, prefetchIssueDetail, } from "@/lib/issueDetailCache"; @@ -98,12 +97,9 @@ export const IssueLinkQuicklook = React.forwardRef< const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state; - const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined); const { data, isLoading } = useQuery({ - queryKey: queryKeys.issues.detail(issuePathId), - queryFn: () => fetchIssueDetail(queryClient, issuePathId), + ...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }), enabled: open, - initialData: () => cachedIssue, staleTime: ISSUE_DETAIL_STALE_TIME_MS, }); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 814d83b05c7..ebbf6953cc4 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -96,6 +96,13 @@ describe("MarkdownBody", () => { expect(html).toContain('data-mention-kind="skill"'); }); + it("sanitizes unsafe javascript markdown links", () => { + const html = renderMarkdown("[click me](javascript:alert(document.cookie))"); + + expect(html).toContain('click me'); + expect(html).not.toContain("javascript:"); + }); + it("uses soft-break styling by default", () => { const html = renderMarkdown("First line\nSecond line"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c9fdf85988a..642fb798e72 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import Markdown, { type Components, type Options } from "react-markdown"; +import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; @@ -71,6 +71,10 @@ function extractMermaidSource(children: ReactNode): string | null { return flattenText(childProps.children).replace(/\n$/, ""); } +function safeMarkdownUrlTransform(url: string): string { + return parseMentionChipHref(url) ? url : defaultUrlTransform(url); +} + function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -215,7 +219,11 @@ export function MarkdownBody({ )} style={style} > - url}> + {children} diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 106a394b996..976ad51c561 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -19,6 +19,7 @@ const mdxEditorMockState = vi.hoisted(() => ({ emitMountParseError: false, emitMountSilentEmptyState: false, markdownValues: [] as string[], + suppressHtmlProcessingValues: [] as boolean[], })); vi.mock("@mdxeditor/editor", async () => { @@ -41,16 +42,19 @@ vi.mock("@mdxeditor/editor", async () => { onChange, onError, className, + suppressHtmlProcessing, }: { markdown: string; placeholder?: string; onChange?: (value: string) => void; onError?: (error: unknown) => void; + suppressHtmlProcessing?: boolean; className?: string; }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, ) { mdxEditorMockState.markdownValues.push(markdown); + mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing)); const [content, setContent] = React.useState(markdown); const editableRef = React.useRef(null); const handle = React.useMemo(() => ({ @@ -59,8 +63,16 @@ vi.mock("@mdxeditor/editor", async () => { }), []); React.useEffect(() => { + if (!suppressHtmlProcessing && markdown.includes(" { setForwardedRef(forwardedRef, null); @@ -165,6 +177,7 @@ describe("MarkdownEditor", () => { mdxEditorMockState.emitMountParseError = false; mdxEditorMockState.emitMountSilentEmptyState = false; mdxEditorMockState.markdownValues = []; + mdxEditorMockState.suppressHtmlProcessingValues = []; }); it("applies async external value updates once the editor ref becomes ready", async () => { @@ -238,6 +251,7 @@ describe("MarkdownEditor", () => { await flush(); expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)"); expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain(" { }); await flush(); - await vi.waitFor(() => { expect(container.querySelector("textarea")).not.toBeNull(); }); - const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); @@ -294,11 +306,9 @@ describe("MarkdownEditor", () => { }); await flush(); - await vi.waitFor(() => { expect(container.querySelector("textarea")).not.toBeNull(); }); - const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); @@ -309,7 +319,6 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); - it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { expect( computeMentionMenuPosition( diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 8e02e46eb81..a2770b3118d 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -84,6 +84,20 @@ export async function fetchIssueDetail( return seedIssueDetailCache(queryClient, issue, { issueRef }); } +export function getIssueDetailQueryOptions( + queryClient: QueryClient, + issueRef: string, + options?: { + placeholderIssue?: Pick | null; + }, +) { + return { + queryKey: queryKeys.issues.detail(issueRef), + queryFn: () => fetchIssueDetail(queryClient, issueRef), + placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined), + }; +} + export function prefetchIssueDetail( queryClient: QueryClient, issueRef: string, diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx new file mode 100644 index 00000000000..83be05ddb11 --- /dev/null +++ b/ui/src/lib/issueDetailQuery.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { getIssueDetailQueryOptions } from "./issueDetailCache"; + +vi.mock("@/api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makeIssue(overrides: Partial = {}): Issue { + const now = new Date("2026-04-13T20:00:00.000Z"); + return { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue title", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1442, + identifier: "PAP-1442", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function IssueDetailQueryHarness({ + issueRef, + placeholderIssue, +}: { + issueRef: string; + placeholderIssue?: Pick | null; +}) { + const queryClient = useQueryClient(); + const query = useQuery({ + ...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }), + }); + + return
{query.data?.description ?? "EMPTY"}
; +} + +async function flush() { + // Multiple act cycles to allow React Query to process the async queryFn + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("getIssueDetailQueryOptions", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("treats cached issue data as placeholder and still fetches full detail", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const partialIssue = makeIssue({ description: null }); + const fullIssue = makeIssue({ description: "GitHub Security Advisory body" }); + + queryClient.setQueryData(queryKeys.issues.detail("issue-1"), partialIssue); + queryClient.setQueryData(queryKeys.issues.detail("PAP-1442"), partialIssue); + vi.mocked(issuesApi.get).mockResolvedValue(fullIssue); + + await act(async () => { + root.render( + + + , + ); + }); + + await flush(); + + expect(issuesApi.get).toHaveBeenCalledWith("PAP-1442"); + expect(container.textContent).toContain("GitHub Security Advisory body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + }); +}); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0b2912b828e..de2a390db0a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -28,7 +28,8 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; -import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; +import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -882,22 +883,15 @@ export function IssueDetail() { () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), [location.state, resolvedIssueDetailState], ); - const cachedIssue = useMemo( - () => - issueId - ? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? { - id: issueHeaderSeed.id, - identifier: issueHeaderSeed.identifier, - } : null) - : undefined, - [issueHeaderSeed, issueId, queryClient], - ); const { data: issue, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.detail(issueId!), - queryFn: () => fetchIssueDetail(queryClient, issueId!), + ...getIssueDetailQueryOptions(queryClient, issueId!, { + placeholderIssue: issueHeaderSeed ? { + id: issueHeaderSeed.id, + identifier: issueHeaderSeed.identifier, + } : null, + }), enabled: !!issueId, - initialData: () => cachedIssue, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => { From f460f744eff5832dc38dc89eb131e3becb229e61 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Wed, 15 Apr 2026 16:42:55 +0200 Subject: [PATCH 07/16] fix: trust PAPERCLIP_PUBLIC_URL in board mutation guard (#3731) 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 > - Humans interact with the system through a web UI that authenticates a session and then issues mutations against the board > - A CSRF-style guard (`boardMutationGuard`) protects those mutations by requiring the request origin match a trusted set built from the `Host` / `X-Forwarded-Host` header > - Behind certain reverse proxies, neither header matches the public URL — TLS terminates at the edge and the inbound `Host` carries an internal service name (cluster-local hostname, IP, or an Ingress backend reference) > - Mutations from legitimate browser sessions then fail with `403 Board mutation requires trusted browser origin` > - `PAPERCLIP_PUBLIC_URL` is already the canonical "what operators told us the public URL is" value — it's used by better-auth and `config.ts` > - This pull request adds it to the trusted-origin set when set, so browsers reaching the legit public URL aren't blocked ## What Changed - `server/src/middleware/board-mutation-guard.ts` — parse `PAPERCLIP_PUBLIC_URL` and add its origin to the trusted set in `trustedOriginsForRequest`. Additive only. ## Verification - `PAPERCLIP_PUBLIC_URL=https://example.com pnpm start` then issue a mutation from a browser pointed at `https://example.com`: 200, as before. From an unrecognized origin: 403, as before. - Without `PAPERCLIP_PUBLIC_URL` set: behavior is unchanged. ## Risks Low. Additive only. The default dev origins and the `Host`/`X-Forwarded-Host`-derived origins continue to be trusted; this just adds the operator-configured public URL on top. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed --- server/src/middleware/board-mutation-guard.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index feff3b409a0..96e2a46122b 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); } + // Behind some reverse proxies the Host / X-Forwarded-Host header may + // not match the public URL (for example when TLS terminates at the + // edge and the inbound Host is an internal service name). Trust the + // explicitly-configured PAPERCLIP_PUBLIC_URL when it's set. + const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim()); + if (publicUrl) origins.add(publicUrl); return origins; } From 6059c665d5ef72e11198981d54cf038a8368c0a6 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Wed, 15 Apr 2026 16:43:45 +0200 Subject: [PATCH 08/16] fix(a11y): remove maximum-scale and user-scalable=no from viewport (#3726) 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 > - Humans watch and oversee those agents through a web UI > - Accessibility matters for anyone who cannot read small text comfortably — they rely on browser zoom > - The app shell's viewport meta tag includes `maximum-scale=1.0, user-scalable=no` > - Those tokens disable pinch-zoom and are a WCAG 2.1 SC 1.4.4 (Resize Text) failure > - The original motivation — suppressing iOS Safari's auto-zoom on focused inputs — is actually a font-size issue, not a viewport issue, and modern Safari only auto-zooms when input font-size is below 16px > - This pull request drops the two tokens, restoring pinch-zoom while leaving the real fix (inputs at ≥16px) to CSS ## What Changed - `ui/index.html` — remove `maximum-scale=1.0, user-scalable=no` from the viewport meta tag. Keep `width=device-width, initial-scale=1.0, viewport-fit=cover`. ## Verification - Manual on iOS and Chrome mobile: pinch-to-zoom now works across the app. - Manual on desktop: Ctrl+/- zoom already worked via `initial-scale=1.0`; unchanged. ## Risks Low. Users who were relying on auto-zoom-suppression for text inputs will notice nothing (modern Safari only auto-zooms below 16px). No API surface change. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed --- ui/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/index.html b/ui/index.html index 70a8550ead5..c41e22f2a7e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,7 +2,7 @@ - + From 0d87fd9a11e4c6d732e8695ece13ddc69b4a6265 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Wed, 15 Apr 2026 16:45:22 +0200 Subject: [PATCH 09/16] fix: proper cache headers for static assets and SPA fallback (#3734) 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 > - Every deployment serves the same Vite-built UI bundle from the same express app > - Vite emits JS/CSS under `/assets/..` — the hash rolls whenever the content rolls, so these files are inherently immutable > - `index.html` references specific hashed filenames, so it has the opposite lifecycle: whenever we deploy, the file changes but the URL doesn't > - Today the static middleware sends neither with cache headers, and the SPA fallback serves `index.html` for any unmatched route — including paths under `/assets/` that no longer exist after a deploy > - That combination produces the familiar "blank screen after deploy" + `Failed to load module script: Expected a JavaScript MIME type but received 'text/html'` bug > - This pull request caches hashed assets immutably, forces `index.html` to `no-cache` everywhere it gets served, and returns 404 for missing `/assets/*` paths ## What Changed - `server/src/app.ts`: - Serve `/assets/*` with `Cache-Control: public, max-age=31536000, immutable`. - Serve the remaining static files (favicon, manifest, robots.txt) with a 1-hour cache, but override to `no-cache` specifically for `index.html` via the `setHeaders` hook — because `express.static` serves it directly for `/` and `/index.html`. - The SPA fallback (`app.get(/.*/, …)`) sets `Cache-Control: no-cache` on its `index.html` response. - The fallback returns 404 for paths under `/assets/` so browsers don't cache the HTML shell as a JavaScript module. ## Verification - `curl -i http://localhost:3100/assets/index-abc123.js` → `cache-control: public, max-age=31536000, immutable`. - `curl -i http://localhost:3100/` → `cache-control: no-cache`. - `curl -i http://localhost:3100/assets/missing.js` → `404`. - `curl -i http://localhost:3100/some/spa/route` → `200` HTML with `cache-control: no-cache`. ## Risks Low. Asset URLs and HTML content are unchanged; only response headers and the 404 behavior for missing asset paths change. No API surface affected. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed --- server/src/app.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 317b87993d0..9f9c9ccfe33 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -299,9 +299,46 @@ export async function createApp( const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); - app.use(express.static(uiDist)); - app.get(/.*/, (_req, res) => { - res.status(200).set("Content-Type", "text/html").end(indexHtml); + // Hashed asset files (Vite emits them under /assets/..) + // never change once built, so they can be cached aggressively. + app.use( + "/assets", + express.static(path.join(uiDist, "assets"), { + maxAge: "1y", + immutable: true, + }), + ); + // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): + // short cache so operators who swap them out see the new version + // reasonably fast. Override for `index.html` specifically — it is + // served by this middleware for `/` and `/index.html`, and it must + // never outlive the asset hashes it points at. + app.use( + express.static(uiDist, { + maxAge: "1h", + setHeaders(res, filePath) { + if (path.basename(filePath) === "index.html") { + res.set("Cache-Control", "no-cache"); + } + }, + }), + ); + // SPA fallback. Only for non-asset routes — if the browser asks for + // /assets/something.js that doesn't exist, we must NOT serve the HTML + // shell: the browser would try to load it as a JavaScript module, fail + // with a MIME-type error, and cache that broken response. Return 404 + // instead. The index.html response itself is no-cache so a subsequent + // deploy's updated asset hashes are picked up on next load. + app.get(/.*/, (req, res) => { + if (req.path.startsWith("/assets/")) { + res.status(404).end(); + return; + } + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); From 390502736c320d6fbeb3f789f52a210a1dfc4a45 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Wed, 15 Apr 2026 16:46:12 +0200 Subject: [PATCH 10/16] chore(ui): drop console.* and legal comments in production builds (#3728) 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 web UI is a single-page app built with Vite and shipped as a static bundle to every deployment > - Production bundles carry `console.log` / `console.debug` calls from dev code and `/*! … */` legal-comment banners from third-party packages > - The console calls leak internals to anyone opening devtools and waste bytes per call site; the legal banners accumulate throughout the bundle > - Both problems affect every self-hoster, since they all ship the same UI bundle > - This pull request configures esbuild (via `vite.config.ts`) to strip `console` and `debugger` statements and drop inline legal comments from production builds only ## What Changed - `ui/vite.config.ts`: - Switch to the functional `defineConfig(({ mode }) => …)` form. - Add `build.minify: "esbuild"` (explicit — it's the existing default). - Add `esbuild.drop: ["console", "debugger"]` and `esbuild.legalComments: "none"`, gated on `mode === "production"` so `vite dev` is unaffected. ## Verification - `pnpm --filter @paperclipai/ui build` then grep the `ui/dist/assets/*.js` bundle for `console.log` — no occurrences. - `pnpm --filter @paperclipai/ui dev` — `console.log` calls in source still reach the browser console. - Bundle size: small reduction (varies with project but measurable on a fresh build). ## Risks Low. No API surface change. Production code should not depend on `console.*` for side effects; any call that did is now a dead call, which is the same behavior most minifiers apply. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed --- ui/vite.config.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 395eebb9d3f..a680f60730d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,8 +3,18 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], + build: { + minify: "esbuild", + }, + esbuild: + mode === "production" + ? { + drop: ["console", "debugger"], + legalComments: "none", + } + : undefined, resolve: { alias: { "@": path.resolve(__dirname, "./src"), @@ -22,4 +32,4 @@ export default defineConfig({ }, }, }, -}); +})); From c1a02497b0a593b35364aa16452bec97d3b86ad9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:47:29 -0500 Subject: [PATCH 11/16] [codex] fix worktree dev dependency ergonomics (#3743) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Local development needs to work cleanly across linked git worktrees because Paperclip itself leans on worktree-based engineering workflows > - Dev-mode asset routing, Vite watch behavior, and workspace package links are part of that day-to-day control-plane ergonomics > - The current branch had a small but coherent set of worktree/dev-tooling fixes that are independent from both the issue UI changes and the heartbeat runtime changes > - This pull request isolates those environment fixes into a standalone branch that can merge without carrying unrelated product work > - The benefit is a smoother multi-worktree developer loop with fewer stale links and less noisy dev watching ## What Changed - Serve dev public assets before the HTML shell and add a routing test that locks that behavior in. - Ignore UI test files in the Vite dev watch helper so the dev server does less unnecessary work. - Update `ensure-workspace-package-links.ts` to relink stale workspace dependencies whenever a workspace `node_modules` directory exists, instead of only inside linked-worktree detection paths. ## Verification - `pnpm vitest run server/src/__tests__/app-vite-dev-routing.test.ts ui/src/lib/vite-watch.test.ts` - `node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts` ## Risks - The asset routing change is low risk but sits near app shell behavior, so a regression would show up as broken static assets in dev mode. - The workspace-link repair now runs in more cases, so the main risk is doing unexpected relinks when a checkout has intentionally unusual workspace symlink state. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution 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 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 --- scripts/ensure-workspace-package-links.ts | 21 ++++---------- .../__tests__/app-vite-dev-routing.test.ts | 27 +++++++++++++++++ server/src/__tests__/http-log-policy.test.ts | 1 + server/src/app.ts | 7 ++++- server/src/middleware/http-log-policy.ts | 1 + ui/src/lib/vite-watch.test.ts | 29 +++++++++++++++++++ ui/src/lib/vite-watch.ts | 29 +++++++++++++++++++ ui/vite.config.ts | 4 +-- 8 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/app-vite-dev-routing.test.ts create mode 100644 ui/src/lib/vite-watch.test.ts create mode 100644 ui/src/lib/vite-watch.ts diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index 17a99909458..8e26d521a39 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --import tsx import fs from "node:fs/promises"; -import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { repoRoot } from "./dev-service-profile.ts"; @@ -43,20 +43,6 @@ function discoverWorkspacePackagePaths(rootDir: string): Map { return packagePaths; } -function isLinkedGitWorktreeCheckout(rootDir: string) { - const gitMetadataPath = path.join(rootDir, ".git"); - if (!existsSync(gitMetadataPath)) return false; - - const stat = lstatSync(gitMetadataPath); - if (!stat.isFile()) return false; - - return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); -} - -if (!isLinkedGitWorktreeCheckout(repoRoot)) { - process.exit(0); -} - const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); const workspaceDirs = Array.from( new Set( @@ -67,6 +53,11 @@ const workspaceDirs = Array.from( ).sort(); function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] { + const nodeModulesDir = path.join(repoRoot, workspaceDir, "node_modules"); + if (!existsSync(nodeModulesDir)) { + return []; + } + const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); const dependencies = { ...(packageJson.dependencies as Record | undefined), diff --git a/server/src/__tests__/app-vite-dev-routing.test.ts b/server/src/__tests__/app-vite-dev-routing.test.ts new file mode 100644 index 00000000000..cc13ec837e9 --- /dev/null +++ b/server/src/__tests__/app-vite-dev-routing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { Request } from "express"; +import { shouldServeViteDevHtml } from "../app.js"; + +function createRequest(path: string, acceptsResult: string | false): Request { + return { + path, + accepts: () => acceptsResult, + } as unknown as Request; +} + +describe("shouldServeViteDevHtml", () => { + it("serves HTML shell for document requests", () => { + expect(shouldServeViteDevHtml(createRequest("/", "html"))).toBe(true); + expect(shouldServeViteDevHtml(createRequest("/issues/abc", "html"))).toBe(true); + }); + + it("skips public assets even when the client accepts */*", () => { + expect(shouldServeViteDevHtml(createRequest("/sw.js", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/site.webmanifest", "html"))).toBe(false); + }); + + it("skips vite asset requests", () => { + expect(shouldServeViteDevHtml(createRequest("/@vite/client", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/src/main.tsx", "html"))).toBe(false); + }); +}); diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index 5f21836adea..ec140c3e878 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -56,6 +56,7 @@ describe("shouldSilenceHttpSuccessLog", () => { expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/sw.js", 200)).toBe(true); }); it("keeps normal successful application requests", () => { diff --git a/server/src/app.ts b/server/src/app.ts index 9f9c9ccfe33..c60de555269 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -70,6 +70,7 @@ const VITE_DEV_STATIC_PATHS = new Set([ "/favicon.ico", "/favicon.svg", "/site.webmanifest", + "/sw.js", ]); export function resolveViteHmrPort(serverPort: number): number { @@ -79,7 +80,7 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } -function shouldServeViteDevHtml(req: ExpressRequest): boolean { +export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false; @@ -347,6 +348,7 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); + const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ @@ -369,6 +371,9 @@ export async function createApp( }); const renderViteHtml = viteHtmlRenderer; + if (fs.existsSync(publicUiRoot)) { + app.use(express.static(publicUiRoot, { index: false })); + } app.get(/.*/, async (req, res, next) => { if (!shouldServeViteDevHtml(req)) { next(); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index fee44fee1af..d652ee38d09 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -25,6 +25,7 @@ const SILENCED_SUCCESS_STATIC_PREFIXES = [ const SILENCED_SUCCESS_STATIC_PATHS = new Set([ "/favicon.ico", "/site.webmanifest", + "/sw.js", ]); function normalizePath(url: string): string { diff --git a/ui/src/lib/vite-watch.test.ts b/ui/src/lib/vite-watch.test.ts new file mode 100644 index 00000000000..c4b456b62a0 --- /dev/null +++ b/ui/src/lib/vite-watch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { createUiDevWatchOptions, shouldIgnoreUiDevWatchPath } from "./vite-watch"; + +describe("shouldIgnoreUiDevWatchPath", () => { + it("ignores test-only files and folders", () => { + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.test.tsx")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/lib/issue-tree.spec.ts")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/__tests__/helpers.ts")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/tests/helpers.ts")).toBe(true); + }); + + it("keeps runtime source files watchable", () => { + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.tsx")).toBe(false); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/pages/IssueDetail.tsx")).toBe(false); + }); +}); + +describe("createUiDevWatchOptions", () => { + it("preserves the WSL /mnt polling fallback", () => { + expect(createUiDevWatchOptions("/mnt/c/paperclip")).toMatchObject({ + usePolling: true, + interval: 1000, + }); + }); + + it("always includes the ignored-path predicate", () => { + expect(createUiDevWatchOptions("/Users/dotta/paperclip")).toHaveProperty("ignored"); + }); +}); diff --git a/ui/src/lib/vite-watch.ts b/ui/src/lib/vite-watch.ts new file mode 100644 index 00000000000..f5792a2e191 --- /dev/null +++ b/ui/src/lib/vite-watch.ts @@ -0,0 +1,29 @@ +const TEST_DIRECTORY_NAMES = new Set([ + "__tests__", + "_tests", + "test", + "tests", +]); + +const TEST_FILE_BASENAME_RE = /\.(test|spec)\.[^/]+$/i; + +export function shouldIgnoreUiDevWatchPath(watchedPath: string): boolean { + const normalizedPath = String(watchedPath).replaceAll("\\", "/"); + if (normalizedPath.length === 0) return false; + + const segments = normalizedPath.split("/"); + const basename = segments.at(-1) ?? normalizedPath; + + return segments.some((segment) => TEST_DIRECTORY_NAMES.has(segment)) + || TEST_FILE_BASENAME_RE.test(basename); +} + +export function createUiDevWatchOptions(currentWorkingDirectory: string) { + return { + ignored: shouldIgnoreUiDevWatchPath, + // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works. + ...(currentWorkingDirectory.startsWith("/mnt/") + ? { usePolling: true, interval: 1000 } + : {}), + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index a680f60730d..27f481a4019 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,6 +2,7 @@ import path from "path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { createUiDevWatchOptions } from "./src/lib/vite-watch"; export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], @@ -23,8 +24,7 @@ export default defineConfig(({ mode }) => ({ }, server: { port: 5173, - // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works - watch: process.cwd().startsWith("/mnt/") ? { usePolling: true, interval: 1000 } : undefined, + watch: createUiDevWatchOptions(process.cwd()), proxy: { "/api": { target: "http://localhost:3100", From 3fa5d25de16a919c3dc3adc6085447efcb880108 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:48:39 -0500 Subject: [PATCH 12/16] [codex] harden heartbeat run summaries and recovery context (#3742) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Heartbeat runs are the control-plane record of what agents did, why they woke up, and what operators should see next > - Run lists, stranded issue comments, and live log polling all depend on compact but accurate heartbeat summaries > - The current branch had a focused backend slice that improves how run result JSON is summarized, how stale process recovery comments are written, and how live log polling resolves the active run > - This pull request isolates that heartbeat/runtime reliability work from the unrelated UI and dev-tooling changes > - The benefit is more reliable issue context and cheaper run lookups without dragging unrelated board UI changes into the same review ## What Changed - Include the latest run failure in stranded issue comments during orphaned process recovery. - Bound heartbeat `result_json` payloads for list responses while preserving the raw stored payloads. - Narrow heartbeat log endpoint lookups so issue polling resolves the relevant active run with less unnecessary scanning. - Add focused tests for heartbeat list summaries, live run polling, orphaned process recovery, and the run context/result summary helpers. ## Verification - `pnpm vitest run server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/heartbeat-list.test.ts server/src/__tests__/agent-live-run-routes.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts` ## Risks - The main risk is accidentally hiding a field that some client still expects from summarized `result_json`, or over-constraining the live log lookup path for edge-case run routing. - Recovery comments now surface the latest failure more aggressively, so wording changes may affect downstream expectations if anyone parses those comments too strictly. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution 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 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 --- .../__tests__/agent-live-run-routes.test.ts | 38 +++ .../heartbeat-context-summary.test.ts | 83 +++++ server/src/__tests__/heartbeat-list.test.ts | 101 ++++++ .../heartbeat-process-recovery.test.ts | 2 + server/src/routes/agents.ts | 4 +- server/src/services/heartbeat-run-summary.ts | 6 +- server/src/services/heartbeat.ts | 288 ++++++++++++++++-- 7 files changed, 498 insertions(+), 24 deletions(-) create mode 100644 server/src/__tests__/heartbeat-context-summary.test.ts diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 061d640d887..e471c3728ec 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -9,6 +9,8 @@ const mockAgentService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ getRunIssueSummary: vi.fn(), getActiveRunIssueSummaryForAgent: vi.fn(), + getRunLogAccess: vi.fn(), + readLog: vi.fn(), })); const mockIssueService = vi.hoisted(() => ({ @@ -100,6 +102,19 @@ describe("agent live run routes", () => { issueId: "issue-1", }); mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null); + mockHeartbeatService.getRunLogAccess.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }); + mockHeartbeatService.readLog.mockResolvedValue({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); }); it("returns a compact active run payload for issue polling", async () => { @@ -163,4 +178,27 @@ describe("agent live run routes", () => { adapterType: "codex_local", }); }); + + it("uses narrow run log metadata lookups for log polling", async () => { + const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }, { + offset: 12, + limitBytes: 64, + }); + expect(res.body).toEqual({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); + }); }); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts new file mode 100644 index 00000000000..c0d6a4238e6 --- /dev/null +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + summarizeHeartbeatRunContextSnapshot, + summarizeHeartbeatRunListResultJson, +} from "../services/heartbeat.js"; + +describe("summarizeHeartbeatRunContextSnapshot", () => { + it("keeps only the small retry/linking fields needed by the client", () => { + const summarized = summarizeHeartbeatRunContextSnapshot({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + paperclipWake: { + comments: [ + { + body: "x".repeat(50_000), + }, + ], + }, + executionStage: { + summary: "large nested object that should not be sent back in run lists", + }, + }); + + expect(summarized).toEqual({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + }); + }); + + it("returns null when no allowed fields are present", () => { + expect( + summarizeHeartbeatRunContextSnapshot({ + paperclipWake: { comments: [{ body: "hello" }] }, + }), + ).toBeNull(); + }); +}); + +describe("summarizeHeartbeatRunListResultJson", () => { + it("keeps only summary fields and parses numeric cost aliases", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "Completed the task", + result: "Updated three files", + message: "", + error: null, + totalCostUsd: "1.25", + costUsd: "0.75", + costUsdCamel: "0.5", + }), + ).toEqual({ + summary: "Completed the task", + result: "Updated three files", + total_cost_usd: 1.25, + cost_usd: 0.75, + costUsd: 0.5, + }); + }); + + it("returns null when projected fields are empty", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "", + result: null, + message: undefined, + error: " ", + totalCostUsd: "abc", + }), + ).toBeNull(); + }); +}); diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index 1c04009d3f6..a3e6620f726 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -88,4 +88,105 @@ describeEmbeddedPostgres("heartbeat list", () => { } } }); + + it("returns small result json payloads unchanged from getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "done", + structured: { ok: true }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + + expect(run?.resultJson).toEqual({ + summary: "done", + structured: { ok: true }, + }); + }); + + it("bounds oversized legacy result json payloads on getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const oversizedStdout = Array.from({ length: 8_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}-${randomUUID()}`, + ).join("|"); + const oversizedNestedPayload = Array.from({ length: 6_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}:${randomUUID()}`, + ).join("|"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "completed", + stdout: oversizedStdout, + nestedHuge: { payload: oversizedNestedPayload }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + const result = run?.resultJson as Record | null; + + expect(result).toMatchObject({ + summary: "completed", + truncated: true, + truncationReason: "oversized_result_json", + stdoutTruncated: true, + }); + expect(typeof result?.stdout).toBe("string"); + expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length); + expect(result).not.toHaveProperty("nestedHuge"); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 338c702b0e3..62b82e55f85 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -598,6 +598,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried dispatch"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); }); it("re-enqueues continuation for stranded in-progress work with no active run", async () => { @@ -646,6 +647,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); }); it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9511d6d0daa..fb72a6d7ba6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2479,7 +2479,7 @@ export function agentRoutes(db: Db) { router.get("/heartbeat-runs/:runId/log", async (req, res) => { const runId = req.params.runId as string; - const run = await heartbeat.getRun(runId); + const run = await heartbeat.getRunLogAccess(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; @@ -2488,7 +2488,7 @@ export function agentRoutes(db: Db) { const offset = Number(req.query.offset ?? 0); const limitBytes = Number(req.query.limitBytes ?? 256000); - const result = await heartbeat.readLog(runId, { + const result = await heartbeat.readLog(run, { offset: Number.isFinite(offset) ? offset : 0, limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4d6335ef653..c071e867e7e 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -1,4 +1,8 @@ -function truncateSummaryText(value: unknown, maxLength = 500) { +export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500; +export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096; +export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024; + +function truncateSummaryText(value: unknown, maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS) { if (typeof value !== "string") return null; return value.length > maxLength ? value.slice(0, maxLength) : value; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 34f7c8ba204..fbb25340edf 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { @@ -35,8 +35,10 @@ import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { buildHeartbeatRunIssueComment, + HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS, + HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS, + HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES, mergeHeartbeatRunResultJson, - summarizeHeartbeatRunResultJson, } from "./heartbeat-run-summary.js"; import { logActivity, type LogActivityInput } from "./activity-log.js"; import { @@ -378,7 +380,6 @@ const heartbeatRunListColumns = { exitCode: heartbeatRuns.exitCode, signal: heartbeatRuns.signal, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, sessionIdBefore: heartbeatRuns.sessionIdBefore, sessionIdAfter: heartbeatRuns.sessionIdAfter, logStore: heartbeatRuns.logStore, @@ -395,11 +396,90 @@ const heartbeatRunListColumns = { processStartedAt: heartbeatRuns.processStartedAt, retryOfRunId: heartbeatRuns.retryOfRunId, processLossRetryCount: heartbeatRuns.processLossRetryCount, - contextSnapshot: heartbeatRuns.contextSnapshot, createdAt: heartbeatRuns.createdAt, updatedAt: heartbeatRuns.updatedAt, } as const; +const heartbeatRunListContextColumns = { + contextIssueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"), + contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), + contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), + contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), + contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), +} as const; + +const heartbeatRunListResultColumns = { + resultSummary: sql`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultSummary"), + resultResult: sql`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultResult"), + resultMessage: sql`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultMessage"), + resultError: sql`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultError"), + resultTotalCostUsd: sql`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"), + resultCostUsd: sql`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"), + resultCostUsdCamel: sql`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"), +} as const; + +const heartbeatRunSafeResultJsonColumn = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES} + then ${heartbeatRuns.resultJson} + else jsonb_strip_nulls( + jsonb_build_object( + 'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stdoutTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'stderrTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ), + 'truncated', true, + 'truncationReason', 'oversized_result_json', + 'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson}) + ) + ) + end +`.as("resultJson"); + +const heartbeatRunSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + resultJson: heartbeatRunSafeResultJsonColumn, +} as const; + +const heartbeatRunLogAccessColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, +} as const; + const heartbeatRunIssueSummaryColumns = { id: heartbeatRuns.id, status: heartbeatRuns.status, @@ -519,6 +599,87 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +export function summarizeHeartbeatRunContextSnapshot( + contextSnapshot: Record | null | undefined, +): Record | null { + const summary: Record = {}; + const allowedKeys = [ + "issueId", + "taskId", + "taskKey", + "commentId", + "wakeCommentId", + "wakeReason", + "wakeSource", + "wakeTriggerDetail", + ] as const; + + for (const key of allowedKeys) { + const value = readNonEmptyString(contextSnapshot?.[key]); + if (value) summary[key] = value; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +export function summarizeHeartbeatRunListResultJson(input: { + summary?: string | null; + result?: string | null; + message?: string | null; + error?: string | null; + totalCostUsd?: string | null; + costUsd?: string | null; + costUsdCamel?: string | null; +}): Record | null { + const summary: Record = {}; + for (const [key, value] of [ + ["summary", input.summary], + ["result", input.result], + ["message", input.message], + ["error", input.error], + ] as const) { + const normalized = readNonEmptyString(value); + if (normalized) summary[key] = normalized; + } + + for (const [key, value] of [ + ["total_cost_usd", input.totalCostUsd], + ["cost_usd", input.costUsd], + ["costUsd", input.costUsdCamel], + ] as const) { + const normalized = readNonEmptyString(value); + if (!normalized) continue; + const parsed = Number(normalized); + if (Number.isFinite(parsed)) summary[key] = parsed; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +function summarizeRunFailureForIssueComment( + run: Pick | null | undefined, +) { + if (!run) return null; + + const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null; + const rawError = readNonEmptyString(run.error)?.trim() ?? null; + const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/); + const firstLine = rawError + ?.split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null; + const summarySource = apiMessageMatch?.[1] ?? firstLine; + const summary = + summarySource && summarySource.length > 240 + ? `${summarySource.slice(0, 237)}...` + : summarySource; + + if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`; + if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`; + if (summary) return ` Latest retry failure: ${summary}.`; + return null; +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -1351,9 +1512,17 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } - async function getRun(runId: string) { + async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) { return db - .select() + .select(opts?.unsafeFullResultJson ? getTableColumns(heartbeatRuns) : heartbeatRunSafeColumns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function getRunLogAccess(runId: string) { + return db + .select(heartbeatRunLogAccessColumns) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); @@ -1421,7 +1590,10 @@ export function heartbeatService(db: Db) { conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); } return db - .select() + .select({ + id: heartbeatRuns.id, + usageJson: heartbeatRuns.usageJson, + }) .from(heartbeatRuns) .where(and(...conditions)) .orderBy(desc(heartbeatRuns.createdAt)) @@ -1497,8 +1669,8 @@ export function heartbeatService(db: Db) { id: heartbeatRuns.id, createdAt: heartbeatRuns.createdAt, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, error: heartbeatRuns.error, + ...heartbeatRunListResultColumns, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId))) @@ -1552,7 +1724,15 @@ export function heartbeatService(db: Db) { }; } - const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson); + const latestSummary = summarizeHeartbeatRunListResultJson({ + summary: latestRun?.resultSummary, + result: latestRun?.resultResult, + message: latestRun?.resultMessage, + error: latestRun?.resultError, + totalCostUsd: latestRun?.resultTotalCostUsd, + costUsd: latestRun?.resultCostUsd, + costUsdCamel: latestRun?.resultCostUsdCamel, + }); const latestTextSummary = readNonEmptyString(latestSummary?.summary) ?? readNonEmptyString(latestSummary?.result) ?? @@ -2681,7 +2861,13 @@ export function heartbeatService(db: Db) { async function getLatestIssueRun(companyId: string, issueId: string) { return db - .select() + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) .from(heartbeatRuns) .where( and( @@ -2771,7 +2957,10 @@ export function heartbeatService(db: Db) { async function escalateStrandedAssignedIssue(input: { issue: typeof issues.$inferSelect; previousStatus: "todo" | "in_progress"; - latestRun: typeof heartbeatRuns.$inferSelect | null; + latestRun: Pick< + typeof heartbeatRuns.$inferSelect, + "id" | "status" | "error" | "errorCode" | "contextSnapshot" + > | null; comment: string; }) { const updated = await issuesSvc.update(input.issue.id, { @@ -2857,13 +3046,15 @@ export function heartbeatService(db: Db) { } if (latestRetryReason === "assignment_recovery") { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "todo", latestRun, comment: "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " + - "but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.", + `but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", }); if (updated) { result.escalated += 1; @@ -2892,14 +3083,15 @@ export function heartbeatService(db: Db) { } if (latestRetryReason === "issue_continuation_needed") { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "in_progress", latestRun, comment: "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " + - "execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is " + - "visible for intervention.", + `execution disappeared, but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", }); if (updated) { result.escalated += 1; @@ -4940,7 +5132,11 @@ export function heartbeatService(db: Db) { return { list: async (companyId: string, agentId?: string, limit?: number) => { const query = db - .select(heartbeatRunListColumns) + .select({ + ...heartbeatRunListColumns, + ...heartbeatRunListContextColumns, + ...heartbeatRunListResultColumns, + }) .from(heartbeatRuns) .where( agentId @@ -4950,14 +5146,55 @@ export function heartbeatService(db: Db) { .orderBy(desc(heartbeatRuns.createdAt)); const rows = limit ? await query.limit(limit) : await query; - return rows.map((row) => ({ - ...row, - resultJson: summarizeHeartbeatRunResultJson(row.resultJson), - })); + return rows.map((row) => { + const { + contextIssueId, + contextTaskId, + contextTaskKey, + contextCommentId, + contextWakeCommentId, + contextWakeReason, + contextWakeSource, + contextWakeTriggerDetail, + resultSummary, + resultResult, + resultMessage, + resultError, + resultTotalCostUsd, + resultCostUsd, + resultCostUsdCamel, + ...rest + } = row; + + return { + ...rest, + contextSnapshot: summarizeHeartbeatRunContextSnapshot({ + issueId: contextIssueId, + taskId: contextTaskId, + taskKey: contextTaskKey, + commentId: contextCommentId, + wakeCommentId: contextWakeCommentId, + wakeReason: contextWakeReason, + wakeSource: contextWakeSource, + wakeTriggerDetail: contextWakeTriggerDetail, + }), + resultJson: summarizeHeartbeatRunListResultJson({ + summary: resultSummary, + result: resultResult, + message: resultMessage, + error: resultError, + totalCostUsd: resultTotalCostUsd, + costUsd: resultCostUsd, + costUsdCamel: resultCostUsdCamel, + }), + }; + }); }, getRun, + getRunLogAccess, + getRuntimeState: async (agentId: string) => { const state = await getRuntimeState(agentId); const agent = await getAgent(agentId); @@ -5031,8 +5268,17 @@ export function heartbeatService(db: Db) { .orderBy(asc(heartbeatRunEvents.seq)) .limit(Math.max(1, Math.min(limit, 1000))), - readLog: async (runId: string, opts?: { offset?: number; limitBytes?: number }) => { - const run = await getRun(runId); + readLog: async ( + runOrLookup: string | { + id: string; + companyId: string; + logStore: string | null; + logRef: string | null; + }, + opts?: { offset?: number; limitBytes?: number }, + ) => { + const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup; + const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id; if (!run) throw notFound("Heartbeat run not found"); if (!run.logStore || !run.logRef) throw notFound("Run log not found"); From 7463479fc82904fd1965ba21ad9059195963e406 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Wed, 15 Apr 2026 16:53:25 +0200 Subject: [PATCH 13/16] fix: disable HTTP caching on run log endpoints (#3724) 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 > - Every run emits a streaming log that the web UI polls so humans can watch what the agent is doing > - Log responses go out without explicit cache directives, so Express adds an ETag > - If the first poll lands before any bytes have been written, the browser caches the empty / partial snapshot and keeps getting `304 Not Modified` on every subsequent poll > - The transcript pane then stays stuck on "Waiting for transcript…" even after the log has plenty of content > - This pull request sets `Cache-Control: no-cache, no-store` on both run-log endpoints so the conditional-request path is defeated ## What Changed - `server/src/routes/agents.ts` — `GET /heartbeat-runs/:runId/log` now sets `Cache-Control: no-cache, no-store` on the response. - Same change applied to `GET /workspace-operations/:operationId/log` (same structure, same bug). ## Verification - Reproduction: start a long-running agent, watch the transcript pane. Before the fix, open devtools and observe `304 Not Modified` on each poll after the initial 200 with an empty body; the UI never updates. After the fix, each poll is a 200 with fresh bytes. - Existing tests pass. ## Risks Low. Cache headers only affect whether the browser revalidates; the response body is unchanged. No API surface change. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed --- server/src/routes/agents.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index fb72a6d7ba6..f5e06dd62aa 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2493,6 +2493,7 @@ export function agentRoutes(db: Db) { limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2527,6 +2528,7 @@ export function agentRoutes(db: Db) { limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); From d4c3899ca4c9c2abfbcbeab8056a9036f30daf34 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:05 -0500 Subject: [PATCH 14/16] [codex] improve issue and routine UI responsiveness (#3744) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators rely on issue, inbox, and routine views to understand what the company is doing in real time > - Those views need to stay fast and readable even when issue lists, markdown comments, and run metadata get large > - The current branch had a coherent set of UI and live-update improvements spread across issue search, issue detail rendering, routine affordances, and workspace lookups > - This pull request groups those board-facing changes into one standalone branch that can merge independently of the heartbeat/runtime work > - The benefit is a faster, clearer issue and routine workflow without changing the underlying task model ## What Changed - Show routine execution issues by default and rename the filter to `Hide routine runs` so the default state no longer looks like an active filter. - Show the routine name in the run dialog and tighten the issue properties pane with a workspace link, copy-on-click behavior, and an inline parent arrow. - Reduce issue detail rerenders, keep queued issue chat mounted, improve issues page search responsiveness, and speed up issues first paint. - Add inbox "other search results", refresh visible issue runs after status updates, and optimize workspace lookups through summary-mode execution workspace queries. - Improve markdown wrapping and scrolling behavior for long strings and self-comment code blocks. - Relax the markdown sanitizer assertion so the test still validates safety after the new wrap-friendly inline styles. ## Verification - `pnpm vitest run ui/src/components/IssuesList.test.tsx ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx ui/src/context/BreadcrumbContext.test.tsx ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/api/execution-workspaces.test.ts server/src/__tests__/execution-workspaces-routes.test.ts` ## Risks - This touches several issue-facing UI surfaces at once, so regressions would most likely show up as stale rendering, search result mismatches, or small markdown presentation differences. - The workspace lookup optimization depends on the summary-mode route shape staying aligned between server and UI. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution 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 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 --- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + .../shared/src/types/workspace-runtime.ts | 7 + .../execution-workspaces-routes.test.ts | 90 +++++++++ server/src/routes/execution-workspaces.ts | 7 +- server/src/routes/issues.ts | 2 + server/src/services/execution-workspaces.ts | 74 ++++++-- server/src/services/issues.ts | 4 +- ui/src/api/execution-workspaces.test.ts | 29 +++ ui/src/api/execution-workspaces.ts | 23 +++ ui/src/components/IssueChatThread.tsx | 4 +- ui/src/components/IssueFiltersPopover.tsx | 6 +- ui/src/components/IssueProperties.test.tsx | 3 + ui/src/components/IssueProperties.tsx | 54 +++--- ui/src/components/IssuesList.test.tsx | 152 ++++++++++++++- ui/src/components/IssuesList.tsx | 73 +++++++- ui/src/components/MarkdownBody.test.tsx | 27 ++- ui/src/components/MarkdownBody.tsx | 66 ++++++- .../components/RoutineRunVariablesDialog.tsx | 5 + .../transcript/RunTranscriptView.test.tsx | 8 +- ui/src/context/BreadcrumbContext.test.tsx | 61 ++++++ ui/src/context/BreadcrumbContext.tsx | 13 +- ui/src/context/LiveUpdatesProvider.test.ts | 102 ++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 26 +++ ui/src/lib/inbox.test.ts | 64 +++++-- ui/src/lib/inbox.ts | 47 ++++- ui/src/lib/issue-filters.ts | 8 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/Inbox.tsx | 94 ++++------ ui/src/pages/IssueDetail.tsx | 177 ++++++++++-------- ui/src/pages/Issues.test.tsx | 16 ++ ui/src/pages/Issues.tsx | 28 +-- ui/src/pages/RoutineDetail.tsx | 1 + ui/src/pages/Routines.tsx | 1 + 34 files changed, 1035 insertions(+), 241 deletions(-) create mode 100644 server/src/__tests__/execution-workspaces-routes.test.ts create mode 100644 ui/src/api/execution-workspaces.test.ts create mode 100644 ui/src/context/BreadcrumbContext.test.tsx create mode 100644 ui/src/pages/Issues.test.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0cc99826432..fab8c1ed7a1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -224,6 +224,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6c38141de9c..62ca4dde0ac 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 9e287200fa8..4e6b01cdace 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings { workspaceRuntime?: Record | null; } +export interface ExecutionWorkspaceSummary { + id: string; + name: string; + mode: Exclude | "adapter_managed" | "cloud_sandbox"; + projectWorkspaceId: string | null; +} + export interface ExecutionWorkspace { id: string; companyId: string; diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts new file mode 100644 index 00000000000..a5295fe09ff --- /dev/null +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -0,0 +1,90 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + list: vi.fn(), + listSummaries: vi.fn(), + getById: vi.fn(), + getCloseReadiness: vi.fn(), + update: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({ + listForExecutionWorkspace: vi.fn(), + createRecorder: vi.fn(), +})); + +function registerServiceMocks() { + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: vi.fn(async () => undefined), + workspaceOperationService: () => mockWorkspaceOperationService, + })); +} + +async function createApp() { + const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/execution-workspaces.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("execution workspace routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerServiceMocks(); + vi.resetAllMocks(); + mockExecutionWorkspaceService.list.mockResolvedValue([]); + mockExecutionWorkspaceService.listSummaries.mockResolvedValue([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + }); + + it("uses summary mode for lightweight workspace lookups", async () => { + const res = await request(await createApp()) + .get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + projectId: undefined, + projectWorkspaceId: undefined, + issueId: undefined, + status: undefined, + reuseEligible: true, + }); + expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index faabd53c50b..20e80c53e43 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -37,13 +37,16 @@ export function executionWorkspaceRoutes(db: Db) { router.get("/companies/:companyId/execution-workspaces", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const workspaces = await svc.list(companyId, { + const filters = { projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, status: req.query.status as string | undefined, reuseEligible: req.query.reuseEligible === "true", - }); + }; + const workspaces = req.query.summary === "true" + ? await svc.listSummaries(companyId, filters) + : await svc.list(companyId, filters); res.json(workspaces); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c5d2d40d122..1531441ed1b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -656,6 +656,8 @@ export function issueRoutes( originId: req.query.originId as string | undefined, includeRoutineExecutions: req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", + excludeRoutineExecutions: + req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", q: req.query.q as string | undefined, limit, }); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index af1c3ee81a5..1832a1a1b5e 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseReadiness, @@ -336,6 +337,15 @@ function toExecutionWorkspace( }; } +function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { + return { + id: row.id, + name: row.name, + mode: row.mode as ExecutionWorkspaceSummary["mode"], + projectWorkspaceId: row.projectWorkspaceId ?? null, + }; +} + function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; @@ -372,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( } export function executionWorkspaceService(db: Db) { + function buildListConditions( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) { + const conditions = [eq(executionWorkspaces.companyId, companyId)]; + if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); + if (filters?.projectWorkspaceId) { + conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); + } + if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); + if (filters?.status) { + const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); + if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); + else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); + } + if (filters?.reuseEligible) { + conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + } + return conditions; + } + return { list: async (companyId: string, filters?: { projectId?: string; @@ -380,21 +417,7 @@ export function executionWorkspaceService(db: Db) { status?: string; reuseEligible?: boolean; }) => { - const conditions = [eq(executionWorkspaces.companyId, companyId)]; - if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); - if (filters?.projectWorkspaceId) { - conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); - } - if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); - if (filters?.status) { - const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); - if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); - else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); - } - if (filters?.reuseEligible) { - conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); - } - + const conditions = buildListConditions(companyId, filters); const rows = await db .select() .from(executionWorkspaces) @@ -409,6 +432,27 @@ export function executionWorkspaceService(db: Db) { ); }, + listSummaries: async (companyId: string, filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }) => { + const conditions = buildListConditions(companyId, filters); + const rows = await db + .select({ + id: executionWorkspaces.id, + name: executionWorkspaces.name, + mode: executionWorkspaces.mode, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + }) + .from(executionWorkspaces) + .where(and(...conditions)) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); + return rows.map((row) => toExecutionWorkspaceSummary(row)); + }, + getById: async (id: string) => { const row = await db .select() diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index db8eb39a1e6..e18cd7c359a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -79,6 +79,7 @@ export interface IssueFilters { originKind?: string; originId?: string; includeRoutineExecutions?: boolean; + excludeRoutineExecutions?: boolean; q?: string; limit?: number; } @@ -985,7 +986,7 @@ export function issueService(db: Db) { )!, ); } - if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) { + if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } conditions.push(isNull(issues.hiddenAt)); @@ -1162,7 +1163,6 @@ export function issueService(db: Db) { eq(issues.companyId, companyId), isNull(issues.hiddenAt), unreadForUserCondition(companyId, userId), - ne(issues.originKind, "routine_execution"), ]; if (status) { const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts new file mode 100644 index 00000000000..b18bd6501f4 --- /dev/null +++ b/ui/src/api/execution-workspaces.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { executionWorkspacesApi } from "./execution-workspaces"; + +describe("executionWorkspacesApi.listSummaries", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + it("requests the lightweight summary payload", async () => { + await executionWorkspacesApi.listSummaries("company-1", { + projectId: "project-1", + reuseEligible: true, + }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", + ); + }); +}); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 2c10a4dc44a..cd5b7341752 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,5 +1,6 @@ import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseReadiness, WorkspaceOperation, WorkspaceRuntimeControlTarget, @@ -8,6 +9,28 @@ import { api } from "./client"; import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; export const executionWorkspacesApi = { + listSummaries: ( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.projectId) params.set("projectId", filters.projectId); + if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId); + if (filters?.issueId) params.set("issueId", filters.issueId); + if (filters?.status) params.set("status", filters.status); + if (filters?.reuseEligible) params.set("reuseEligible", "true"); + params.set("summary", "true"); + const qs = params.toString(); + return api.get( + `/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`, + ); + }, list: ( companyId: string, filters?: { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a8f48cd9aec..91fda37330f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -922,7 +922,7 @@ function IssueChatUserMessage() {
) : null} -
+
, diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 2d21d49a2bd..321d9b1d227 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -251,10 +251,10 @@ export function IssueFiltersPopover({ Visibility
) : null} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 3c92683a7b0..2069d76b1bf 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -323,6 +323,9 @@ describe("IssueProperties", () => { const selectedParentTrigger = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); expect(selectedParentTrigger).not.toBeUndefined(); + const parentLink = container.querySelector('a[href="/issues/PAP-2"]'); + expect(parentLink).not.toBeNull(); + expect(selectedParentTrigger!.contains(parentLink)).toBe(false); await act(async () => { selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ed7a5c31fd9..0deebe6376d 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { @@ -39,17 +39,15 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C return (
- - {value} - + {copied && }
); } @@ -704,16 +702,25 @@ export function IssueProperties({ if (!issue.parentId) return null; return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; }, [allIssues, issue.parentId]); + const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier; + const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8); const parentTrigger = issue.parentId ? ( - - {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier - ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` - : ""} - {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + {parentIdentifier ? `${parentIdentifier} ` : ""} + {parentTitle} ) : ( No parent ); + const parentLink = issue.parentId ? ( + e.stopPropagation()} + > + + + ) : undefined; const parentOptions = (allIssues ?? []) .filter((candidate) => candidate.id !== issue.id) .filter((candidate) => !descendantIssueIds.has(candidate.id)) @@ -939,15 +946,7 @@ export function IssueProperties({ triggerContent={parentTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-72" - extra={issue.parentId ? ( - e.stopPropagation()} - > - - - ) : undefined} + extra={parentLink} > {parentContent} @@ -1060,10 +1059,21 @@ export function IssueProperties({ )}
- {issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? ( + {issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( <>
+ {issue.executionWorkspaceId && ( + + + View workspace + + + + )} {issue.currentExecutionWorkspace?.branchName && ( ({ const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), + listSummaries: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ @@ -183,11 +184,13 @@ describe("IssuesList", () => { mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); + mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); localStorage.clear(); }); @@ -216,7 +219,11 @@ describe("IssuesList", () => { ); await waitForAssertion(() => { - expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined }); + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + q: "server", + projectId: undefined, + limit: 200, + }); expect(container.textContent).toContain("Server result"); expect(container.textContent).not.toContain("Local issue"); }); @@ -250,6 +257,7 @@ describe("IssuesList", () => { q: "server", projectId: undefined, parentId: "parent-1", + limit: 200, }); expect(container.textContent).toContain("Server result"); expect(container.textContent).not.toContain("Local issue"); @@ -333,7 +341,7 @@ describe("IssuesList", () => { expect(onSearchChange).not.toHaveBeenCalled(); act(() => { - vi.advanceTimersByTime(149); + vi.advanceTimersByTime(249); }); expect(onSearchChange).not.toHaveBeenCalled(); @@ -351,6 +359,109 @@ describe("IssuesList", () => { }); }); + it("shows a refinement hint when search results hit the live search cap", async () => { + const serverIssues = Array.from({ length: 200 }, (_, index) => + createIssue({ + id: `issue-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Server result ${index + 1}`, + }), + ); + + mockIssuesApi.list.mockResolvedValue(serverIssues); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Showing up to 200 matches. Refine the search to narrow further."); + }); + + act(() => { + root.unmount(); + }); + }); + + it("caps the first paint for large issue lists", async () => { + const manyIssues = Array.from({ length: 220 }, (_, index) => + createIssue({ + id: `issue-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Issue ${index + 1}`, + }), + ); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(150); + expect(container.textContent).toContain("Rendering 150 of 220 issues"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("skips deferred row sizing for expanded parent rows with visible children", async () => { + const parentIssue = createIssue({ + id: "issue-parent", + identifier: "PAP-1", + title: "Parent issue", + }); + const childIssue = createIssue({ + id: "issue-child", + identifier: "PAP-2", + title: "Child issue", + parentId: "issue-parent", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); + const parentRow = rows.find((row) => row.textContent?.includes("Parent issue")); + const childRow = rows.find((row) => row.textContent?.includes("Child issue")); + expect(parentRow).not.toBeUndefined(); + expect(childRow).not.toBeUndefined(); + expect((parentRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe(""); + expect((parentRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe(""); + expect((childRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe("auto"); + expect((childRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe("44px"); + }); + + act(() => { + root.unmount(); + }); + }); + it("uses context-scoped persisted column visibility", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"])); @@ -423,7 +534,7 @@ describe("IssuesList", () => { it("filters the list to a single workspace when a workspace name is clicked", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"])); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); - mockExecutionWorkspacesApi.list.mockResolvedValue([ + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-alpha", name: "Alpha", @@ -491,7 +602,7 @@ describe("IssuesList", () => { }); }); - it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { + it("shows routine-backed issues by default and hides them when the routine filter is toggled off", async () => { const manualIssue = createIssue({ id: "issue-manual", identifier: "PAP-10", @@ -519,7 +630,7 @@ describe("IssuesList", () => { await waitForAssertion(() => { expect(container.textContent).toContain("Manual issue"); - expect(container.textContent).not.toContain("Routine issue"); + expect(container.textContent).toContain("Routine issue"); }); await act(async () => { @@ -532,21 +643,21 @@ describe("IssuesList", () => { await waitForAssertion(() => { const toggle = Array.from(document.body.querySelectorAll("label")).find( - (label) => label.textContent?.includes("Show routine runs"), + (label) => label.textContent?.includes("Hide routine runs"), ); expect(toggle).not.toBeUndefined(); }); await act(async () => { const toggle = Array.from(document.body.querySelectorAll("label")).find( - (label) => label.textContent?.includes("Show routine runs"), + (label) => label.textContent?.includes("Hide routine runs"), ); toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); await waitForAssertion(() => { - expect(container.textContent).toContain("Routine issue"); + expect(container.textContent).not.toContain("Routine issue"); }); act(() => { @@ -624,4 +735,29 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("uses workspace summaries instead of the full workspace list on the issues page", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1"); + expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 2ac9b89c177..1f8ebcdcba7 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -54,7 +54,11 @@ import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import type { Issue, Project } from "@paperclipai/shared"; -const ISSUE_SEARCH_DEBOUNCE_MS = 150; +const ISSUE_SEARCH_DEBOUNCE_MS = 250; +const ISSUE_SEARCH_RESULT_LIMIT = 200; +const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150; +const ISSUE_ROW_RENDER_BATCH_SIZE = 150; +const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; /* ── View state ── */ @@ -283,6 +287,7 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); + const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => loadIssueColumns(scopedKey)); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); @@ -333,12 +338,14 @@ export function IssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, + ISSUE_SEARCH_RESULT_LIMIT, enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, + limit: ISSUE_SEARCH_RESULT_LIMIT, ...searchFilters, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }), @@ -347,9 +354,9 @@ export function IssuesList({ }); const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.executionWorkspaces.list(selectedCompanyId) + ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], - queryFn: () => executionWorkspacesApi.list(selectedCompanyId!), + queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); @@ -529,6 +536,26 @@ export function IssuesList({ })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]); + useEffect(() => { + if (viewState.viewMode !== "list") return; + setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT)); + }, [filtered, viewState.viewMode]); + + useEffect(() => { + if (viewState.viewMode !== "list") return; + if (renderedIssueRowLimit >= filtered.length) return; + + const timeoutId = window.setTimeout(() => { + startTransition(() => { + setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE)); + }); + }, ISSUE_ROW_RENDER_BATCH_DELAY_MS); + + return () => window.clearTimeout(timeoutId); + }, [filtered.length, renderedIssueRowLimit, viewState.viewMode]); + + const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); + const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = { ...(baseCreateIssueDefaults ?? {}) }; if (projectId && defaults.projectId === undefined) defaults.projectId = projectId; @@ -578,6 +605,7 @@ export function IssuesList({ setAssigneeSearch(""); }, [onUpdateIssue]); + let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY; return (
@@ -732,7 +760,11 @@ export function IssuesList({ {isLoading && } {error &&

{error.message}

} - + {normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && ( +

+ Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further. +

+ )} {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( ) : ( - groupedContent.map((group) => ( + <> + {groupedContent.map((group) => { + if (remainingRowsToRender <= 0) return null; + return ( () }; const renderIssueRow = (issue: Issue, depth: number) => { + if (remainingRowsToRender <= 0) return null; + remainingRowsToRender -= 1; + const children = childMap.get(issue.id) ?? []; const hasChildren = children.length > 0; const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0; const isExpanded = !viewState.collapsedParents.includes(issue.id); + const useDeferredRowRendering = !(hasChildren && isExpanded); const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null; const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { @@ -810,7 +849,18 @@ export function IssuesList({ }; return ( -
0 ? { paddingLeft: `${depth * 16}px` } : undefined}> +
0 ? { paddingLeft: `${depth * 16}px` } : {}), + ...(useDeferredRowRendering + ? { + contentVisibility: "auto", + containIntrinsicSize: "44px", + } + : {}), + }} + > renderIssueRow(issue, 0)); + return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null); })()} - )) + ); + })} + {remainingIssueRowCount > 0 && ( +

+ Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues +

+ )} + )}
); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index ebbf6953cc4..c3b66c69bbd 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -99,7 +99,8 @@ describe("MarkdownBody", () => { it("sanitizes unsafe javascript markdown links", () => { const html = renderMarkdown("[click me](javascript:alert(document.cookie))"); - expect(html).toContain('click me'); + expect(html).toContain('click me"); expect(html).not.toContain("javascript:"); }); @@ -173,7 +174,7 @@ describe("MarkdownBody", () => { ]); expect(html).toContain('href="/issues/PAP-1271"'); - expect(html).toContain("PAP-1271"); + expect(html).toContain('PAP-1271'); expect(html).toContain("text-green-600"); }); @@ -192,4 +193,26 @@ describe("MarkdownBody", () => { expect(html).toContain("Depends on PAP-1271"); expect(html).toContain('href="PAP-1271"'); }); + + it("applies wrap-friendly styles to long inline content", () => { + const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken"); + + expect(html).toContain('class="paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden'); + expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"'); + expect(html).toContain(" { + const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)"); + + expect(html).toContain(' { + const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```"); + + expect(html).toContain(" ( +

+ {paragraphChildren} +

+ ), + li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => ( +
  • + {listItemChildren} +
  • + ), + blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => ( +
    + {blockquoteChildren} +
    + ), + td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => ( + + {tableCellChildren} + + ), + th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => ( + + {tableHeaderChildren} + + ), pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); if (mermaidSource) { return ; } - return
    {preChildren}
    ; + return
    {preChildren}
    ; }, - a: ({ href, children: linkChildren }) => { + code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => ( + + {codeChildren} + + ), + a: ({ href, style: linkStyle, children: linkChildren }) => { const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null; if (issueRef) { return ( @@ -181,14 +235,14 @@ export function MarkdownBody({ parsed.kind === "project" && "paperclip-project-mention-chip", )} data-mention-kind={parsed.kind} - style={mentionChipInlineStyle(parsed)} + style={{ ...mergeWrapStyle(linkStyle as React.CSSProperties | undefined), ...mentionChipInlineStyle(parsed) }} > {linkChildren}
    ); } return ( - + {linkChildren} ); @@ -213,11 +267,11 @@ export function MarkdownBody({ return (
    void; companyId: string | null | undefined; + routineName?: string | null; projects: Project[]; agents: Agent[]; defaultProjectId?: string | null; @@ -253,6 +255,9 @@ export function RoutineRunVariablesDialog({ !isPending && onOpenChange(next)}> + {routineName && ( +

    {routineName}

    + )} Run routine Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed. diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 230ab5a42db..41f635d2b3f 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -54,8 +54,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("world"); - expect(html).toContain("
  • first
  • "); - expect(html).toContain("
  • second
  • "); + expect(html).toMatch(/]*>first<\/li>/); + expect(html).toMatch(/]*>second<\/li>/); }); it("hides saved-session resume skip stderr from nice mode normalization", () => { @@ -106,8 +106,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("

    Summary

    "); - expect(html).toContain("
  • fixed deploy config
  • "); - expect(html).toContain("
  • posted issue update
  • "); + expect(html).toMatch(/]*>fixed deploy config<\/li>/); + expect(html).toMatch(/]*>posted issue update<\/li>/); expect(html).not.toContain("result"); }); }); diff --git a/ui/src/context/BreadcrumbContext.test.tsx b/ui/src/context/BreadcrumbContext.test.tsx new file mode 100644 index 00000000000..fb705dc9165 --- /dev/null +++ b/ui/src/context/BreadcrumbContext.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("BreadcrumbContext", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("does not rerender consumers when breadcrumbs are set to the same values", () => { + const renderCounts: number[] = []; + let updateBreadcrumbs: ((crumbs: Array<{ label: string; href?: string }>) => void) | null = null; + + function TestConsumer() { + const { breadcrumbs, setBreadcrumbs } = useBreadcrumbs(); + renderCounts.push(breadcrumbs.length); + updateBreadcrumbs = setBreadcrumbs; + return null; + } + + act(() => { + root.render( + + + , + ); + }); + + expect(renderCounts).toHaveLength(1); + + act(() => { + updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]); + }); + + expect(renderCounts).toHaveLength(2); + + act(() => { + updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]); + }); + + expect(renderCounts).toHaveLength(2); + }); +}); diff --git a/ui/src/context/BreadcrumbContext.tsx b/ui/src/context/BreadcrumbContext.tsx index 11d1db6ed15..0f156840922 100644 --- a/ui/src/context/BreadcrumbContext.tsx +++ b/ui/src/context/BreadcrumbContext.tsx @@ -14,12 +14,23 @@ interface BreadcrumbContextValue { const BreadcrumbContext = createContext(null); +function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index]?.label !== right[index]?.label || left[index]?.href !== right[index]?.href) { + return false; + } + } + return true; +} + export function BreadcrumbProvider({ children }: { children: ReactNode }) { const [breadcrumbs, setBreadcrumbsState] = useState([]); const [mobileToolbar, setMobileToolbarState] = useState(null); const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => { - setBreadcrumbsState(crumbs); + setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs)); }, []); const setMobileToolbar = useCallback((node: ReactNode | null) => { diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 559f80df1e9..96c5d3b98c6 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -318,6 +318,108 @@ describe("LiveUpdatesProvider issue invalidation", () => { refetchType: "inactive", }); }); + + it("refreshes visible issue run queries when the displayed run changes status", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { + return { + id: "issue-1", + identifier: "PAP-759", + assigneeAgentId: "agent-1", + }; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) { + return { + id: "run-1", + }; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.liveRuns("PAP-759"))) { + return [{ id: "run-1" }]; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.runs("PAP-759"))) { + return [{ runId: "run-1" }]; + } + return undefined; + }, + }; + + const invalidated = __liveUpdatesTestUtils.invalidateVisibleIssueRunQueries( + queryClient as never, + "/PAP/issues/PAP-759", + { + runId: "run-1", + agentId: "agent-1", + status: "succeeded", + }, + { isForegrounded: true }, + ); + + expect(invalidated).toBe(true); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.detail("PAP-759"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.activity("PAP-759"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.runs("PAP-759"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.liveRuns("PAP-759"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.activeRun("PAP-759"), + }); + }); + + it("ignores run status events for other issues", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { + return { + id: "issue-1", + identifier: "PAP-759", + assigneeAgentId: "agent-1", + }; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) { + return { + id: "run-1", + }; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.liveRuns("PAP-759"))) { + return [{ id: "run-1" }]; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.runs("PAP-759"))) { + return [{ runId: "run-1" }]; + } + return undefined; + }, + }; + + const invalidated = __liveUpdatesTestUtils.invalidateVisibleIssueRunQueries( + queryClient as never, + "/PAP/issues/PAP-759", + { + runId: "run-2", + agentId: "agent-2", + status: "succeeded", + }, + { isForegrounded: true }, + ); + + expect(invalidated).toBe(false); + expect(invalidations).toEqual([]); + }); }); describe("LiveUpdatesProvider visible issue comment hydration", () => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 1db342c0405..a146ca66f01 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -245,6 +245,30 @@ function shouldSuppressRunStatusToastForVisibleIssue( return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId; } +function invalidateVisibleIssueRunQueries( + queryClient: QueryClient, + pathname: string, + payload: Record, + options?: VisibleRouteOptions, +): boolean { + const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); + if (!context) return false; + + const runId = readString(payload.runId); + const agentId = readString(payload.agentId); + const matchesVisibleIssue = + (runId !== null && context.runIds.has(runId)) || + (!!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId); + if (!matchesVisibleIssue) return false; + + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(context.routeIssueRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(context.routeIssueRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(context.routeIssueRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(context.routeIssueRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(context.routeIssueRef) }); + return true; +} + function shouldSuppressAgentStatusToastForVisibleIssue( queryClient: QueryClient, pathname: string, @@ -735,6 +759,7 @@ function handleLiveEvent( if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") { invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload); + invalidateVisibleIssueRunQueries(queryClient, pathname, payload); if (event.type === "heartbeat.run.status") { const toast = buildRunStatusToast(payload, nameOf); if ( @@ -830,6 +855,7 @@ export const __liveUpdatesTestUtils = { closeSocketQuietly, hydrateVisibleIssueComment, invalidateActivityQueries, + invalidateVisibleIssueRunQueries, resolveLiveCompanyId, shouldDeferIssueRefetchForVisibleAgentActivity, shouldDeferVisibleIssueCommentActivity, diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 37f0fc0fd5e..3c1008b3b7d 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -12,6 +12,7 @@ import type { } from "@paperclipai/shared"; import { DEFAULT_INBOX_ISSUE_COLUMNS, + buildGroupedInboxSections, buildInboxKeyboardNavEntries, buildInboxDismissedAtByKey, computeInboxBadgeData, @@ -718,7 +719,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }).map((issue) => issue.id), ).toEqual(["remote-match"]); @@ -736,7 +737,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }), ).toEqual([]); @@ -754,12 +755,51 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }), ).toEqual([]); }); + it("keeps inbox search matches ahead of archived and other result sections", () => { + const inboxIssue = makeIssue("inbox", false); + inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); + + const archivedIssue = makeIssue("archived", false); + archivedIssue.lastActivityAt = new Date("2026-03-11T03:00:00.000Z"); + + const otherIssue = makeIssue("other", false); + otherIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z"); + + const sections = [ + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [inboxIssue], approvals: [] }), + "none", + {}, + ), + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [archivedIssue], approvals: [] }), + "none", + {}, + { keyPrefix: "archived-search:", searchSection: "archived" }, + ), + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [otherIssue], approvals: [] }), + "none", + {}, + { keyPrefix: "other-search:", searchSection: "other" }, + ), + ]; + + expect(sections.map((section) => section.searchSection)).toEqual(["none", "archived", "other"]); + expect( + sections.map((section) => { + const [item] = section.displayItems; + return item?.kind === "issue" ? item.issue.id : null; + }), + ).toEqual(["inbox", "archived", "other"]); + }); + it("defaults the remembered inbox tab to mine and persists all", () => { localStorage.clear(); expect(loadLastInboxTab()).toBe("mine"); @@ -779,7 +819,7 @@ describe("inbox helpers", () => { labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: true, + hideRoutineExecutions: false, }, }); saveInboxFilterPreferences("company-2", { @@ -792,7 +832,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }); @@ -806,7 +846,7 @@ describe("inbox helpers", () => { labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: true, + hideRoutineExecutions: false, }, }); expect(loadInboxFilterPreferences("company-2")).toEqual({ @@ -819,7 +859,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }); }); @@ -835,7 +875,7 @@ describe("inbox helpers", () => { labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], - showRoutineExecutions: "yes", + hideRoutineExecutions: "yes", }, })); @@ -849,7 +889,7 @@ describe("inbox helpers", () => { labels: [], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: false, + hideRoutineExecutions: false, }, }); }); @@ -1003,12 +1043,12 @@ describe("inbox helpers", () => { expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); }); - it("hides routine execution issues until the toggle is enabled", () => { + it("hides routine execution issues when the hide toggle is enabled", () => { const manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const }; const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const }; - expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue]); - expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue, routineIssue]); + expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue, routineIssue]); + expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue]); }); it("groups mixed inbox items by type while preserving item order within each group", () => { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 9060d0a2026..43f116451de 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -87,6 +87,16 @@ export interface InboxWorkItemGroup { items: InboxWorkItem[]; } +export type InboxSearchSection = "none" | "archived" | "other"; + +export interface InboxGroupedSection { + key: string; + label: string | null; + displayItems: InboxWorkItem[]; + childrenByIssueId: Map; + searchSection: InboxSearchSection; +} + export interface InboxKeyboardGroupSection { key: string; displayItems: InboxWorkItem[]; @@ -142,7 +152,7 @@ function normalizeIssueFilterState(value: unknown): IssueFilterState { labels: normalizeStringArray(candidate.labels), projects: normalizeStringArray(candidate.projects), workspaces: normalizeStringArray(candidate.workspaces), - showRoutineExecutions: candidate.showRoutineExecutions === true, + hideRoutineExecutions: candidate.hideRoutineExecutions === true, }; } @@ -367,14 +377,14 @@ export function shouldResetInboxWorkspaceGrouping( export function shouldIncludeRoutineExecutionIssue( issue: Pick, - showRoutineExecutions: boolean, + hideRoutineExecutions: boolean, ): boolean { - return showRoutineExecutions || issue.originKind !== "routine_execution"; + return !hideRoutineExecutions || issue.originKind !== "routine_execution"; } -export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolean): Issue[] { - if (showRoutineExecutions) return issues; - return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions)); +export function filterInboxIssues(issues: Issue[], hideRoutineExecutions: boolean): Issue[] { + if (!hideRoutineExecutions) return issues; + return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, hideRoutineExecutions)); } export function matchesInboxIssueSearch( @@ -916,6 +926,31 @@ export function buildInboxNesting(items: InboxWorkItem[]): { return { displayItems, childrenByIssueId }; } +export function buildGroupedInboxSections( + items: InboxWorkItem[], + groupBy: InboxWorkItemGroupBy, + workspaceGrouping: InboxWorkspaceGroupingOptions, + options?: { keyPrefix?: string; searchSection?: InboxSearchSection; nestingEnabled?: boolean }, +): InboxGroupedSection[] { + const keyPrefix = options?.keyPrefix ?? ""; + const searchSection = options?.searchSection ?? "none"; + const nestingEnabled = options?.nestingEnabled ?? false; + + return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => { + const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue") + ? buildInboxNesting(group.items) + : { displayItems: group.items, childrenByIssueId: new Map() }; + + return { + key: `${keyPrefix}${group.key}`, + label: group.label, + displayItems: nestedGroup.displayItems, + childrenByIssueId: nestedGroup.childrenByIssueId, + searchSection, + }; + }); +} + export function getInboxWorkItemKey(item: InboxWorkItem): string { if (item.kind === "issue") return `issue:${item.issue.id}`; if (item.kind === "approval") return `approval:${item.approval.id}`; diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index 46a77e07273..c9ec4a5bade 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -7,7 +7,7 @@ export type IssueFilterState = { labels: string[]; projects: string[]; workspaces: string[]; - showRoutineExecutions: boolean; + hideRoutineExecutions: boolean; }; export const defaultIssueFilterState: IssueFilterState = { @@ -17,7 +17,7 @@ export const defaultIssueFilterState: IssueFilterState = { labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: false, }; export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; @@ -58,7 +58,7 @@ export function applyIssueFilters( enableRoutineVisibilityFilter = false, ): Issue[] { let result = issues; - if (enableRoutineVisibilityFilter && !state.showRoutineExecutions) { + if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) { result = result.filter((issue) => issue.originKind !== "routine_execution"); } if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status)); @@ -99,6 +99,6 @@ export function countActiveIssueFilters( if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; - if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1; + if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1; return count; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index f7c47b45c75..6c67cbd0a1a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -65,6 +65,8 @@ export const queryKeys = { executionWorkspaces: { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, + summaryList: (companyId: string, filters?: Record) => + ["execution-workspaces", companyId, "summary", filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff3d22fa441..6cf23a8b7da 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -97,8 +97,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, + buildGroupedInboxSections, buildInboxKeyboardNavEntries, - buildInboxNesting, getAvailableInboxIssueColumns, getInboxWorkItemKey, getApprovalsForTab, @@ -109,7 +109,6 @@ import { getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, - groupInboxWorkItems, isInboxEntityDismissed, isMineInboxTab, loadCollapsedInboxGroupKeys, @@ -135,6 +134,7 @@ import { type InboxKeyboardNavEntry, saveLastInboxTab, shouldShowInboxSection, + type InboxGroupedSection, type InboxTab, type InboxWorkItem, type InboxWorkItemGroupBy, @@ -150,38 +150,6 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; -type InboxGroupedSection = { - key: string; - label: string | null; - displayItems: InboxWorkItem[]; - childrenByIssueId: Map; - isArchivedSearch: boolean; -}; - -function buildGroupedInboxSections( - items: InboxWorkItem[], - groupBy: InboxWorkItemGroupBy, - nestingEnabled: boolean, - workspaceGrouping: InboxWorkspaceGroupingOptions, - options?: { keyPrefix?: string; isArchivedSearch?: boolean }, -): InboxGroupedSection[] { - const keyPrefix = options?.keyPrefix ?? ""; - const isArchivedSearch = options?.isArchivedSearch ?? false; - return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => { - const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue") - ? buildInboxNesting(group.items) - : { displayItems: group.items, childrenByIssueId: new Map() }; - - return { - key: `${keyPrefix}${group.key}`, - label: group.label, - displayItems: nestedGroup.displayItems, - childrenByIssueId: nestedGroup.childrenByIssueId, - isArchivedSearch, - }; - }); -} - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -1081,19 +1049,12 @@ export function Inbox() { remoteIssueSearchResults, ], ); - const effectiveWorkItems = useMemo( - () => - issueSearchSupplementResults.length > 0 - ? [ - ...filteredWorkItems, - ...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), - ] - : filteredWorkItems, - [filteredWorkItems, issueSearchSupplementResults], - ); - const archivedSearchIssueIds = useMemo( - () => new Set(archivedSearchIssues.map((issue) => issue.id)), - [archivedSearchIssues], + const nonInboxSearchIssueIds = useMemo( + () => new Set([ + ...archivedSearchIssues.map((issue) => issue.id), + ...issueSearchSupplementResults.map((issue) => issue.id), + ]), + [archivedSearchIssues, issueSearchSupplementResults], ); // --- Parent-child nesting for inbox issues --- @@ -1123,15 +1084,27 @@ export function Inbox() { }); }, [selectedCompanyId]); const groupedSections = useMemo(() => [ - ...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled, inboxWorkspaceGrouping), + ...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }), ...buildGroupedInboxSections( getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }), groupBy, - nestingEnabled, inboxWorkspaceGrouping, - { keyPrefix: "archived-search:", isArchivedSearch: true }, + { keyPrefix: "archived-search:", searchSection: "archived", nestingEnabled }, ), - ], [archivedSearchIssues, effectiveWorkItems, groupBy, inboxWorkspaceGrouping, nestingEnabled]); + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), + groupBy, + inboxWorkspaceGrouping, + { keyPrefix: "other-search:", searchSection: "other", nestingEnabled }, + ), + ], [ + archivedSearchIssues, + filteredWorkItems, + groupBy, + inboxWorkspaceGrouping, + issueSearchSupplementResults, + nestingEnabled, + ]); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections], @@ -1500,7 +1473,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1513,7 +1486,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1616,10 +1589,10 @@ export function Inbox() { e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { - if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); + if (!st.nonInboxSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); } else if (item) { if (item.kind === "issue") { - if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) { + if (!st.nonInboxSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) { act.archiveIssue(item.issue.id); } } else { @@ -2113,15 +2086,18 @@ export function Inbox() { return groupedSections.flatMap((group, groupIndex) => { const elements: ReactNode[] = []; const isGroupCollapsed = collapsedGroupKeys.has(group.key); - if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) { + if ( + group.searchSection !== "none" + && group.searchSection !== groupedSections[groupIndex - 1]?.searchSection + ) { elements.push(
    - Archived + {group.searchSection === "archived" ? "Archived" : "Other results"}
    , @@ -2292,7 +2268,7 @@ export function Inbox() { const childIssues = group.childrenByIssueId.get(issue.id) ?? []; const hasChildren = childIssues.length > 0; const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); - const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch; + const canArchiveIssue = canArchiveFromTab && group.searchSection === "none"; const parentRow = renderInboxIssue({ issue, depth: 0, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index de2a390db0a..4a6f2269062 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; @@ -488,7 +488,10 @@ function InboxMobileToolbar({ type IssueDetailChatTabProps = { issueId: string; - issue: Issue; + companyId: string; + projectId: string | null; + issueStatus: Issue["status"]; + executionRunId: string | null; comments: IssueDetailComment[]; hasOlderComments: boolean; commentsLoadingOlder: boolean; @@ -519,9 +522,12 @@ type IssueDetailChatTabProps = { onImageClick: (src: string) => void; }; -function IssueDetailChatTab({ +const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, - issue, + companyId, + projectId, + issueStatus, + executionRunId, comments, hasOlderComments, commentsLoadingOlder, @@ -547,59 +553,62 @@ function IssueDetailChatTab({ interruptingQueuedRunId, onImageClick, }: IssueDetailChatTabProps) { - const { data: activity, isLoading: activityLoading } = useQuery({ + const { data: activity } = useQuery({ queryKey: queryKeys.issues.activity(issueId), queryFn: () => activityApi.forIssue(issueId), placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ + const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), refetchInterval: 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const liveRunCount = liveRuns?.length ?? 0; - const { data: activeRun, isLoading: activeRunLoading } = useQuery({ + const resolvedLiveRuns = liveRuns ?? []; + const liveRunCount = resolvedLiveRuns.length; + const { data: activeRun = null } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId), - enabled: !!issue.executionRunId || issue.status === "in_progress", + enabled: !!executionRunId || issueStatus === "in_progress", refetchInterval: liveRunCount > 0 ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const hasLiveRuns = liveRunCount > 0 || !!activeRun; - const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), refetchInterval: hasLiveRuns ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); + const resolvedActivity = activity ?? []; + const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( - () => resolveRunningIssueRun(activeRun, liveRuns), - [activeRun, liveRuns], + () => resolveRunningIssueRun(activeRun, resolvedLiveRuns), + [activeRun, resolvedLiveRuns], ); const timelineRuns = useMemo(() => { const liveIds = new Set(); - for (const run of liveRuns ?? []) liveIds.add(run.id); + for (const run of resolvedLiveRuns) liveIds.add(run.id); if (activeRun) liveIds.add(activeRun.id); const historicalRuns = liveIds.size === 0 - ? (linkedRuns ?? []) - : (linkedRuns ?? []).filter((run) => !liveIds.has(run.runId)); + ? resolvedLinkedRuns + : resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId)); return historicalRuns.map((run) => ({ ...run, adapterType: run.adapterType, hasStoredOutput: (run.logBytes ?? 0) > 0, })); - }, [activeRun, linkedRuns, liveRuns]); + }, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]); const commentsWithRunMeta = useMemo(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); const agentIdByRunId = new Map(); - for (const run of linkedRuns ?? []) { + for (const run of resolvedLinkedRuns) { agentIdByRunId.set(run.runId, run.agentId); } - for (const evt of activity ?? []) { + for (const evt of resolvedActivity) { if (evt.action !== "issue.comment_added" || !evt.runId) continue; const details = evt.details ?? {}; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; @@ -633,20 +642,11 @@ function IssueDetailChatTab({ } return nextComment; }); - }, [activity, comments, linkedRuns, runningIssueRun]); + }, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]); const timelineEvents = useMemo( - () => extractIssueTimelineEvents(activity), - [activity], + () => extractIssueTimelineEvents(resolvedActivity), + [resolvedActivity], ); - const initialLoading = - (activityLoading && activity === undefined) - || (linkedRunsLoading && linkedRuns === undefined) - || (liveRunsLoading && liveRuns === undefined) - || (activeRunLoading && activeRun === undefined); - - if (initialLoading) { - return ; - } return (
    @@ -671,11 +671,11 @@ function IssueDetailChatTab({ feedbackTermsUrl={feedbackTermsUrl} linkedRuns={timelineRuns} timelineEvents={timelineEvents} - liveRuns={liveRuns} + liveRuns={resolvedLiveRuns} activeRun={activeRun} - companyId={issue.companyId} - projectId={issue.projectId} - issueStatus={issue.status} + companyId={companyId} + projectId={projectId} + issueStatus={issueStatus} agentMap={agentMap} currentUserId={currentUserId} draftKey={draftKey} @@ -703,7 +703,7 @@ function IssueDetailChatTab({ />
    ); -} +}); type IssueDetailActivityTabProps = { issueId: string; @@ -1060,6 +1060,14 @@ export function IssueDetail() { () => buildIssuePropertiesPanelKey(issue ?? null, childIssues), [childIssues, issue], ); + const panelIssue = useMemo( + () => issue ?? null, + [issue?.id, issuePanelKey], + ); + const panelChildIssues = useMemo( + () => childIssues, + [issuePanelKey], + ); const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length); const openNewSubIssue = useCallback(() => { if (!issue) return; @@ -1103,6 +1111,7 @@ export function IssueDetail() { () => mergeIssueComments(comments ?? [], optimisticComments), [comments, optimisticComments], ); + const breadcrumbTitle = issue?.title ?? issueId ?? "Issue"; const invalidateIssueDetail = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); @@ -1743,12 +1752,17 @@ export function IssueDetail() { }); useEffect(() => { - const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ sourceBreadcrumb, - { label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel }, + { label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle }, ]); - }, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]); + }, [ + breadcrumbTitle, + hasLiveRuns, + setBreadcrumbs, + sourceBreadcrumb.href, + sourceBreadcrumb.label, + ]); const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox"; @@ -1790,20 +1804,28 @@ export function IssueDetail() { }, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (!issue) { + if (!panelIssue) { closePanel(); return; } openPanel( ); return () => closePanel(); - }, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]); + }, [ + closePanel, + handleIssuePropertiesUpdate, + issuePanelKey, + openNewSubIssue, + openPanel, + panelChildIssues, + panelIssue, + ]); const goToInboxShortcutArmedRef = useRef(false); const goToInboxShortcutTimeoutRef = useRef(null); @@ -2032,6 +2054,36 @@ export function IssueDetail() { }, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; + const loadOlderComments = useCallback(() => { + void fetchOlderComments(); + }, [fetchOlderComments]); + const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_comment", + targetId: commentId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }, [feedbackDataSharingPreference, feedbackVoteMutation]); + const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { + if (reassignment) { + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + return; + } + await addComment.mutateAsync({ body, reopen }); + }, [addComment, addCommentAndReassign]); + const handleCommentImageUpload = useCallback(async (file: File) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }, [uploadAttachment]); + const handleCommentAttachImage = useCallback(async (file: File) => { + await uploadAttachment.mutateAsync(file); + }, [uploadAttachment]); + const handleInterruptQueuedRun = useCallback(async (runId: string) => { + await interruptQueuedComment.mutateAsync(runId); + }, [interruptQueuedComment]); if (isLoading) return ; if (error) return

    {error.message}

    ; @@ -2557,13 +2609,14 @@ export function IssueDetail() { {detailTab === "chat" ? ( { - void fetchOlderComments(); - }} + onLoadOlderComments={loadOlderComments} composerRef={commentComposerRef} feedbackVotes={feedbackVotes} feedbackDataSharingPreference={feedbackDataSharingPreference} @@ -2576,33 +2629,11 @@ export function IssueDetail() { suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} composerDisabledReason={commentComposerDisabledReason} - onVote={async (commentId, vote, options) => { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }} - onAdd={async (body, reopen, reassignment) => { - if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); - return; - } - await addComment.mutateAsync({ body, reopen }); - }} - onImageUpload={async (file) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }} - onAttachImage={async (file) => { - await uploadAttachment.mutateAsync(file); - }} - onInterruptQueued={async (runId) => { - await interruptQueuedComment.mutateAsync(runId); - }} + onVote={handleCommentVote} + onAdd={handleChatAdd} + onImageUpload={handleCommentImageUpload} + onAttachImage={handleCommentAttachImage} + onInterruptQueued={handleInterruptQueuedRun} onCancelQueued={handleCancelQueuedComment} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onImageClick={handleChatImageClick} diff --git a/ui/src/pages/Issues.test.tsx b/ui/src/pages/Issues.test.tsx new file mode 100644 index 00000000000..1234242baec --- /dev/null +++ b/ui/src/pages/Issues.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { buildIssuesSearchUrl } from "./Issues"; + +describe("buildIssuesSearchUrl", () => { + it("preserves trailing spaces in the synced search param", () => { + expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug", "bug ")).toBe("/issues?q=bug+"); + }); + + it("removes the search param when the input is cleared", () => { + expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug#details", "")).toBe("/issues#details"); + }); + + it("returns null when the URL already matches the current search", () => { + expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull(); + }); +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index e1ecffc456f..90db2baec61 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -13,6 +13,20 @@ import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; +export function buildIssuesSearchUrl(currentHref: string, search: string): string | null { + const url = new URL(currentHref); + const currentSearch = url.searchParams.get("q") ?? ""; + if (currentSearch === search) return null; + + if (search.length > 0) { + url.searchParams.set("q", search); + } else { + url.searchParams.delete("q"); + } + + return `${url.pathname}${url.search}${url.hash}`; +} + export function Issues() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -23,18 +37,8 @@ export function Issues() { const initialSearch = searchParams.get("q") ?? ""; const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const handleSearchChange = useCallback((search: string) => { - const trimmedSearch = search.trim(); - const currentSearch = new URLSearchParams(window.location.search).get("q") ?? ""; - if (currentSearch === trimmedSearch) return; - - const url = new URL(window.location.href); - if (trimmedSearch) { - url.searchParams.set("q", trimmedSearch); - } else { - url.searchParams.delete("q"); - } - - const nextUrl = `${url.pathname}${url.search}${url.hash}`; + const nextUrl = buildIssuesSearchUrl(window.location.href, search); + if (!nextUrl) return; window.history.replaceState(window.history.state, "", nextUrl); }, []); diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 1ff17a2547e..de6a0099ef5 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -1114,6 +1114,7 @@ export function RoutineDetail() { open={runVariablesOpen} onOpenChange={setRunVariablesOpen} companyId={routine.companyId} + routineName={routine.title} agents={agents ?? []} projects={projects ?? []} defaultProjectId={routine.projectId} diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index f36adeba121..d86dceefcad 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -972,6 +972,7 @@ export function Routines() { if (!next) setRunDialogRoutine(null); }} companyId={selectedCompanyId} + routineName={runDialogRoutine?.title ?? null} agents={agents ?? []} projects={projects ?? []} defaultProjectId={runDialogRoutine?.projectId ?? null} From 5f457128463cbdc9d2282dacb3dfa44bc99fceb1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:13:56 -0500 Subject: [PATCH 15/16] Sync/master post pap1497 followups 2026 04 15 (#3779) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board depends on issue, inbox, cost, and company-skill surfaces to stay accurate and fast while agents are actively working > - The PAP-1497 follow-up branch exposed a few rough edges in those surfaces: stale active-run state on completed issues, missing creator filters, oversized issue payload scans, and placeholder issue-route parsing > - Those gaps make the control plane harder to trust because operators can see misleading run state, miss the right subset of work, or pay extra query/render cost on large issue records > - This pull request tightens those follow-ups across server and UI code, and adds regression coverage for the affected paths > - The benefit is a more reliable issue workflow, safer high-volume cost aggregation, and clearer board/operator navigation ## What Changed - Added the `v2026.415.0` release changelog entry. - Fixed stale issue-run presentation after completion and reused the shared issue-path parser so literal route placeholders no longer become issue links. - Added creator filters to the Issues page and Inbox, including persisted filter-state normalization and regression coverage. - Bounded issue detail/list project-mention scans and trimmed large issue-list payload fields to keep issue reads lighter. - Hardened company-skill list projection and cost/finance aggregation so large markdown blobs and large summed values do not leak into list responses or overflow 32-bit casts. - Added targeted server/UI regression tests for company skills, costs/finance, issue mention scanning, creator filters, inbox normalization, and issue reference parsing. ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/costs-service.test.ts server/src/__tests__/issues-goal-context-routes.test.ts server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts` - `gh pr checks 3779` Current pass set on the PR head: `policy`, `verify`, `e2e`, `security/snyk (cryppadotta)`, `Greptile Review` ## Risks - Creator filter options are derived from the currently loaded issue/agent data, so very sparse result sets may not surface every historical creator until they appear in the active dataset. - Cost/finance aggregate casts now use `double precision`; that removes the current overflow risk, but future schema changes should keep large-value aggregation behavior under review. - Issue detail mention scanning now skips comment-body scans on the detail route, so any consumer that relied on comment-only project mentions there would need to fetch them separately. ## Model Used - OpenAI Codex, GPT-5-based coding agent with terminal tool use and local code execution in the Paperclip workspace. Exact internal model ID/context-window exposure is 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 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 --- releases/v2026.415.0.md | 27 +++ .../__tests__/company-skills-service.test.ts | 92 ++++++++++ server/src/__tests__/costs-service.test.ts | 160 ++++++++++++++++++ .../issues-goal-context-routes.test.ts | 4 + server/src/__tests__/issues-service.test.ts | 115 +++++++++++++ server/src/log-redaction.ts | 2 + server/src/routes/issues.ts | 2 +- server/src/services/agents.ts | 2 +- server/src/services/budgets.ts | 2 +- server/src/services/companies.ts | 8 +- server/src/services/company-skills.ts | 104 +++++++++++- server/src/services/costs.ts | 82 ++++----- server/src/services/dashboard.ts | 2 +- server/src/services/finance.ts | 14 +- server/src/services/issues.ts | 78 +++++++-- ui/src/components/IssueFiltersPopover.tsx | 100 ++++++++++- ui/src/components/IssuesList.tsx | 74 +++++++- ui/src/lib/inbox.test.ts | 9 + ui/src/lib/inbox.ts | 22 +-- ui/src/lib/issue-filters.test.ts | 69 ++++++++ ui/src/lib/issue-filters.ts | 32 ++++ ui/src/lib/issue-reference.test.ts | 12 ++ ui/src/lib/issue-reference.ts | 4 +- ui/src/lib/router.tsx | 7 +- ui/src/pages/Inbox.tsx | 68 ++++++++ ui/src/pages/IssueDetail.tsx | 15 +- 26 files changed, 998 insertions(+), 108 deletions(-) create mode 100644 releases/v2026.415.0.md create mode 100644 server/src/__tests__/company-skills-service.test.ts create mode 100644 ui/src/lib/issue-filters.test.ts diff --git a/releases/v2026.415.0.md b/releases/v2026.415.0.md new file mode 100644 index 00000000000..2286554a731 --- /dev/null +++ b/releases/v2026.415.0.md @@ -0,0 +1,27 @@ +# v2026.415.0 + +> Released: 2026-04-15 + +## Highlights + +- **Faster issues page first paint** — The issues list now renders the initial view significantly faster by deferring non-critical work and optimizing workspace lookups. +- **Issue detail performance** — Reduced unnecessary rerenders on the issue detail page and kept queued issue chat mounted to avoid layout thrash during agent runs. +- **Inbox search expansion** — Added an "Other results" section to inbox search, surfacing matches beyond the current filter scope. + +## Improvements + +- **Properties pane polish** — Workspace link, copy-on-click for identifiers, and an inline parent navigation arrow in the issue properties sidebar. +- **Routine UX** — Routine name now appears above the "Run routine" title in the run dialog; routine execution issues are shown in issue lists by default. +- **Filter label clarity** — Renamed the routine-run filter to "Hide routine runs" so the default state shows no active filter badge. +- **Stranded issue diagnostics** — Stranded issue comments now include the latest run failure message for faster triage. +- **Issues search responsiveness** — Debounce and rendering improvements make the issues page search feel snappier. +- **Live run refresh** — Visible issue runs now refresh automatically on status updates without a manual reload. +- **Heartbeat payload hygiene** — Raw `result_json` writes are preserved exactly as received; payloads are bounded to prevent oversized records. +- **Heartbeat log scoping** — Narrowed heartbeat log endpoint lookups to reduce query overhead. +- **Vite dev watch** — UI test files are now excluded from the Vite dev watcher, reducing unnecessary rebuilds during development. + +## Fixes + +- **Self-comment code block scrolling** — Fixed horizontal scrolling for code blocks inside an agent's own comment thread. +- **Markdown long-string wrapping** — Long unbroken strings in markdown comments now wrap correctly instead of overflowing the container. +- **Dev asset serving order** — Fixed dev mode to serve public assets before the HTML shell, preventing 404s on static files during local development. diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 00000000000..b2f54f92862 --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 5ee13e4c06e..83e4e87b703 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { randomUUID } from "node:crypto"; +import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -230,3 +239,154 @@ describe("cost routes", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = 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: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index f7382e8b9a3..c20040840d0 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -190,6 +190,10 @@ describe("issue goal context routes", () => { title: projectGoal.title, }), ); + expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { includeCommentBodies: false }, + ); expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0629686a154..75aefd6a364 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -22,6 +22,7 @@ import { } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; +import { buildProjectMentionHref } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -697,6 +698,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { @@ -1182,3 +1216,84 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); }); + +describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("can skip comment-body scans for bounded issue detail reads", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const titleProjectId = randomUUID(); + const commentProjectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values([ + { + id: titleProjectId, + companyId, + name: "Title project", + status: "in_progress", + }, + { + id: commentProjectId, + companyId, + name: "Comment project", + status: "in_progress", + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`, + }); + + expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]); + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + titleProjectId, + commentProjectId, + ]); + }); +}); diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts index ab59b3e414a..952d1553ad1 100644 --- a/server/src/log-redaction.ts +++ b/server/src/log-redaction.ts @@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction let result = input; for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + if (!result.includes(homeDir)) continue; const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const replacementDir = lastSegment ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement)) @@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction } for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + if (!result.includes(userName)) continue; const pattern = new RegExp(`(?`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 3e7d8b4e6cb..48575438386 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index dcdeb27dd3c..89678572ca8 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b40f5..0985c2b6226 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1159,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readPaperclipSkillSyncPreference(config); @@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 76a90f2dad6..88825b498bd 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index e495208d9cd..c1169aa98ca 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -65,7 +65,7 @@ export function dashboardService(db: Db) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 29dcec494ab..766bea7b1f3 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e18cd7c359a..5d045e47ae0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -132,6 +132,7 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); @@ -527,6 +528,51 @@ async function activeRunMapForIssues( return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -1005,7 +1051,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -2350,7 +2396,10 @@ export function issueService(db: Db) { return [...resolved]; }, - findMentionedProjectIds: async (issueId: string) => { + findMentionedProjectIds: async ( + issueId: string, + opts?: { includeCommentBodies?: boolean }, + ) => { const issue = await db .select({ companyId: issues.companyId, @@ -2362,21 +2411,26 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) return []; - const comments = await db - .select({ body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const mentionedIds = new Set(); - for (const source of [ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]) { + for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } + + if (opts?.includeCommentBodies !== false) { + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + for (const comment of comments) { + for (const projectId of extractProjectMentionIds(comment.body)) { + mentionedIds.add(projectId); + } + } + } + if (mentionedIds.size === 0) return []; const rows = await db diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 321d9b1d227..2702ea0c7e5 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,10 @@ +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User, HardDrive } from "lucide-react"; +import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -14,6 +17,7 @@ import { toggleIssueFilterValue, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; type AgentOption = { id: string; @@ -36,6 +40,13 @@ type WorkspaceOption = { name: string; }; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -48,6 +59,7 @@ export function IssueFiltersPopover({ buttonVariant = "ghost", iconOnly = false, workspaces, + creators, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -60,7 +72,39 @@ export function IssueFiltersPopover({ buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; workspaces?: WorkspaceOption[]; + creators?: CreatorOption[]; }) { + const [creatorSearch, setCreatorSearch] = useState(""); + const creatorOptions = creators ?? []; + const creatorOptionById = useMemo( + () => new Map(creatorOptions.map((option) => [option.id, option])), + [creatorOptions], + ); + const normalizedCreatorSearch = creatorSearch.trim().toLowerCase(); + const visibleCreatorOptions = useMemo(() => { + if (!normalizedCreatorSearch) return creatorOptions; + return creatorOptions.filter((option) => + `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalizedCreatorSearch), + ); + }, [creatorOptions, normalizedCreatorSearch]); + const selectedCreatorOptions = useMemo( + () => state.creators.map((creatorId) => { + const knownOption = creatorOptionById.get(creatorId); + if (knownOption) return knownOption; + if (creatorId.startsWith("agent:")) { + const agentId = creatorId.slice("agent:".length); + return { id: creatorId, label: agentId.slice(0, 8), kind: "agent" as const }; + } + const userId = creatorId.startsWith("user:") ? creatorId.slice("user:".length) : creatorId; + return { + id: creatorId, + label: formatAssigneeUserLabel(userId, currentUserId) ?? userId.slice(0, 5), + kind: "user" as const, + }; + }), + [creatorOptionById, currentUserId, state.creators], + ); + return ( @@ -191,6 +235,60 @@ export function IssueFiltersPopover({
    + {creatorOptions.length > 0 ? ( +
    + Creator + {selectedCreatorOptions.length > 0 ? ( +
    + {selectedCreatorOptions.map((creator) => ( + + {creator.kind === "agent" ? : } + {creator.label} + + + ))} +
    + ) : null} +
    + + setCreatorSearch(event.target.value)} + placeholder="Search creators..." + className="h-8 pl-7 text-xs" + /> +
    +
    + {visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => { + const selected = state.creators.includes(creator.id); + return ( + + ); + }) : ( +
    No creators match.
    + )} +
    +
    + ) : null} + {projects && projects.length > 0 ? (
    Project diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1f8ebcdcba7..45d3f188a37 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -19,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, @@ -86,7 +87,10 @@ const defaultViewState: IssueViewState = { function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); - if (raw) return { ...defaultViewState, ...JSON.parse(raw) }; + if (raw) { + const parsed = JSON.parse(raw); + return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) }; + } } catch { /* ignore */ } return { ...defaultViewState }; } @@ -161,6 +165,13 @@ interface Agent { name: string; } +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; @@ -432,6 +443,66 @@ export function IssuesList({ .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); + const creatorOptions = useMemo(() => { + const options = new Map(); + const knownAgentIds = new Set(); + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of issues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of issues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, issues]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -671,6 +742,7 @@ export function IssuesList({ onChange={updateView} activeFilterCount={activeFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3c1008b3b7d..834c262c890 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -716,6 +716,7 @@ describe("inbox helpers", () => { statuses: ["in_progress"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -734,6 +735,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -752,6 +754,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -816,6 +819,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -829,6 +833,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -843,6 +848,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -856,6 +862,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -872,6 +879,7 @@ describe("inbox helpers", () => { statuses: ["todo", 123], priorities: "high", assignees: ["agent-1"], + creators: ["user:user-1", 42], labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], @@ -886,6 +894,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: [], assignees: ["agent-1"], + creators: ["user:user-1"], labels: [], projects: ["project-1"], workspaces: ["workspace-1"], diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 43f116451de..6b66f66f2d2 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -9,6 +9,7 @@ import type { import { applyIssueFilters, defaultIssueFilterState, + normalizeIssueFilterState, type IssueFilterState, } from "./issue-filters"; @@ -137,25 +138,6 @@ const defaultInboxFilterPreferences: InboxFilterPreferences = { issueFilters: defaultIssueFilterState, }; -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -function normalizeIssueFilterState(value: unknown): IssueFilterState { - if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; - const candidate = value as Partial>; - return { - statuses: normalizeStringArray(candidate.statuses), - priorities: normalizeStringArray(candidate.priorities), - assignees: normalizeStringArray(candidate.assignees), - labels: normalizeStringArray(candidate.labels), - projects: normalizeStringArray(candidate.projects), - workspaces: normalizeStringArray(candidate.workspaces), - hideRoutineExecutions: candidate.hideRoutineExecutions === true, - }; -} - function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter { return value === "issues_i_touched" || value === "join_requests" @@ -244,7 +226,7 @@ export function loadCollapsedInboxGroupKeys( const raw = localStorage.getItem(storageKey); if (!raw) return new Set(); const parsed = JSON.parse(raw); - return new Set(normalizeStringArray(parsed)); + return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []); } catch { return new Set(); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts new file mode 100644 index 00000000000..523a2222d6e --- /dev/null +++ b/ui/src/lib/issue-filters.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters"; + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + labelIds: [], + createdAt: new Date("2026-04-15T00:00:00.000Z"), + updatedAt: new Date("2026-04-15T00:00:00.000Z"), + ...overrides, + }; +} + +describe("issue filters", () => { + it("filters issues by creator across agents and users", () => { + const issues = [ + makeIssue({ id: "agent-match", createdByAgentId: "agent-1" }), + makeIssue({ id: "user-match", createdByUserId: "user-1" }), + makeIssue({ id: "excluded", createdByAgentId: "agent-2", createdByUserId: "user-2" }), + ]; + + const filtered = applyIssueFilters(issues, { + ...defaultIssueFilterState, + creators: ["agent:agent-1", "user:user-1"], + }); + + expect(filtered.map((issue) => issue.id)).toEqual(["agent-match", "user-match"]); + }); + + it("counts creator filters as an active filter group", () => { + expect(countActiveIssueFilters({ + ...defaultIssueFilterState, + creators: ["user:user-1"], + })).toBe(1); + }); +}); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index c9ec4a5bade..4a64b839f3f 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -4,6 +4,7 @@ export type IssueFilterState = { statuses: string[]; priorities: string[]; assignees: string[]; + creators: string[]; labels: string[]; projects: string[]; workspaces: string[]; @@ -14,6 +15,7 @@ export const defaultIssueFilterState: IssueFilterState = { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -41,6 +43,26 @@ export function issueFilterArraysEqual(a: string[], b: string[]): boolean { return sortedA.every((value, index) => value === sortedB[index]); } +function normalizeIssueFilterValueArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +export function normalizeIssueFilterState(value: unknown): IssueFilterState { + if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; + const candidate = value as Partial>; + return { + statuses: normalizeIssueFilterValueArray(candidate.statuses), + priorities: normalizeIssueFilterValueArray(candidate.priorities), + assignees: normalizeIssueFilterValueArray(candidate.assignees), + creators: normalizeIssueFilterValueArray(candidate.creators), + labels: normalizeIssueFilterValueArray(candidate.labels), + projects: normalizeIssueFilterValueArray(candidate.projects), + workspaces: normalizeIssueFilterValueArray(candidate.workspaces), + hideRoutineExecutions: candidate.hideRoutineExecutions === true, + }; +} + export function toggleIssueFilterValue(values: string[], value: string): string[] { return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value]; } @@ -73,6 +95,15 @@ export function applyIssueFilters( return false; }); } + if (state.creators.length > 0) { + result = result.filter((issue) => { + for (const creator of state.creators) { + if (creator.startsWith("agent:") && issue.createdByAgentId === creator.slice("agent:".length)) return true; + if (creator.startsWith("user:") && issue.createdByUserId === creator.slice("user:".length)) return true; + } + return false; + }); + } if (state.labels.length > 0) { result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id))); } @@ -96,6 +127,7 @@ export function countActiveIssueFilters( if (state.statuses.length > 0) count += 1; if (state.priorities.length > 0) count += 1; if (state.assignees.length > 0) count += 1; + if (state.creators.length > 0) count += 1; if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index a7c7d4c768f..d1d613e0325 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -5,12 +5,19 @@ describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); it("extracts issue ids from full issue URLs", () => { expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179"); }); + it("ignores placeholder issue paths", () => { + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); + expect(parseIssuePathIdFromPath("http://localhost:3100/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + }); + it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => { expect(parseIssueReferenceFromHref("pap-1271")).toEqual({ issuePathId: "PAP-1271", @@ -36,4 +43,9 @@ describe("issue-reference", () => { href: "/issues/PAP-1271", }); }); + + it("ignores literal route placeholder paths", () => { + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull(); + }); }); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 18de5a32440..25b4a4a27fd 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -25,7 +25,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): const segments = pathname.split("/").filter(Boolean); const issueIndex = segments.findIndex((segment) => segment === "issues"); if (issueIndex === -1 || issueIndex === segments.length - 1) return null; - return decodeURIComponent(segments[issueIndex + 1] ?? ""); + const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); + if (!issuePathId || issuePathId.startsWith(":")) return null; + return issuePathId; } export function parseIssueReferenceFromHref(href: string | null | undefined) { diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index c8d3a352d38..0ec23ede33c 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -9,12 +9,7 @@ import { extractCompanyPrefixFromPath, normalizeCompanyPrefix, } from "@/lib/company-routes"; - -function parseIssuePathIdFromPath(pathname: string | null | undefined): string | null { - if (!pathname) return null; - const match = pathname.match(/(?:^|\/)issues\/([^/?#]+)/); - return match?.[1] ?? null; -} +import { parseIssuePathIdFromPath } from "@/lib/issue-reference"; function resolveTo(to: To, companyPrefix: string | null): To { if (typeof to === "string") { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6cf23a8b7da..5134bd9b1a2 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,7 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, @@ -149,6 +150,12 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; @@ -796,6 +803,66 @@ export function Inbox() { () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), [visibleTouchedIssues], ); + const creatorOptions = useMemo(() => { + const options = new Map(); + const sourceIssues = [...mineIssues, ...touchedIssues]; + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of sourceIssues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + const knownAgentIds = new Set(); + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of sourceIssues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, mineIssues, touchedIssues]); const issuesToRender = useMemo( () => { if (tab === "mine") return visibleMineIssues; @@ -1814,6 +1881,7 @@ export function Inbox() { onChange={updateIssueFilters} activeFilterCount={activeIssueFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 4a6f2269062..7edea69730d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -573,7 +573,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetchInterval: liveRunCount > 0 ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const hasLiveRuns = liveRunCount > 0 || !!activeRun; + const resolvedActiveRun = useMemo( + () => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun), + [activeRun, executionRunId, issueStatus], + ); + const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), @@ -584,8 +588,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( - () => resolveRunningIssueRun(activeRun, resolvedLiveRuns), - [activeRun, resolvedLiveRuns], + () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), + [resolvedActiveRun, resolvedLiveRuns], ); const timelineRuns = useMemo(() => { const liveIds = new Set(); @@ -672,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ linkedRuns={timelineRuns} timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} - activeRun={activeRun} + activeRun={resolvedActiveRun} companyId={companyId} projectId={projectId} issueStatus={issueStatus} @@ -950,7 +954,8 @@ export function IssueDetail() { select: (run) => !!run, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); - const hasLiveRuns = liveRunCount > 0 || hasActiveRun; + const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; + const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], From b8725c52eff66cdea8cb223f1ca885475a254468 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:40:35 -0500 Subject: [PATCH 16/16] release: v2026.416.0 notes (#3782) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies, and stable releases need a clear changelog artifact for operators upgrading between versions. > - The release-note workflow in this repo stores one stable changelog file per release under `releases/`. > - `v2026.410.0` and `v2026.413.0` were intermediate drafts for the same release window, while the next stable release is `v2026.416.0`. > - Keeping superseded draft release notes around would make the stable release history noisy and misleading. > - This pull request consolidates the intended content into `releases/v2026.416.0.md` and removes the older `releases/v2026.410.0.md` and `releases/v2026.413.0.md` files. > - The benefit is a single canonical stable release note for `v2026.416.0` with no duplicate release artifacts. ## What Changed - Added `releases/v2026.416.0.md` as the canonical stable changelog for the April 16, 2026 release. - Removed the superseded `releases/v2026.410.0.md` and `releases/v2026.413.0.md` draft release-note files. - Kept the final release-note ordering and content as edited in the working tree before commit. ## Verification - Reviewed the git diff to confirm the PR only changes release-note artifacts in `releases/`. - Confirmed the branch is based on `public-gh/master` and contains a single release-note commit. - Did not run tests because this is a docs-only changelog update. ## Risks - Low risk. The change is limited to release-note markdown files. - The main risk is editorial: if any release item was meant to stay in a separate changelog file, it now exists only in `v2026.416.0.md`. ## Model Used - OpenAI GPT-5 Codex, model `gpt-5.4`, medium reasoning, tool use and code execution in the Codex CLI environment. ## 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) - [ ] 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.410.0.md | 50 ------------------ releases/{v2026.413.0.md => v2026.416.0.md} | 56 ++++++++++++--------- 2 files changed, 33 insertions(+), 73 deletions(-) delete mode 100644 releases/v2026.410.0.md rename releases/{v2026.413.0.md => v2026.416.0.md} (63%) diff --git a/releases/v2026.410.0.md b/releases/v2026.410.0.md deleted file mode 100644 index e835f186c09..00000000000 --- a/releases/v2026.410.0.md +++ /dev/null @@ -1,50 +0,0 @@ -# v2026.410.0 - -> Released: 2026-04-13 - -## Security - -- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/cryppadotta/paperclip/pull/3315)) -- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. -- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. ([#2659](https://github.com/cryppadotta/paperclip/pull/2659)) -- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2819](https://github.com/cryppadotta/paperclip/pull/2819)) - -## Highlights - -- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/cryppadotta/paperclip/pull/3542)) -- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/cryppadotta/paperclip/pull/3538)) -- **Inbox parent-child nesting** — Issues in the Mine inbox can now be grouped by parent, with a toggle and keyboard-traversable nested rows. -- **Keyboard shortcut cheatsheet** — Press `?` to see all available keyboard shortcuts in a dialog. -- **Issue search in inbox** — Broadened comment matching for inbox issue search with fallback. -- **Codex fast mode** — Added fast mode support for `codex_local` adapters with env probe safeguards. -- **Backups with retention** — Gzip-compressed database backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. -- **AWS Bedrock auth** — Added AWS Bedrock authentication support on `claude-local` adapters. ([#2793](https://github.com/cryppadotta/paperclip/pull/2793)) - -## Improvements - -- **Issue detail stability** — Faster comment loading, reduced rerenders on interrupted runs, stable transcript rendering for non-succeeded runs. -- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain. -- **Agent runtime** — Hardened heartbeat and adapter runtime workflows, scoped-wake fast path skips full heartbeat on comment wakes, signoff stage access fixes. -- **Execution policy** — Fixed non-participant stage mutation rejection, decision persistence, and signoff PR follow-up flows. -- **Chat UX polish** — Shimmer animation improvements, image gallery in chat messages, inline comment composer, Working/Worked status tokens. -- **Inbox refinements** — Avoid refetching on filter-only changes, archive shortcut fix, badge fixture alignment, nesting column alignment. -- **Typing performance** — Fixed typing lag in long comment threads. ([#3163](https://github.com/cryppadotta/paperclip/pull/3163)) -- **Issue list grouping** — Added workspace and parent issue grouping to the issues list view. -- **Worktree tooling** — Improved worktree helpers, bind presets for deployment setup, tailnet bind hardening. -- **Plugin SDK** — Plugin SDK now prepares before CLI dev boot. ([#3343](https://github.com/cryppadotta/paperclip/pull/3343)) - -## Fixes - -- **Agent env bindings** — Cleared agent env bindings now persist correctly on save. -- **Comment editor sync** — Hardened issue comment editor synchronization. -- **Document revisions** — Latest issue document revision stays current in the UI. ([#3342](https://github.com/cryppadotta/paperclip/pull/3342)) -- **Claude instructions** — Fixed instruction sibling path hints, gate file I/O to fresh sessions only, skip `--append-system-prompt-file` on resumed sessions. -- **Codex transcript** — Fixed Codex tool-use transcript completion parsing. -- **Backup cleanup** — Orphaned `.sql` files cleaned up on compression failure; stale startup log fixed. -- **Chat layout** — Fixed avatar positioning, activity line alignment, comment alignment, and feedback panel closing. - -## Upgrade Guide - -Multiple database migrations will run automatically on startup. All migrations are additive — no existing data is modified. - -**Security:** This release addresses [GHSA-68qg-g8mg-6pr7](https://github.com/cryppadotta/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7). All deployments should upgrade as soon as possible. diff --git a/releases/v2026.413.0.md b/releases/v2026.416.0.md similarity index 63% rename from releases/v2026.413.0.md rename to releases/v2026.416.0.md index c90142cb4b7..bc1206e48ac 100644 --- a/releases/v2026.413.0.md +++ b/releases/v2026.416.0.md @@ -1,24 +1,26 @@ -# v2026.413.0 +# v2026.416.0 -> Released: 2026-04-13 +> Released: 2026-04-16 ## Highlights - **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages now render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079)) -- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm) - **Execution policies** — Issues can now carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222)) - **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797)) -- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435)) +- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/cryppadotta/paperclip/pull/3542)) +- **Auto-checkout for scoped wakes** — The agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/cryppadotta/paperclip/pull/3538)) +- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, `j`/`k` keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) ## Improvements +- **BETA Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435)) - **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) -- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) - **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356)) +- **Improvements to plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm) - **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999)) - **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) - **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792)) -- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772)) +- **Keyboard shortcut cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772)) - **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), @kimnamu) - **Codex fast mode** — Added fast mode support for the Codex local adapter. ([#3383](https://github.com/paperclipai/paperclip/pull/3383)) - **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins) @@ -38,39 +40,45 @@ ## Fixes -- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, main-pane focus on navigation, and other detail-view rerender issues. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) - **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang) -- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) -- **Vite HTML transforms** — Fixed repeated vite HTML transforms in dev mode. +- **Inbox keyboard navigation** — Fixed `j`/`k` traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Vite HTML transforms** — Fixed repeated Vite HTML transforms in dev mode. - **Auth session lookup** — Skipped unnecessary auth session lookups on non-API requests. - **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer) - **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar) - **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros) - **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa) -- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey) -- **JWT secret fallback** — Removed hardcoded JWT secret fallback; auth now properly falls back to `BETTER_AUTH_SECRET`. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn) -- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks) +- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions, preserved instructions on resume fallback, and tightened fresh-session-only instruction file handling. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey) - **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163)) -- **Infinite render loop** — Fixed infinite render loop in inbox mobile toolbar. -- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats. +- **Comment editor sync** — Hardened issue comment editor synchronization. +- **Document revisions freshness** — The latest issue document revision now stays current in the UI. ([#3342](https://github.com/cryppadotta/paperclip/pull/3342)) +- **Infinite render loop** — Fixed infinite render loop in the inbox mobile toolbar. +- **Shimmer animation** — Fixed shimmer text using an invalid `hsl()` wrapper on `oklch` colors, reduced loop jitter, and added a pause between repeats. - **Mention selection** — Restored touch mention selection and fixed spaced mention queries. - **Inbox archive** — Fixed archive flashing back after fade-out. -- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326) +- **Goal description** — Made the goal description area scrollable in the create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326) - **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) - **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors. - **Noisy request logging** — Reduced noisy server request logging. - **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing. -- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session. +- **Codex resume error** — Recognize missing-rollout Codex resume error as a stale session. - **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305)) -- **Security** — Bumped rollup to 4.59.0 (path-traversal CVE), multer to 2.1.1 (HIGH CVEs), and redacted Bearer tokens from server log output. ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog) - **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation. - **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins) -- **Route authorization** — Scoped import, approvals, activity, and heartbeat routes properly. ([#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA) -- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt) -- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison) -- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore) -- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm) -- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log. +- **Windows adapter wrappers** — Use `cmd.exe` for `.cmd` and `.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt) +- **Markdown autoformat** — Fixed autoformat of pasted markdown in the inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison) +- **Paused agent dimming** — Correctly dim paused agents in list and org chart views while skipping dimming on the Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore) +- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to `agent`. ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm) +- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup logs. +- **Chat layout alignment** — Fixed avatar positioning, activity line alignment, comment alignment, and feedback panel closing. + +## Security + +- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. All deployments should upgrade. ([#3315](https://github.com/cryppadotta/paperclip/pull/3315)) +- **JWT secret handling** — Removed the hardcoded JWT secret fallback and fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), [#2866](https://github.com/paperclipai/paperclip/pull/2866), @cleanunicorn, @ergonaworks) +- **Redacted Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. ([#2659](https://github.com/cryppadotta/paperclip/pull/2659)) +- **Dependency security bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2819](https://github.com/cryppadotta/paperclip/pull/2819), [#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog) ## Upgrade Guide @@ -91,6 +99,8 @@ All migrations are additive — no existing data is modified or removed. If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening. +This release also includes the fix for [GHSA-68qg-g8mg-6pr7](https://github.com/cryppadotta/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7). Upgrade all deployments as soon as practical. + ## Contributors Thank you to everyone who contributed to this release!