mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge public-gh/master into PAP-989-multi-user-implementation-using-plan-from-pap-958
Co-Authored-By: Paperclip <noreply@paperclip.ing> * public-gh/master: release: v2026.416.0 notes (#3782) Sync/master post pap1497 followups 2026 04 15 (#3779) [codex] improve issue and routine UI responsiveness (#3744) fix: disable HTTP caching on run log endpoints (#3724) [codex] harden heartbeat run summaries and recovery context (#3742) [codex] fix worktree dev dependency ergonomics (#3743) chore(ui): drop console.* and legal comments in production builds (#3728) fix: proper cache headers for static assets and SPA fallback (#3734) fix(a11y): remove maximum-scale and user-scalable=no from viewport (#3726) fix: trust PAPERCLIP_PUBLIC_URL in board mutation guard (#3731) [codex] harden authenticated routes and issue editor reliability (#3741) feat(adapters): add capability flags to ServerAdapterModule (#3540) fix: Anthropic subscription quota always shows 100% used (#3589) fix(server): respect externally set PAPERCLIP_API_URL env var (#3472) fix(routines): include cronExpression and timezone in list trigger response (#3209) fix: include routine-execution issues in agent inbox-lite (#3329) # Conflicts: # server/src/__tests__/issues-service.test.ts # server/src/routes/access.ts # server/src/routes/plugins.ts # ui/src/components/IssueChatThread.tsx # ui/src/pages/Inbox.tsx
This commit is contained in:
commit
ea7066709b
121 files changed
+6172
-660
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -328,6 +328,36 @@ export interface ServerAdapterModule {
|
||||
* resolved inside this method — the caller receives a fully hydrated schema.
|
||||
*/
|
||||
getConfigSchema?: () => Promise<AdapterConfigSchema> | 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -229,6 +229,7 @@ export type {
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,7 +129,7 @@ export interface RoutineExecutionIssueOrigin {
|
||||
}
|
||||
|
||||
export interface RoutineListItem extends Routine {
|
||||
triggers: Pick<RoutineTrigger, "id" | "kind" | "label" | "enabled" | "nextRunAt" | "lastFiredAt" | "lastResult">[];
|
||||
triggers: Pick<RoutineTrigger, "id" | "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "nextRunAt" | "lastFiredAt" | "lastResult">[];
|
||||
lastRun: RoutineRunSummary | null;
|
||||
activeIssue: RoutineIssueSummary | null;
|
||||
}
|
||||
@@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings {
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -12,14 +12,12 @@ export type CreateApproval = z.infer<typeof createApprovalSchema>;
|
||||
|
||||
export const resolveApprovalSchema = z.object({
|
||||
decisionNote: z.string().optional().nullable(),
|
||||
decidedByUserId: z.string().optional().default("board"),
|
||||
});
|
||||
|
||||
export type ResolveApproval = z.infer<typeof resolveApprovalSchema>;
|
||||
|
||||
export const requestApprovalRevisionSchema = z.object({
|
||||
decisionNote: z.string().optional().nullable(),
|
||||
decidedByUserId: z.string().optional().default("board"),
|
||||
});
|
||||
|
||||
export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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!
|
||||
@@ -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<string, string> {
|
||||
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<string, unknown> | undefined),
|
||||
|
||||
@@ -28,7 +28,15 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
async function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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",
|
||||
|
||||
@@ -59,6 +59,11 @@ const assetSvc = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const secretSvc = {
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
|
||||
};
|
||||
|
||||
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<string>(),
|
||||
}));
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
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",
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createDb>;
|
||||
let svc!: ReturnType<typeof companySkillService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<typeof createDb>;
|
||||
let costs!: ReturnType<typeof costService>;
|
||||
let finance!: ReturnType<typeof financeService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../routes/execution-workspaces.js")>("../routes/execution-workspaces.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
...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"),
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, loaderOverrides: Record<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
+60
-6
@@ -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/<name>.<hash>.<ext>)
|
||||
// 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();
|
||||
|
||||
+4
-2
@@ -554,7 +554,9 @@ export async function startServer(): Promise<StartedServer> {
|
||||
: 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<StartedServer> {
|
||||
server,
|
||||
host: config.host,
|
||||
listenPort,
|
||||
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
|
||||
apiUrl: process.env.PAPERCLIP_API_URL!,
|
||||
databaseUrl: activeDatabaseConnectionString,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
|
||||
result = result.replace(pattern, maskUserNameForLogs(userName, replacement));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) {
|
||||
origins.add(`http://${host}`.toLowerCase());
|
||||
origins.add(`https://${host}`.toLowerCase());
|
||||
}
|
||||
// Behind some reverse proxies the Host / X-Forwarded-Host header may
|
||||
// not match the public URL (for example when TLS terminates at the
|
||||
// edge and the inbound Host is an internal service name). Trust the
|
||||
// explicitly-configured PAPERCLIP_PUBLIC_URL when it's set.
|
||||
const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim());
|
||||
if (publicUrl) origins.add(publicUrl);
|
||||
return origins;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const SILENCED_SUCCESS_STATIC_PREFIXES = [
|
||||
const SILENCED_SUCCESS_STATIC_PATHS = new Set([
|
||||
"/favicon.ico",
|
||||
"/site.webmanifest",
|
||||
"/sw.js",
|
||||
]);
|
||||
|
||||
function normalizePath(url: string): string {
|
||||
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
collapseDuplicatePendingHumanJoinRequests,
|
||||
findReusableHumanJoinRequest,
|
||||
} from "../lib/join-request-dedupe.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
import { assertAuthenticated, assertCompanyAccess } from "./authz.js";
|
||||
import {
|
||||
claimBoardOwnership,
|
||||
inspectBoardClaimChallenge
|
||||
@@ -891,6 +891,7 @@ function toInviteSummaryResponse(
|
||||
const invitePath = `/invite/${token}`;
|
||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||
const skillIndexPath = `/api/invites/${token}/skills/index`;
|
||||
const inviteMessage = extractInviteMessage(invite);
|
||||
return {
|
||||
id: invite.id,
|
||||
@@ -910,10 +911,10 @@ function toInviteSummaryResponse(
|
||||
onboardingTextUrl: baseUrl
|
||||
? `${baseUrl}${onboardingTextPath}`
|
||||
: onboardingTextPath,
|
||||
skillIndexPath: "/api/skills/index",
|
||||
skillIndexPath,
|
||||
skillIndexUrl: baseUrl
|
||||
? `${baseUrl}/api/skills/index`
|
||||
: "/api/skills/index",
|
||||
? `${baseUrl}${skillIndexPath}`
|
||||
: skillIndexPath,
|
||||
inviteMessage
|
||||
};
|
||||
}
|
||||
@@ -1384,7 +1385,7 @@ function buildInviteOnboardingManifest(
|
||||
}
|
||||
) {
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
const skillPath = "/api/skills/paperclip";
|
||||
const skillPath = `/api/invites/${token}/skills/paperclip`;
|
||||
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
||||
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
||||
const registrationEndpointUrl = baseUrl
|
||||
@@ -2462,11 +2463,13 @@ export function accessRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/skills/available", (_req, res) => {
|
||||
router.get("/skills/available", (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
res.json({ skills: listAvailableSkills() });
|
||||
});
|
||||
|
||||
router.get("/skills/index", (_req, res) => {
|
||||
router.get("/skills/index", (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
res.json({
|
||||
skills: [
|
||||
{ name: "paperclip", path: "/api/skills/paperclip" },
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string>): 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.
|
||||
|
||||
+182
-49
@@ -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<string, string> = {
|
||||
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<T> {
|
||||
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<string, unknown> | 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<string, unknown>) {
|
||||
@@ -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<string, unknown>,
|
||||
);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
...(req.body.name === undefined ? {} : { name: req.body.name }),
|
||||
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),
|
||||
|
||||
@@ -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<typeof toDevServerHealthStatus> | 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Request } from "express";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOwn(value: Record<string, unknown>, 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;
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
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<string>([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<typeof condition> => 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);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export function agentService(db: Db) {
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
spentMonthlyCents: sql<number>`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;
|
||||
},
|
||||
|
||||
@@ -156,7 +156,7 @@ async function computeObservedAmount(
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
@@ -76,10 +76,10 @@ export function companyService(db: Db) {
|
||||
if (companyIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await database
|
||||
.select({
|
||||
companyId: costEvents.companyId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.select({
|
||||
companyId: costEvents.companyId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
|
||||
@@ -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<string | null> | 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<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>,
|
||||
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<string, string>,
|
||||
existingSlugToAgentId: Map<string, string>,
|
||||
agentStatusById: Map<string, string | null | undefined>,
|
||||
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<ResolvedSource> {
|
||||
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<string, string>();
|
||||
const existingSlugToAgentId = new Map<string, string>();
|
||||
const agentStatusById = new Map<string, string | null | undefined>();
|
||||
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<string, string>();
|
||||
const importedProjectWorkspaceIdByProjectSlug = new Map<string, Map<string, string>>();
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<CompanySkill, "id" | "key" | "slug">;
|
||||
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<Record<string, unknown>> {
|
||||
@@ -1159,14 +1225,14 @@ function serializeFileInventory(
|
||||
}));
|
||||
}
|
||||
|
||||
function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
|
||||
function getSkillMeta(skill: Pick<CompanySkill, "metadata">): 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<string, unknown>,
|
||||
) {
|
||||
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<CompanySkillListItem[]> {
|
||||
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) => {
|
||||
|
||||
@@ -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<number>`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<number>`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<number>`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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`count(distinct ${costEvents.provider})::int`,
|
||||
modelCount: sql<number>`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<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`;
|
||||
const costCentsExpr = sumAsNumber(costEvents.costCents);
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectId: effectiveProjectId,
|
||||
projectName: projects.name,
|
||||
costCents: costCentsExpr,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`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))
|
||||
|
||||
@@ -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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
|
||||
@@ -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<ExecutionWorkspaceRow, "id" | "name" | "mode" | "projectWorkspaceId">): 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<string, unknown> | 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()
|
||||
|
||||
@@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) {
|
||||
}
|
||||
|
||||
export function financeService(db: Db) {
|
||||
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
||||
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
||||
const estimatedDebitExpr = sql<number>`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<typeof financeEvents.$inferInsert, "companyId">) => {
|
||||
@@ -95,12 +95,12 @@ export function financeService(db: Db) {
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
netCents: sql<number>`(${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<number>`count(*)::int`,
|
||||
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
netCents: sql<number>`(${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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
|
||||
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
contextTaskKey: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
|
||||
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
|
||||
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
|
||||
contextWakeReason: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"),
|
||||
contextWakeSource: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"),
|
||||
contextWakeTriggerDetail: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"),
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListResultColumns = {
|
||||
resultSummary: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultSummary"),
|
||||
resultResult: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultResult"),
|
||||
resultMessage: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultMessage"),
|
||||
resultError: sql<string | null>`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultError"),
|
||||
resultTotalCostUsd: sql<string | null>`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"),
|
||||
resultCostUsd: sql<string | null>`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"),
|
||||
resultCostUsdCamel: sql<string | null>`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"),
|
||||
} as const;
|
||||
|
||||
const heartbeatRunSafeResultJsonColumn = sql<Record<string, unknown> | 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<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
const summary: Record<string, unknown> = {};
|
||||
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<string, unknown> | null {
|
||||
const summary: Record<string, unknown> = {};
|
||||
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<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | 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");
|
||||
|
||||
|
||||
@@ -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<string | null>`
|
||||
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>`null`,
|
||||
executionState: sql<null>`null`,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: sql<null>`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<string, IssueActiveRunRow>,
|
||||
@@ -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<string>();
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -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<string, AdapterCapabilities> = {
|
||||
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<string, AdapterCapabilities>();
|
||||
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;
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ExecutionWorkspaceSummary[]>(
|
||||
`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(<InlineEditor value="" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("");
|
||||
|
||||
act(() => {
|
||||
textarea!.focus();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(<InlineEditor value="Loaded description" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
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(<InlineEditor value="Original" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
textarea!.focus();
|
||||
});
|
||||
act(() => {
|
||||
setNativeTextareaValue(textarea!, "Local draft");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(<InlineEditor value="Remote update" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
expect(textarea?.value).toBe("Local draft");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueContainedBlurCommit", () => {
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
||||
|
||||
@@ -970,10 +970,9 @@ function IssueChatUserMessage() {
|
||||
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 break-all rounded-2xl px-4 py-2.5",
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
@@ -1008,7 +1007,7 @@ function IssueChatUserMessage() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
|
||||
@@ -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<IssueFilterState>) => 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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -191,6 +235,60 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{creatorOptions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Creator</span>
|
||||
{selectedCreatorOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedCreatorOptions.map((creator) => (
|
||||
<Badge key={creator.id} variant="secondary" className="gap-1 pr-1">
|
||||
{creator.kind === "agent" ? <Bot className="h-3 w-3" /> : <User className="h-3 w-3" />}
|
||||
<span>{creator.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-accent"
|
||||
onClick={() => onChange({ creators: state.creators.filter((value) => value !== creator.id) })}
|
||||
aria-label={`Remove creator ${creator.label}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={creatorSearch}
|
||||
onChange={(event) => setCreatorSearch(event.target.value)}
|
||||
placeholder="Search creators..."
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => {
|
||||
const selected = state.creators.includes(creator.id);
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-sm ${
|
||||
selected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => onChange({ creators: toggleIssueFilterValue(state.creators, creator.id) })}
|
||||
>
|
||||
{creator.kind === "agent" ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
|
||||
<span className="min-w-0 flex-1 truncate">{creator.label}</span>
|
||||
{selected ? <X className="h-3 w-3" /> : null}
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">No creators match.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
@@ -251,10 +349,10 @@ export function IssueFiltersPopover({
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.showRoutineExecutions}
|
||||
onCheckedChange={(checked) => onChange({ showRoutineExecutions: checked === true })}
|
||||
checked={state.hideRoutineExecutions}
|
||||
onCheckedChange={(checked) => onChange({ hideRoutineExecutions: checked === true })}
|
||||
/>
|
||||
<span className="text-sm">Show routine runs</span>
|
||||
<span className="text-sm">Hide routine runs</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<span className="text-sm font-mono min-w-0 break-all">
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
className="text-sm font-mono min-w-0 break-all text-left cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Copy"}
|
||||
title={copied ? "Copied!" : "Click to copy"}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
{value}
|
||||
</button>
|
||||
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<span className="text-sm break-words min-w-0">
|
||||
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
|
||||
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
|
||||
: ""}
|
||||
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
|
||||
<span className="text-sm break-words min-w-0 inline">
|
||||
{parentIdentifier ? `${parentIdentifier} ` : ""}
|
||||
{parentTitle}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No parent</span>
|
||||
);
|
||||
const parentLink = issue.parentId ? (
|
||||
<Link
|
||||
to={`/issues/${parentIdentifier ?? issue.parentId}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : 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 ? (
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier ?? issue.parentId}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined}
|
||||
extra={parentLink}
|
||||
>
|
||||
{parentContent}
|
||||
</PropertyPicker>
|
||||
@@ -1118,10 +1117,21 @@ export function IssueProperties({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
|
||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{issue.executionWorkspaceId && (
|
||||
<PropertyRow label="Workspace">
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View workspace
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.currentExecutionWorkspace?.branchName && (
|
||||
<PropertyRow label="Branch">
|
||||
<TruncatedCopyable
|
||||
|
||||
@@ -33,6 +33,7 @@ const mockAccessApi = vi.hoisted(() => ({
|
||||
|
||||
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(
|
||||
<IssuesList
|
||||
issues={[]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch="server"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={manyIssues}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={[parentIssue, childIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1");
|
||||
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
|
||||
|
||||
@@ -290,6 +305,7 @@ export function IssuesList({
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => 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<CreatorOption[]>(() => {
|
||||
const options = new Map<string, CreatorOption>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
|
||||
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<string, unknown> = { ...(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 (
|
||||
<div className="space-y-4">
|
||||
@@ -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 && <PageSkeleton variant="issues-list" />}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
@@ -766,7 +870,10 @@ export function IssuesList({
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
) : (
|
||||
groupedContent.map((group) => (
|
||||
<>
|
||||
{groupedContent.map((group) => {
|
||||
if (remainingRowsToRender <= 0) return null;
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!viewState.collapsedGroups.includes(group.key)}
|
||||
@@ -809,10 +916,14 @@ export function IssuesList({
|
||||
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
||||
|
||||
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 (
|
||||
<div key={issue.id} style={depth > 0 ? { paddingLeft: `${depth * 16}px` } : undefined}>
|
||||
<div
|
||||
key={issue.id}
|
||||
style={{
|
||||
...(depth > 0 ? { paddingLeft: `${depth * 16}px` } : {}),
|
||||
...(useDeferredRowRendering
|
||||
? {
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "44px",
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
@@ -1010,11 +1132,18 @@ export function IssuesList({
|
||||
);
|
||||
};
|
||||
|
||||
return roots.map((issue) => renderIssueRow(issue, 0));
|
||||
return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null);
|
||||
})()}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))
|
||||
);
|
||||
})}
|
||||
{remainingIssueRowCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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('<a href="" rel="noreferrer"');
|
||||
expect(html).toContain(">click me</a>");
|
||||
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("<code>PAP-1271</code>");
|
||||
expect(html).toContain('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
|
||||
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("<p");
|
||||
});
|
||||
|
||||
it("applies wrap-friendly styles to long links", () => {
|
||||
const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)");
|
||||
|
||||
expect(html).toContain('<a href="https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong"');
|
||||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks width-bounded and horizontally scrollable", () => {
|
||||
const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```");
|
||||
|
||||
expect(html).toContain("<pre");
|
||||
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown, { type Components, type Options } from "react-markdown";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
@@ -56,6 +56,30 @@ function loadMermaid() {
|
||||
return mermaidLoaderPromise;
|
||||
}
|
||||
|
||||
const wrapAnywhereStyle: React.CSSProperties = {
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
|
||||
const scrollableBlockStyle: React.CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...wrapAnywhereStyle,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...scrollableBlockStyle,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenText(value: ReactNode): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
@@ -71,6 +95,10 @@ function extractMermaidSource(children: ReactNode): string | null {
|
||||
return flattenText(childProps.children).replace(/\n$/, "");
|
||||
}
|
||||
|
||||
function safeMarkdownUrlTransform(url: string): string {
|
||||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
|
||||
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
@@ -144,14 +172,44 @@ export function MarkdownBody({
|
||||
remarkPlugins.push(remarkSoftBreaks);
|
||||
}
|
||||
const components: Components = {
|
||||
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (
|
||||
<p {...paragraphProps} style={mergeWrapStyle(paragraphStyle as React.CSSProperties | undefined)}>
|
||||
{paragraphChildren}
|
||||
</p>
|
||||
),
|
||||
li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => (
|
||||
<li {...listItemProps} style={mergeWrapStyle(listItemStyle as React.CSSProperties | undefined)}>
|
||||
{listItemChildren}
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => (
|
||||
<blockquote {...blockquoteProps} style={mergeWrapStyle(blockquoteStyle as React.CSSProperties | undefined)}>
|
||||
{blockquoteChildren}
|
||||
</blockquote>
|
||||
),
|
||||
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
|
||||
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
||||
{tableCellChildren}
|
||||
</td>
|
||||
),
|
||||
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
|
||||
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
||||
{tableHeaderChildren}
|
||||
</th>
|
||||
),
|
||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||
const mermaidSource = extractMermaidSource(preChildren);
|
||||
if (mermaidSource) {
|
||||
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
||||
}
|
||||
return <pre {...preProps}>{preChildren}</pre>;
|
||||
return <pre {...preProps} style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}>{preChildren}</pre>;
|
||||
},
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => (
|
||||
<code {...codeProps} style={mergeWrapStyle(codeStyle as React.CSSProperties | undefined)}>
|
||||
{codeChildren}
|
||||
</code>
|
||||
),
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} rel="noreferrer">
|
||||
<a href={href} rel="noreferrer" style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
@@ -211,13 +269,17 @@ export function MarkdownBody({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||
"paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden",
|
||||
theme === "dark" && "prose-invert",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
style={mergeWrapStyle(style)}
|
||||
>
|
||||
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
|
||||
<Markdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
urlTransform={safeMarkdownUrlTransform}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const handle = React.useMemo(() => ({
|
||||
@@ -59,8 +63,16 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
|
||||
setContent("");
|
||||
onError?.({
|
||||
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
|
||||
source: markdown,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setContent(markdown);
|
||||
}, [markdown]);
|
||||
}, [markdown, onError, suppressHtmlProcessing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
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("");
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
|
||||
expect(container.textContent).toContain("Before");
|
||||
expect(container.textContent).toContain("After");
|
||||
|
||||
@@ -262,11 +276,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");
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
{routineName && (
|
||||
<p className="text-muted-foreground text-sm">{routineName}</p>
|
||||
)}
|
||||
<DialogTitle>Run routine</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed.
|
||||
|
||||
@@ -54,8 +54,8 @@ describe("RunTranscriptView", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("<strong>world</strong>");
|
||||
expect(html).toContain("<li>first</li>");
|
||||
expect(html).toContain("<li>second</li>");
|
||||
expect(html).toMatch(/<li[^>]*>first<\/li>/);
|
||||
expect(html).toMatch(/<li[^>]*>second<\/li>/);
|
||||
});
|
||||
|
||||
it("hides saved-session resume skip stderr from nice mode normalization", () => {
|
||||
@@ -106,8 +106,8 @@ describe("RunTranscriptView", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<li>fixed deploy config</li>");
|
||||
expect(html).toContain("<li>posted issue update</li>");
|
||||
expect(html).toMatch(/<li[^>]*>fixed deploy config<\/li>/);
|
||||
expect(html).toMatch(/<li[^>]*>posted issue update<\/li>/);
|
||||
expect(html).not.toContain("result");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createRoot>;
|
||||
|
||||
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(
|
||||
<BreadcrumbProvider>
|
||||
<TestConsumer />
|
||||
</BreadcrumbProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,23 @@ interface BreadcrumbContextValue {
|
||||
|
||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(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<Breadcrumb[]>([]);
|
||||
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||
|
||||
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
||||
setBreadcrumbsState(crumbs);
|
||||
setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs));
|
||||
}, []);
|
||||
|
||||
const setMobileToolbar = useCallback((node: ReactNode | null) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -259,6 +259,30 @@ function shouldSuppressRunStatusToastForVisibleIssue(
|
||||
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
function invalidateVisibleIssueRunQueries(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
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,
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user