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/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/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/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/packages/shared/src/index.ts b/packages/shared/src/index.ts index c39099f44ea..fcaf8bc1499 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -229,6 +229,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 0fcec4d8442..0089c92d015 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/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/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/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/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.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/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! 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__/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__/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 a380348cb34..cab559afab6 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -78,6 +78,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/__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-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__/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__/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__/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__/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__/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/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 9b5ecbb8f1c..2d294c0e0d3 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -33,7 +33,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__/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 06049f3be06..5d907470fa4 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -599,6 +599,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 () => { @@ -647,6 +648,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/__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/__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__/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 3bd47343ac8..3e233634dfe 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; @@ -696,6 +697,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", () => { @@ -1191,10 +1225,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); 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); @@ -1456,3 +1492,84 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); }); + +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/__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__/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", + }); + }); }); // --------------------------------------------------------------------------- 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/__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/__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/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/app.ts b/server/src/app.ts index 1ed8f7f64cd..971645d5e91 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -71,6 +71,7 @@ const VITE_DEV_STATIC_PATHS = new Set([ "/favicon.ico", "/favicon.svg", "/site.webmanifest", + "/sw.js", ]); export function resolveViteHmrPort(serverPort: number): number { @@ -80,13 +81,23 @@ 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; 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: { @@ -124,8 +135,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, @@ -272,9 +285,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"); @@ -283,6 +333,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({ @@ -305,6 +356,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/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, }; } 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(`(? { + 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" }, @@ -2483,6 +2486,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"); @@ -2726,6 +2730,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/adapters.ts b/server/src/routes/adapters.ts index edac845fff4..a01f3132ba0 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 87f3411fc6d..f5e06dd62aa 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, @@ -72,6 +76,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 +89,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", @@ -213,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") { @@ -299,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); @@ -321,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); @@ -557,7 +608,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; } @@ -592,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) { @@ -638,7 +694,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 +704,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( @@ -973,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; } @@ -1105,6 +1167,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( @@ -1148,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)); }); @@ -1241,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); @@ -1255,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); @@ -1274,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 = @@ -1306,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), @@ -1565,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); @@ -1616,7 +1693,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({ @@ -1865,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; @@ -1969,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" }); @@ -1992,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" }); @@ -2013,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" }); @@ -2036,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" }); @@ -2057,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); }); @@ -2064,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 }); }); @@ -2102,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, { @@ -2152,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( @@ -2200,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" }); @@ -2348,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; @@ -2357,11 +2488,12 @@ 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, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2396,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); }); 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 b6b639b67a8..2561609b80f 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"); @@ -34,9 +40,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"); } @@ -59,9 +63,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..20e80c53e43 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(); @@ -32,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); }); @@ -96,6 +104,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 +442,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 b965761a0d9..a1992dd41c6 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -8,6 +8,14 @@ import { logger } from "../middleware/logger.js"; 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: { @@ -24,9 +32,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; } @@ -72,7 +90,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 @@ -87,6 +105,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..9d8220a023a 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, @@ -652,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, }); @@ -723,7 +729,7 @@ export function issueRoutes( const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), - svc.findMentionedProjectIds(issue.id), + svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), ]); @@ -1323,6 +1329,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 +1380,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 582e699a4f3..a0e26a7f99d 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 { assertBoardOrgAccess, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, 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) => { - assertBoardOrgAccess(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) => { - assertBoardOrgAccess(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..131578de9cb 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -216,7 +216,7 @@ export function agentService(db: Db) { const rows = await db .select({ agentId: costEvents.agentId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( @@ -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/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-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/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/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/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/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"); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index db8eb39a1e6..5d045e47ae0 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; } @@ -131,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, "\\$&"); @@ -526,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, @@ -985,7 +1032,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)); @@ -1004,7 +1051,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -1162,7 +1209,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); @@ -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/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, 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 @@ - + 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/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/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/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/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 0208bb2ea3f..096ef8f245b 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -970,10 +970,9 @@ function IssueChatUserMessage() {
{resolvedAuthorName}
-
) : null} -
+
, diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 2d21d49a2bd..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 @@ -251,10 +349,10 @@ export function IssueFiltersPopover({ Visibility
) : null} 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/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 ffc96c41a8f..4a83830b3cb 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -22,7 +22,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 }> }) { @@ -41,17 +41,15 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C return (
- - {value} - + {copied && }
); } @@ -762,16 +760,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)) @@ -997,15 +1004,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} @@ -1118,10 +1117,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(() => ({ @@ -194,6 +195,7 @@ describe("IssuesList", () => { mockAccessApi.listMembers.mockReset(); mockAccessApi.listUserDirectory.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); + mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); @@ -201,6 +203,7 @@ describe("IssuesList", () => { mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} }); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); localStorage.clear(); }); @@ -229,7 +232,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"); }); @@ -263,6 +270,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"); @@ -346,7 +354,7 @@ describe("IssuesList", () => { expect(onSearchChange).not.toHaveBeenCalled(); act(() => { - vi.advanceTimersByTime(149); + vi.advanceTimersByTime(249); }); expect(onSearchChange).not.toHaveBeenCalled(); @@ -364,6 +372,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"])); @@ -480,7 +591,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", @@ -548,7 +659,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", @@ -576,7 +687,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 () => { @@ -589,21 +700,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(() => { @@ -681,4 +792,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 ba376c95dde..283b6ba8566 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -21,6 +21,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, @@ -56,7 +57,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 ── */ @@ -84,7 +89,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 }; } @@ -159,6 +167,13 @@ interface Agent { name: string; } +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; @@ -290,6 +305,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(); @@ -340,12 +356,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 } : {}), }), @@ -354,9 +372,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, }); @@ -441,6 +459,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), @@ -545,6 +623,26 @@ export function IssuesList({ })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]); + 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; @@ -594,6 +692,7 @@ export function IssuesList({ setAssigneeSearch(""); }, [onUpdateIssue]); + let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY; return (
@@ -659,6 +758,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} @@ -748,7 +848,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 assigneeUserProfile = issue.assigneeUserId @@ -834,7 +945,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 3824af3bb68..3604f52d122 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -98,6 +98,14 @@ 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"); @@ -168,7 +176,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"); }); @@ -187,4 +195,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("(null); @@ -144,14 +172,44 @@ export function MarkdownBody({ remarkPlugins.push(remarkSoftBreaks); } const components: Components = { + p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => ( +

+ {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 ( @@ -179,14 +237,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} ); @@ -211,13 +269,17 @@ export function MarkdownBody({ return (
    - 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/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/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index 0a6f419bf2d..59f1e97cf2c 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -131,6 +131,7 @@ export function RoutineRunVariablesDialog({ open, onOpenChange, companyId, + routineName, projects, agents, defaultProjectId, @@ -142,6 +143,7 @@ export function RoutineRunVariablesDialog({ open: boolean; onOpenChange: (open: boolean) => 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 139ed76adf2..f9a9cec13f3 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 dd24a7e5939..95f06ace19e 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -259,6 +259,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, @@ -749,6 +773,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 ( @@ -844,6 +869,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 6cf208251ce..23c6973016d 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, @@ -772,10 +773,11 @@ describe("inbox helpers", () => { statuses: ["in_progress"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }).map((issue) => issue.id), ).toEqual(["remote-match"]); @@ -790,10 +792,11 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }), ).toEqual([]); @@ -808,15 +811,55 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], 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"); @@ -833,10 +876,11 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: true, + hideRoutineExecutions: false, }, }); saveInboxFilterPreferences("company-2", { @@ -846,10 +890,11 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }); @@ -860,10 +905,11 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: true, + hideRoutineExecutions: false, }, }); expect(loadInboxFilterPreferences("company-2")).toEqual({ @@ -873,10 +919,11 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: true, }, }); }); @@ -889,10 +936,11 @@ 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], - showRoutineExecutions: "yes", + hideRoutineExecutions: "yes", }, })); @@ -903,10 +951,11 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: [], assignees: ["agent-1"], + creators: ["user:user-1"], labels: [], projects: ["project-1"], workspaces: ["workspace-1"], - showRoutineExecutions: false, + hideRoutineExecutions: false, }, }); }); @@ -1060,12 +1109,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 6dbfc91100f..86d34e1b13f 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"; @@ -87,6 +88,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[]; @@ -127,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), - showRoutineExecutions: candidate.showRoutineExecutions === true, - }; -} - function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter { return value === "issues_i_touched" || value === "join_requests" @@ -234,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(); } @@ -367,14 +359,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( @@ -928,6 +920,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.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 46a77e07273..4a64b839f3f 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -4,20 +4,22 @@ export type IssueFilterState = { statuses: string[]; priorities: string[]; assignees: string[]; + creators: string[]; labels: string[]; projects: string[]; workspaces: string[]; - showRoutineExecutions: boolean; + hideRoutineExecutions: boolean; }; export const defaultIssueFilterState: IssueFilterState = { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], - showRoutineExecutions: false, + hideRoutineExecutions: false, }; export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; @@ -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]; } @@ -58,7 +80,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)); @@ -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,9 +127,10 @@ 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; - if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1; + if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1; return count; } 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/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/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9ba80d60fe2..57c4abb5225 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/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/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/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), diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6464ce6506d..f58d9688a2e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -99,8 +99,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, + buildGroupedInboxSections, buildInboxKeyboardNavEntries, - buildInboxNesting, getAvailableInboxIssueColumns, getInboxWorkItemKey, getApprovalsForTab, @@ -111,7 +111,6 @@ import { getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, - groupInboxWorkItems, isInboxEntityDismissed, isMineInboxTab, loadCollapsedInboxGroupKeys, @@ -138,6 +137,7 @@ import { saveLastInboxTab, shouldShowCompanyAlerts, shouldShowInboxSection, + type InboxGroupedSection, type InboxTab, type InboxWorkItem, type InboxWorkItemGroupBy, @@ -152,39 +152,13 @@ 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; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; }; -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); @@ -875,6 +849,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; @@ -1128,19 +1162,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 --- @@ -1170,15 +1197,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], @@ -1547,7 +1586,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1560,7 +1599,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1663,10 +1702,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 { @@ -1891,6 +1930,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} @@ -2172,15 +2212,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"}
    , @@ -2351,7 +2394,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 2d435555445..4ee14f76609 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"; @@ -30,7 +30,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, @@ -492,7 +493,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; @@ -525,9 +529,12 @@ type IssueDetailChatTabProps = { onImageClick: (src: string) => void; }; -function IssueDetailChatTab({ +const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, - issue, + companyId, + projectId, + issueStatus, + executionRunId, comments, hasOlderComments, commentsLoadingOlder, @@ -555,59 +562,66 @@ 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 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), refetchInterval: hasLiveRuns ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); + const resolvedActivity = activity ?? []; + const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( - () => resolveRunningIssueRun(activeRun, liveRuns), - [activeRun, liveRuns], + () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), + [resolvedActiveRun, 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; @@ -641,20 +655,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 (
    @@ -679,11 +684,11 @@ function IssueDetailChatTab({ feedbackTermsUrl={feedbackTermsUrl} linkedRuns={timelineRuns} timelineEvents={timelineEvents} - liveRuns={liveRuns} - activeRun={activeRun} - companyId={issue.companyId} - projectId={issue.projectId} - issueStatus={issue.status} + liveRuns={resolvedLiveRuns} + activeRun={resolvedActiveRun} + companyId={companyId} + projectId={projectId} + issueStatus={issueStatus} agentMap={agentMap} currentUserId={currentUserId} userLabelMap={userLabelMap} @@ -713,7 +718,7 @@ function IssueDetailChatTab({ />
    ); -} +}); type IssueDetailActivityTabProps = { issueId: string; @@ -895,22 +900,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(() => { @@ -969,7 +967,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], @@ -1074,6 +1073,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; @@ -1118,6 +1125,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!) }); @@ -1758,12 +1766,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"; @@ -1805,20 +1818,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); @@ -2047,6 +2068,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}

    ; @@ -2572,13 +2623,14 @@ export function IssueDetail() { {detailTab === "chat" ? ( { - void fetchOlderComments(); - }} + onLoadOlderComments={loadOlderComments} composerRef={commentComposerRef} feedbackVotes={feedbackVotes} feedbackDataSharingPreference={feedbackDataSharingPreference} @@ -2593,33 +2645,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 c5c2e5c465d..0262cc94a4b 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -963,6 +963,7 @@ export function Routines() { if (!next) setRunDialogRoutine(null); }} companyId={selectedCompanyId} + routineName={runDialogRoutine?.title ?? null} agents={agents ?? []} projects={projects ?? []} defaultProjectId={runDialogRoutine?.projectId ?? null} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 395eebb9d3f..27f481a4019 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,9 +2,20 @@ 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({ +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"), @@ -13,8 +24,7 @@ export default defineConfig({ }, 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", @@ -22,4 +32,4 @@ export default defineConfig({ }, }, }, -}); +}));