From 7f452cbd758e7b0a1f033e27002c2b64de509e73 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 14:01:46 -0500 Subject: [PATCH 01/55] Align the second registry fixture with the package-local probe boundary Carry the existing downstream probe import and partial mock, preserving all original exports and readiness assertions. All 112 affected server tests pass. The other prerequisites and readiness branch already have this exact test blob, so no shipping or downstream source changes are needed. Co-Authored-By: Paperclip --- server/src/__tests__/adapter-registry.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 730717d33f3..3e959425019 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,4 +1,4 @@ -import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "@paperclipai/paperclip-runner/live"; +import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/index.js"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -17,7 +17,8 @@ import { setOverridePaused, } from "../adapters/registry.js"; -vi.mock("@paperclipai/paperclip-runner/live", () => ({ +vi.mock("../vendor/paperclip-runner/index.js", async (importOriginal) => ({ + ...await importOriginal(), probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), probeAcpxPiInstallation: vi.fn(async () => undefined), From 3a726e676fe47f4725d03037e823d7f057760708 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:06:40 -0500 Subject: [PATCH 02/55] feat: enforce private task permissions across execution and data (#10633) Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314. Co-Authored-By: Paperclip --- doc/DATABASE.md | 6 +- doc/DEVELOPING.md | 30 + doc/ISSUE-PRIVACY.md | 204 + doc/SPEC-implementation.md | 52 +- doc/SPEC.md | 4 +- .../0314_private_task_draft_assets.sql | 28 + ...{0309_snapshot.json => 0314_snapshot.json} | 10329 ++++++++++------ packages/db/src/migrations/meta/_journal.json | 9 +- ...rivate-task-draft-assets-migration.test.ts | 35 + packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 5 + packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/issue.test.ts | 8 + packages/shared/src/validators/issue.ts | 1 + packages/shared/src/validators/project.ts | 8 + server/src/__tests__/activity-routes.test.ts | 10 + server/src/__tests__/activity-service.test.ts | 68 + .../__tests__/agent-live-run-routes.test.ts | 19 +- .../approval-routes-idempotency.test.ts | 4 + server/src/__tests__/assets.test.ts | 2 +- .../src/__tests__/attention-service.test.ts | 35 + .../__tests__/authorization-service.test.ts | 241 +- .../src/__tests__/chat-project-tools.test.ts | 10 +- .../company-search-extract-routes.test.ts | 8 +- .../company-search-extract-service.test.ts | 43 + .../__tests__/company-search-service.test.ts | 100 + .../connection-intents-service.test.ts | 2 + .../src/__tests__/decisions-service.test.ts | 3 +- ...environment-selection-route-guards.test.ts | 6 + ...workspace-runtime-control-conflict.test.ts | 33 +- .../execution-workspaces-routes.test.ts | 10 +- .../__tests__/external-object-routes.test.ts | 6 +- server/src/__tests__/file-resources.test.ts | 14 + .../heartbeat-run-privacy-routes.test.ts | 395 + .../issue-access-grants-routes.test.ts | 462 + .../issue-activity-events-routes.test.ts | 15 +- ...ue-agent-mutation-ownership-routes.test.ts | 43 +- .../__tests__/issue-attachment-routes.test.ts | 3 +- .../issue-closed-workspace-routes.test.ts | 6 + .../issue-comment-cancel-routes.test.ts | 3 + .../issue-comment-reopen-routes.test.ts | 4 +- .../issue-dependency-wakeups-routes.test.ts | 6 + .../issue-stale-execution-lock-routes.test.ts | 6 + .../issue-tree-control-routes.test.ts | 21 + server/src/__tests__/issues-service.test.ts | 117 + .../low-trust-red-team-routes.test.ts | 10 +- server/src/__tests__/openapi-routes.test.ts | 13 + .../plugin-orchestration-apis.test.ts | 232 +- .../privacy-production-review.test.ts | 487 + .../projects-list-archived-routes.test.ts | 76 +- server/src/__tests__/public-mcp.test.ts | 48 +- server/src/__tests__/status-cards.test.ts | 59 + .../workspace-runtime-routes-authz.test.ts | 9 + server/src/realtime/live-events-ws.ts | 76 +- server/src/routes/activity.ts | 7 + server/src/routes/agents.ts | 66 +- server/src/routes/approvals.ts | 12 +- server/src/routes/assets.ts | 47 +- server/src/routes/attention.ts | 1 + server/src/routes/decision-training.ts | 16 +- server/src/routes/execution-workspaces.ts | 24 +- server/src/routes/file-resources.ts | 20 +- server/src/routes/issue-tree-control.ts | 38 +- server/src/routes/issues.ts | 765 +- server/src/routes/openapi.ts | 115 + server/src/routes/projects.ts | 201 +- server/src/routes/status-cards.ts | 15 +- server/src/services/activity-log.ts | 1 + server/src/services/activity.ts | 15 +- server/src/services/approvals.ts | 5 +- server/src/services/attention.ts | 94 +- server/src/services/authorization.ts | 327 +- server/src/services/chat-channels.ts | 5 + server/src/services/company-search-extract.ts | 7 +- server/src/services/company-search.ts | 13 +- server/src/services/decision-training.ts | 2 + server/src/services/execution-continuation.ts | 2 +- server/src/services/execution-workspaces.ts | 7 + server/src/services/heartbeat-run-privacy.ts | 169 + server/src/services/heartbeat.ts | 69 +- .../src/services/issue-thread-interactions.ts | 8 + server/src/services/issues.ts | 235 +- .../native-runtime/child-review-outcomes.ts | 3 + .../native-runtime/handoff-plan-context.ts | 5 +- .../paperclip-runner-tool-authority.test.ts | 7 +- .../paperclip-runner-tool-authority.ts | 22 +- server/src/services/plugin-host-services.ts | 260 +- server/src/services/projects.ts | 94 +- server/src/services/recovery/service.ts | 2 + server/src/services/status-cards.ts | 64 +- server/src/services/task-search.ts | 8 +- .../src/services/workspace-file-resources.ts | 32 +- 93 files changed, 11876 insertions(+), 4337 deletions(-) create mode 100644 doc/ISSUE-PRIVACY.md create mode 100644 packages/db/src/migrations/0314_private_task_draft_assets.sql rename packages/db/src/migrations/meta/{0309_snapshot.json => 0314_snapshot.json} (89%) create mode 100644 packages/db/src/private-task-draft-assets-migration.test.ts create mode 100644 server/src/__tests__/heartbeat-run-privacy-routes.test.ts create mode 100644 server/src/__tests__/issue-access-grants-routes.test.ts create mode 100644 server/src/__tests__/privacy-production-review.test.ts create mode 100644 server/src/services/heartbeat-run-privacy.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 91976e8e21b..e76e143b6a0 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -226,15 +226,15 @@ When authoring migrations or one-time backfills: - Split schema changes, index creation, and data backfill into separate phases so each step has clear locking and rollback behavior. - Treat the `check:migrations` CI gate as the enforcement backstop for these rules. If it flags a migration, rewrite the migration or add a suppression comment with the indexed predicate, batch bound, and reason the remaining scan is safe. -Private-task migration `0313` is explicitly allowlisted in the -Paperclip executor to run outside a file-wide transaction. Its idempotent +Private-task migrations `0313` and `0314` are explicitly allowlisted in the +Paperclip executor to run outside a file-wide transaction. Their idempotent keyset batches commit every 1,000 rows, and migration history is recorded only when all batches finish. The executor repairs invalid concurrent indexes on retry. A reserved connection holds a session advisory lock across all batch commits, so concurrent migrators recheck history only after the preceding runner finishes. Privacy triggers are replaced atomically. Bootstrap uses the same executor; other migrations remain transactional -per file. Apply this migration through `pnpm db:migrate` using a direct +per file. Apply these migrations through `pnpm db:migrate` using a direct connection, before enabling the new server and UI. ## Migration snapshots diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index fede68c1399..c8cca2fc2e9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -749,6 +749,36 @@ PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run No Docker or external database is required for this mode. +## Issue Privacy Rollout + +`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the canonical opt-in issue/project privacy predicate: + +- `enforce` (default): return private issues only to implicit principals, issue grantees, or private-project access members; hide private projects from non-members +- `shadow`: log structured would-deny decisions but preserve existing reads; use only for rollout diagnosis +- `off`: skip the predicate and shadow logging + +Grant checks read current database state; there is no positive privacy cache. +Sharing a task grants access to that task and its private descendants. Parents +and siblings need separate grants. An agent's access is intersected with its +run's responsible user's current access. + +`enforce` is the production default. `shadow` and `off` disclose private data and +are only temporary diagnostic overrides. The old branch's historical sign-off +does not qualify the current runtime. See [ISSUE-PRIVACY.md](./ISSUE-PRIVACY.md) +for the model, validation coverage, break-glass behavior, and residual risks. + +Issue-bound heartbeat runs inherit the same read predicate through the stored +`heartbeat_runs.scope_kind = 'issue'` and `issue_id` binding. Issue deletion +nulls the foreign key but preserves the explicit issue scope as a fail-closed +tombstone. Workspace-operation history is either deleted with its direct issue +or run binding, or remains bound to that fail-closed run tombstone, so it cannot +become company-level maintenance data through foreign-key nulling. In enforce mode, direct run detail, transcript, +event, log, and workspace-operation reads return `404` to non-members. Company +run-history and live-run lists retain a metadata-only row with timing, status, +token usage, and cost for budget oversight; issue identifiers, summaries, and +run content are omitted. Runs without an issue binding keep company-level +maintenance-run visibility. + ## Storage in Dev (Auto-Handled) For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at: diff --git a/doc/ISSUE-PRIVACY.md b/doc/ISSUE-PRIVACY.md new file mode 100644 index 00000000000..fde0b89e48c --- /dev/null +++ b/doc/ISSUE-PRIVACY.md @@ -0,0 +1,204 @@ +# Private tasks and projects + +Paperclip work is company-open by default. A task or project can instead be +marked private when its title, discussion, documents, attachments, work +products, or agent run traces should be limited to named participants. + +## Who can read private work + +A private task is readable by its responsible user, creating user, current +user or agent assignee, active task grantees, and members of its private +project. Restrictions flow down through children and run-created handoffs. Sharing or +assigning a task grants access to that task and its descendants; ancestors and +siblings require a separate grant. An assignment grant stays +active until it is explicitly revoked. Current assignment, ownership, private-project +membership, and inherited access remain independent reasons for access. The +sharing panel shows the source; revoking one grant does not remove other reasons. +Making a parent private protects existing descendants in the same transaction. +Moving a task under a private parent inherits privacy. A child cannot be made +public while it inherits a private boundary; making a parent public leaves its +existing private children private. + +Private projects have a separate access-member list. A task-level grant can +expose one task without exposing its containing private project. Direct reads +by non-members return `404`, and list, count, search, attention, status-card, +activity, run-history, and tree-control surfaces apply the same predicate. +Visible blocker and mention edges may show a locked identifier-only stub; they +never include the private task's title or content. + +Audit logs remain company-wide because they are the sanctioned oversight path. +They contain entity identifiers rather than private task content. Dashboard and +sidebar aggregate counts also remain company-wide: they may include private +work in totals, but do not expose titles, descriptions, or identifiers. The +issue list/count APIs themselves are viewer-filtered. + +## Audited break-glass + +Company owners and admins do not silently inherit private-task access. A normal +task detail request still returns `404`. When an owner or admin has a legitimate +emergency need, they must deliberately request the task with +`?breakGlass=true`. + +Every successful break-glass read writes both: + +- an `issue.break_glass_read` audit row containing the actor, task id, and time; +- a warning system notice on the private task so its owners can see that access occurred. + +The flag does not change the canonical read predicate, create a grant, or make +future reads implicit. Non-admin members and agents cannot use it. + +Cloud customer-success inspection is a separate operator capability. When an +operator enables it, the dedicated Cloud-signed permit can read private task +content within its company scope. Ordinary users, agent API keys, and plugins +cannot use that permit. Cloud enforces the inspection grant, expiration, +revocation, replay protection, and audit trail; this path does not create a task +grant or a task break-glass notice. Operators who require task ACLs on every +content read must leave `PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED` disabled. +See [Cloud customer-success inspection support](CUSTOMER-SUCCESS-INSPECTION.md). + +## Agent runs and shared-agent residual risk + +Issue-bound run detail, events, transcripts, logs, and workspace operations use +the task ACL. Run authority is the intersection of the agent and the run's responsible user. +Reusing an agent does not transfer a different user's private-task permission. +Company run lists retain only timing/status/token/cost metadata for +non-members so budget oversight continues without exposing task identity or run +content. + +Workspace operation records retain their original task and run sources even if +those entities are deleted or the operation is relinked. Deletion preserves the +audit record; unresolved sources make its details and log inaccessible. A +company-scoped run does not override a private task linked to the operation. + +> **Residual risk — trusted agents (`trust-agent`).** Paperclip enforces privacy +> on reads from the control plane, but an agent that legitimately processes a +> private task may write learned content into shared persistent memory, its home +> directory, an external tool, or a later public response. V1 deliberately +> trusts the agent not to exfiltrate that context. Use isolated execution +> workspaces and appropriately trusted agents for sensitive work; task ACLs are +> not a sandbox or data-loss-prevention system. + +## Plugins and notification/digest surfaces + +Plugins (including any Slack notifier or digest plugin) are **non-member +principals**: they hold no company membership, task grant, or private-project +access. Rather than an allowlist of individually patched methods, the plugin +host centralizes the synthetic non-member check into shared helpers +(`requirePluginReadableIssue`, `pluginReadableIssueIdsByIds`, +`pluginRedactRelationSummary`) and routes **every** issue-derived read through +them. The complete inventory of issue-derived reads and their disposition: + +- **Direct issue content** — `issues.list` (SQL predicate), `issues.get`, + `issues.listComments`, `issues.listAttachments`, `issues.getAttachmentContent`, + `issueDocuments.list`, `issueDocuments.get`: a private task is filtered from + list results, returns `null`/empty on soft reads, and reports "not found" + (indistinguishable from missing) on document reads. +- **Subtree / orchestration** — `issues.getSubtree` and + `issues.getOrchestrationSummary`: a private *root* reports "not found"; an + open root drops private descendants **before** any relation, document, run, + or assignee is fetched, so no private row or its metadata enters the payload. +- **Relationship metadata** — `issues.getRelations` (and the summaries echoed + by `issues.setBlockedBy` / `addBlockers` / `removeBlockers`, and the subtree + relation maps): blocker/blocks edges pointing at a private task are dropped, + recursing into nested terminal blockers, so its title, status, and assignee + never surface even on a public task. +- **Interactions** — `issues.listInteractions`: a private task yields no + interaction payloads. + +A synthetic `{ type: "none" }` actor resolves to the same public-only scope as +any other non-member. This is why a digest broadcast to a shared channel — +whose audience is non-members by definition — cannot carry private issue +content. Writes that operate on a task must also satisfy its read predicate; plugin +capabilities do not substitute for task access. Returned relationship summaries +remain redacted. + +There is intentionally **no plugin oversight exception**: unlike the +company-wide audit log, no plugin read path bypasses the predicate. A plugin +that must surface private work would need an explicit, separately designed +oversight capability; none exists today. + +> **Residual metadata disclosure.** A plugin still learns aggregate, +> content-free signals about private work that mirror the accepted +> dashboard/count disclosure: `issues.getOrchestrationSummary` returns +> company-wide `openBudgetIncidents`. Its `costs`/token roll-up is limited to +> the readable subtree, and unreadable relationship edges are omitted entirely. +> No private title, body, comment, attachment, identifier, relationship target, +> or locked-edge signal is exposed. + +## Rollout modes + +`PAPERCLIP_ISSUE_PRIVACY_MODE=enforce` is the default. `shadow` records +structured would-deny decisions without enforcing them and exists only for +rollout diagnosis. `off` disables the task predicate. Operators should not use +`shadow` or `off` when private-task confidentiality is required. + +The production gate includes positive and negative checks: an authorized reader +can read a shared child and its descendants, an unrelated reader cannot, and a +warm HTTP or live subscription loses that grant after revocation. Privacy grants +are read from the database without a cross-request positive cache. + +## Migration and current runtime integration + +Migration `0313_private_task_access.sql` adds the task/project ACL tables and +backfills privacy parent edges with indexed keyset batches. Run binding is derived +from native task identity, explicit issue identity, or legacy `issueId`/`taskId` context. Missing +source tasks remain issue-scoped tombstones. Context changes cannot turn those +runs into company-wide history. The migration is idempotent for preview installs; +existing explicit grants are preserved. Operators upgrading an unreleased preview +should inspect root grants created under its former whole-tree sharing semantics. + +Migration `0314_private_task_draft_assets.sql` accompanies enforcement and binds +historical inline images to their first owned task. Unbound drafts remain +uploader-only under the new asset-content guard. + +Both privacy migrations use the Paperclip migration executor's explicit +nontransactional path. Indexes on existing tables build concurrently; invalid builds are repaired on +retry. DDL statements commit individually and each 1,000-row +keyset batch commits before advancing, releasing schema and row locks. The +migration journal is written only after all batches succeed; an interruption +leaves the migration pending and its idempotent statements can be replayed. +Bootstrap uses the same executor, and other migrations retain a transaction per +file. Apply these migrations with `pnpm db:migrate`, before enabling the new +server or private-task UI. + +Private output uses the same predicate on native tool searches and task context, +linked approvals, training exports, execution workspace APIs, stored run-response +assets, and WebSocket delivery. Workspace access requires permission for every +linked task. Chat publications recheck recipient identity and task access at the +provider boundary: private output is withheld from shared channels and from DMs +whose known recipients no longer qualify. This does not revoke Gmail or other +connection credentials; connection permissions continue to apply independently. + +## CI leak-test inventory + +The privacy regression gate is part of the normal server Vitest suite. Its +surface coverage is intentionally distributed beside the routes and services +it protects: + +| Surface | Non-member regression coverage | +| --- | --- | +| Downward sharing, responsible-user intersection, mutation denial, provenance, live revocation | `privacy-production-review.test.ts` | +| Task detail, list, count, grants, documents, work products | `issue-access-grants-routes.test.ts`, `company-search-service.test.ts` | +| Search and machine extract | `company-search-service.test.ts`, `company-search-extract-service.test.ts` | +| Attention feed | `attention-service.test.ts` | +| Status-card hydrate and dry-run | `status-cards.test.ts` | +| Activity stream | `activity-service.test.ts`, `activity-routes.test.ts` | +| Run list, live run, detail, transcript, events, logs, operation history | `heartbeat-run-privacy-routes.test.ts` | +| Tree holds and tree control | `issue-tree-control-routes.test.ts` | +| Blocker and mention identifier-only stubs | `issue-access-grants-routes.test.ts` | +| Attachment content | `issue-attachment-routes.test.ts` | +| Private-project list and direct read | `projects-list-archived-routes.test.ts` | +| Plugin issue reads (list, get, comments, attachments, orchestration, subtree, relations, interactions, documents) | `plugin-orchestration-apis.test.ts` | + +Adding a new task-derived read surface requires a non-member fixture in this +gate before the surface can ship. + +Project privacy management belongs to its recorded creator (the responsible user +for agent-created projects), the personal-project owner, and administrators. +Project read membership alone never allows publishing the project or changing +its audience. Legacy projects recover ownership from their creation audit event; +when that evidence is missing, an administrator manages their privacy. + +### Task privacy management hints + +`GET /api/issues/:id/privacy-constraints` is available only to a principal who can read and manage that task. It returns the blocked scope kind and whether publishing leaves a personal project. It does not return protected parent or project names, identifiers, owners, or contents. This lets task owners manage their task without requiring access to its surrounding project. Visibility writes still enforce the canonical rules under the privacy-tree lock; these hints do not authorize a write. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 1efb6cbfc8c..5fc5c9a361d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1. | Company model | Company is first-order; all business entities are company-scoped | | Board | Single human board operator per deployment | | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy | +| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. | @@ -220,6 +220,8 @@ Invariant: at least one root `company` level goal per company. - `goal_id` uuid fk `goals.id` null - `name` text not null - `description` text null +- `visibility` enum-like text: `open | private`, default `open` +- `personal_owner_user_id` text null; unique with `company_id` when present - `status` enum: `backlog | planned | in_progress | completed | cancelled` - `lead_agent_id` uuid fk `agents.id` null - `target_date` date null @@ -258,6 +260,9 @@ retain their titles and default to no generation request. - `project_workspace_id` uuid fk `project_workspaces.id` null - `goal_id` uuid fk `goals.id` null - `parent_id` uuid fk `issues.id` null +- `visibility` enum-like text: `open | private`, default `open` +- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata +- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs - `title` text not null - `description` text null - `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled` @@ -310,11 +315,39 @@ Invariants: - `author_user_id` uuid fk `users.id` null - `body` text not null +## 7.7.1 `issue_access_grants` + +- `issue_id` uuid fk `issues.id` not null +- `subject_type` enum-like text: `user | agent` +- `subject_id` text not null +- `source` enum-like text: `explicit | assignment | project` +- `granted_by_user_id` text null +- `granted_by_agent_id` uuid fk `agents.id` null +- `created_at` timestamptz not null +- `revoked_at` timestamptz null + +An issue is readable when it is open outside a private project, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), the actor has an unrevoked grant on the issue or a private ancestor, or the actor is an access member of its private project. This is one canonical predicate shared by detail, list, count, search, and run-derived reads. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access. Sharing a child covers that child and descendants; ancestors and siblings require separate access. Agent reads also require the responsible user to qualify. A private parent protects existing and newly created descendants; making a parent public does not publish private descendants. + +## 7.7.2 `project_access_members` + +Authorization membership for private projects, distinct from the personal-sidebar `project_memberships` table: + +- `id` uuid pk +- `company_id` uuid fk `companies.id` not null +- `project_id` uuid fk `projects.id` not null +- `subject_type` enum-like text: `user | agent` +- `subject_id` text not null +- `created_at` timestamptz not null +- unique `(project_id, subject_type, subject_id)` + +Private projects are absent from list and search results for non-members, and direct reads return `404`. An issue-level grant can still expose its issue without exposing the containing project. A user's `My private tasks` project is created lazily and concurrency-safely on their first unprojected private task; the owning user is its initial and non-removable project member. An agent-created private root also grants that creator issue-level access while retaining the responsible user as the human principal. + ## 7.8 `heartbeat_runs` - `id` uuid pk - `company_id` uuid fk not null - `agent_id` uuid fk not null +- `issue_id` uuid fk `issues.id` null; first-class binding for issue-scoped run confidentiality - `invocation_source` enum: `scheduler | manual | callback` - `status` enum: `queued | running | succeeded | failed | cancelled | timed_out` - `started_at` timestamptz null @@ -417,6 +450,7 @@ Operational policy: - `cost_events(company_id, occurred_at)` - `cost_events(company_id, agent_id, occurred_at)` - `heartbeat_runs(company_id, agent_id, started_at desc)` +- `heartbeat_runs(company_id, issue_id, created_at)` - `approvals(company_id, status, type)` - `activity_log(company_id, created_at desc)` - `assets(company_id, created_at desc)` @@ -706,7 +740,7 @@ changes so both agent and board edits are visible in the issue activity stream. ## 9.4 Permission Terminology and Default Visibility Rule -Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. +Paperclip keeps company-scoped visibility as the default. The task-ACL foundation adds opt-in private issues while preserving `open` for all existing and unspecified issues. The approved term set is: @@ -720,8 +754,8 @@ The approved term set is: ## 9.5 Core V1 Rule: what “private” means - A **private marker** on an agent profile (where represented) does **not** make company-visible work private. -- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default. -- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls. +- Work objects remain company-open by default. An issue or project can opt into the shared private-read predicate. +- Organization-wide custom ACL policy remains deferred to Pro/Enterprise controls. ## 9.6 V1 vs Pro/Enterprise Controls (recommended target split) @@ -732,7 +766,7 @@ The approved term set is: | Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces | | Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement | | Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes | -| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels | +| Work-object visibility | Company-open by default; opt-in private issues/projects with explicit user or agent access | Policy-driven ACLs and reviewer-only channels | | Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation | | Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX | | Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces | @@ -1139,6 +1173,9 @@ Invites tab does not hide this Cloud action. - `POST /companies/:companyId/issues` - `GET /issues/:issueId` - `PATCH /issues/:issueId` +- `GET /issues/:issueId/access-grants` +- `POST /issues/:issueId/access-grants` +- `POST /issues/:issueId/access-grants/:grantId/revoke` - `GET /issues/:issueId/documents` - `GET /issues/:issueId/documents/:key` - `PUT /issues/:issueId/documents/:key` @@ -1198,6 +1235,11 @@ assignee/run ownership checks, and the `issue.released` activity event still app - `POST /companies/:companyId/projects` - `GET /projects/:projectId` - `PATCH /projects/:projectId` +- `GET /projects/:projectId/access-members` +- `POST /projects/:projectId/access-members` +- `DELETE /projects/:projectId/access-members/:memberId` + +Project list, search, direct reads, and nested project routes apply private-project access. Non-members receive the same not-found behavior as missing projects. ## 10.6 Current-user Resource Memberships diff --git a/doc/SPEC.md b/doc/SPEC.md index 6731f25c509..ccdf5dcb721 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -178,7 +178,7 @@ Hierarchical reporting structure. CEO at top, reports cascade down. **Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control. -Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place. +Visibility settings on an agent profile do not control task privacy. Work is company-open by default; opt-in private tasks and projects use centralized authorization as specified in `SPEC-implementation.md` and `ISSUE-PRIVACY.md`. Sharing a child grants access to it and its descendants. Ancestors and siblings require separate access. Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what. @@ -326,6 +326,8 @@ recipients must be valid and authorized in the company before a question is saved. See `SPEC-implementation.md` §9.8.1 for the resolver contract. +Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with the task's selected visibility. New execution tasks are ordinary project tasks, not structural children of the conversation; private-source restrictions still flow through their run provenance. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`. + ### Implications - An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in diff --git a/packages/db/src/migrations/0314_private_task_draft_assets.sql b/packages/db/src/migrations/0314_private_task_draft_assets.sql new file mode 100644 index 00000000000..a7591cf72f9 --- /dev/null +++ b/packages/db/src/migrations/0314_private_task_draft_assets.sql @@ -0,0 +1,28 @@ +-- Runs through the Paperclip migration executor outside a file-wide transaction. +-- Each idempotent keyset batch commits before advancing; history is recorded last. +-- Bind historical inline draft images to their first owned task. Unbound drafts +-- remain uploader-only. Attachment deletion never makes the draft company-open. +DO $$ +DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid; +BEGIN + LOOP + SELECT max(id::text)::uuid INTO next_id FROM ( + SELECT id FROM assets WHERE id > cursor_id AND object_key LIKE company_id::text || '/assets/issues/drafts/%' + ORDER BY id LIMIT 1000 + ) batch; + EXIT WHEN next_id IS NULL; + INSERT INTO issue_attachments (company_id, issue_id, asset_id) + SELECT a.company_id, first_issue.id, a.id FROM assets a + CROSS JOIN LATERAL ( + SELECT i.id FROM issues i WHERE i.company_id = a.company_id + AND (i.created_by_user_id = a.created_by_user_id OR i.responsible_user_id = a.created_by_user_id + OR i.created_by_agent_id = a.created_by_agent_id) + AND strpos(coalesce(i.description, ''), '/api/assets/' || a.id::text || '/content') > 0 + ORDER BY i.created_at, i.id LIMIT 1 + ) first_issue + WHERE a.id > cursor_id AND a.id <= next_id AND a.object_key LIKE a.company_id::text || '/assets/issues/drafts/%' + ON CONFLICT DO NOTHING; + cursor_id := next_id; + COMMIT; + END LOOP; +END $$; diff --git a/packages/db/src/migrations/meta/0309_snapshot.json b/packages/db/src/migrations/meta/0314_snapshot.json similarity index 89% rename from packages/db/src/migrations/meta/0309_snapshot.json rename to packages/db/src/migrations/meta/0314_snapshot.json index 73605603da1..52d8ef6d49a 100644 --- a/packages/db/src/migrations/meta/0309_snapshot.json +++ b/packages/db/src/migrations/meta/0314_snapshot.json @@ -1,9 +1,1079 @@ { - "id": "593431cc-d6a8-4daf-a3e4-9e3c892d9419", - "prevId": "00d98f2c-c054-4ca3-91eb-08211bdf418d", + "id": "a1aefefe-8911-4583-9c52-7c658e46e9b4", + "prevId": "adf783f2-c391-4fd5-b93a-d85e6d3c60a0", "version": "7", "dialect": "postgresql", "tables": { + "public.accounting_runtime_baselines": { + "name": "accounting_runtime_baselines", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_cents": { + "name": "cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_runtime_baselines_company_idx": { + "name": "accounting_runtime_baselines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "accounting_runtime_baselines_agent_id_agents_id_fk": { + "name": "accounting_runtime_baselines_agent_id_agents_id_fk", + "tableFrom": "accounting_runtime_baselines", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "accounting_runtime_baselines_company_id_companies_id_fk": { + "name": "accounting_runtime_baselines_company_id_companies_id_fk", + "tableFrom": "accounting_runtime_baselines", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_invoice_lines": { + "name": "billing_invoice_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "billing_invoice_lines_identity_idx": { + "name": "billing_invoice_lines_identity_idx", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "billing_invoice_lines_company_idx": { + "name": "billing_invoice_lines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "billing_invoice_lines_company_id_companies_id_fk": { + "name": "billing_invoice_lines_company_id_companies_id_fk", + "tableFrom": "billing_invoice_lines", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "billing_invoice_lines_invoice_id_billing_invoices_id_fk": { + "name": "billing_invoice_lines_invoice_id_billing_invoices_id_fk", + "tableFrom": "billing_invoice_lines", + "columnsFrom": [ + "invoice_id" + ], + "tableTo": "billing_invoices", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "billing_invoice_lines_cost_event_id_cost_events_id_fk": { + "name": "billing_invoice_lines_cost_event_id_cost_events_id_fk", + "tableFrom": "billing_invoice_lines", + "columnsFrom": [ + "cost_event_id" + ], + "tableTo": "cost_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_invoice_lines_amount_check": { + "name": "billing_invoice_lines_amount_check", + "value": "\"billing_invoice_lines\".\"amount_cents\" >= 0" + }, + "billing_invoice_lines_kind_check": { + "name": "billing_invoice_lines_kind_check", + "value": "\"billing_invoice_lines\".\"kind\" in ('inference', 'fee', 'credit')" + } + }, + "isRLSEnabled": false + }, + "public.billing_invoices": { + "name": "billing_invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imported_by": { + "name": "imported_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_invoices_identity_idx": { + "name": "billing_invoices_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "billing_invoices_company_id_companies_id_fk": { + "name": "billing_invoices_company_id_companies_id_fk", + "tableFrom": "billing_invoices", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_reservations": { + "name": "budget_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'held'" + }, + "provider_started_at": { + "name": "provider_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_reservations_run_idx": { + "name": "budget_reservations_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "budget_reservations_active_idx": { + "name": "budget_reservations_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"budget_reservations\".\"state\" = 'held'", + "concurrently": false + } + }, + "foreignKeys": { + "budget_reservations_company_id_companies_id_fk": { + "name": "budget_reservations_company_id_companies_id_fk", + "tableFrom": "budget_reservations", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "budget_reservations_run_id_heartbeat_runs_id_fk": { + "name": "budget_reservations_run_id_heartbeat_runs_id_fk", + "tableFrom": "budget_reservations", + "columnsFrom": [ + "run_id" + ], + "tableTo": "heartbeat_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "budget_reservations_agent_id_agents_id_fk": { + "name": "budget_reservations_agent_id_agents_id_fk", + "tableFrom": "budget_reservations", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "budget_reservations_state_check": { + "name": "budget_reservations_state_check", + "value": "\"budget_reservations\".\"state\" in ('held', 'settled', 'released')" + }, + "budget_reservations_amount_check": { + "name": "budget_reservations_amount_check", + "value": "\"budget_reservations\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cost_adjustments": { + "name": "cost_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_line_id": { + "name": "invoice_line_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_cents": { + "name": "previous_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "corrected_cents": { + "name": "corrected_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "previous_status": { + "name": "previous_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_pricing": { + "name": "previous_pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_adjustments_receipt_idx": { + "name": "cost_adjustments_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cost_adjustments_event_idx": { + "name": "cost_adjustments_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cost_adjustments_invoice_line_idx": { + "name": "cost_adjustments_invoice_line_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "cost_adjustments_company_id_companies_id_fk": { + "name": "cost_adjustments_company_id_companies_id_fk", + "tableFrom": "cost_adjustments", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "cost_adjustments_cost_event_id_cost_events_id_fk": { + "name": "cost_adjustments_cost_event_id_cost_events_id_fk", + "tableFrom": "cost_adjustments", + "columnsFrom": [ + "cost_event_id" + ], + "tableTo": "cost_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk": { + "name": "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk", + "tableFrom": "cost_adjustments", + "columnsFrom": [ + "invoice_line_id" + ], + "tableTo": "billing_invoice_lines", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cost_adjustments_amount_check": { + "name": "cost_adjustments_amount_check", + "value": "\"cost_adjustments\".\"previous_cents\" >= 0 and \"cost_adjustments\".\"corrected_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.provider_billing_snapshots": { + "name": "provider_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_billing_snapshots_identity_idx": { + "name": "provider_billing_snapshots_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "provider_billing_snapshots_company_id_companies_id_fk": { + "name": "provider_billing_snapshots_company_id_companies_id_fk", + "tableFrom": "provider_billing_snapshots", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "provider_billing_snapshots_amount_check": { + "name": "provider_billing_snapshots_amount_check", + "value": "\"provider_billing_snapshots\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.run_usage_receipts": { + "name": "run_usage_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_json": { + "name": "receipt_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_usage_receipts_source_sequence_idx": { + "name": "run_usage_receipts_source_sequence_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "run_usage_receipts_run_latest_idx": { + "name": "run_usage_receipts_run_latest_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "run_usage_receipts_company_id_companies_id_fk": { + "name": "run_usage_receipts_company_id_companies_id_fk", + "tableFrom": "run_usage_receipts", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "run_usage_receipts_run_id_heartbeat_runs_id_fk": { + "name": "run_usage_receipts_run_id_heartbeat_runs_id_fk", + "tableFrom": "run_usage_receipts", + "columnsFrom": [ + "run_id" + ], + "tableTo": "heartbeat_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "run_usage_receipts_positive_sequence": { + "name": "run_usage_receipts_positive_sequence", + "value": "\"run_usage_receipts\".\"sequence\" > 0" + } + }, + "isRLSEnabled": false + }, "public.activity_log": { "name": "activity_log", "schema": "", @@ -102,9 +1172,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "activity_log_company_agent_created_idx": { "name": "activity_log_company_agent_created_idx", @@ -129,9 +1199,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "activity_log_company_responsible_user_created_idx": { "name": "activity_log_company_responsible_user_created_idx", @@ -156,9 +1226,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "activity_log_run_id_idx": { "name": "activity_log_run_id_idx", @@ -171,9 +1241,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "activity_log_entity_type_id_idx": { "name": "activity_log_entity_type_id_idx", @@ -192,50 +1262,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "activity_log_company_id_companies_id_fk": { "name": "activity_log_company_id_companies_id_fk", "tableFrom": "activity_log", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "activity_log_agent_id_agents_id_fk": { "name": "activity_log_agent_id_agents_id_fk", "tableFrom": "activity_log", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "activity_log_run_id_heartbeat_runs_id_fk": { "name": "activity_log_run_id_heartbeat_runs_id_fk", "tableFrom": "activity_log", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -391,9 +1461,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "adapter_auth_sessions_company_owner_adapter_active_uq": { "name": "adapter_auth_sessions_company_owner_adapter_active_uq", @@ -418,10 +1488,10 @@ } ], "isUnique": true, - "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')", + "concurrently": false }, "adapter_auth_sessions_public_session_id_uq": { "name": "adapter_auth_sessions_public_session_id_uq", @@ -434,9 +1504,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "adapter_auth_sessions_environment_idx": { "name": "adapter_auth_sessions_environment_idx", @@ -449,9 +1519,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "adapter_auth_sessions_expires_idx": { "name": "adapter_auth_sessions_expires_idx", @@ -464,9 +1534,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "adapter_auth_sessions_provider_lease_idx": { "name": "adapter_auth_sessions_provider_lease_idx", @@ -479,37 +1549,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "adapter_auth_sessions_company_id_companies_id_fk": { "name": "adapter_auth_sessions_company_id_companies_id_fk", "tableFrom": "adapter_auth_sessions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "adapter_auth_sessions_environment_id_environments_id_fk": { "name": "adapter_auth_sessions_environment_id_environments_id_fk", "tableFrom": "adapter_auth_sessions", - "tableTo": "environments", "columnsFrom": [ "environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -597,9 +1667,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_api_keys_company_agent_idx": { "name": "agent_api_keys_company_agent_idx", @@ -618,37 +1688,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_api_keys_agent_id_agents_id_fk": { "name": "agent_api_keys_agent_id_agents_id_fk", "tableFrom": "agent_api_keys", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_api_keys_company_id_companies_id_fk": { "name": "agent_api_keys_company_id_companies_id_fk", "tableFrom": "agent_api_keys", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -657,6 +1727,194 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_commentary": { + "name": "agent_commentary", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_commentary_run_key_uq": { + "name": "agent_commentary_run_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_commentary_company_kind_created_idx": { + "name": "agent_commentary_company_kind_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_commentary_company_id_companies_id_fk": { + "name": "agent_commentary_company_id_companies_id_fk", + "tableFrom": "agent_commentary", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_commentary_agent_id_agents_id_fk": { + "name": "agent_commentary_agent_id_agents_id_fk", + "tableFrom": "agent_commentary", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_commentary_run_id_heartbeat_runs_id_fk": { + "name": "agent_commentary_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_commentary", + "columnsFrom": [ + "run_id" + ], + "tableTo": "heartbeat_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_commentary_issue_id_issues_id_fk": { + "name": "agent_commentary_issue_id_issues_id_fk", + "tableFrom": "agent_commentary", + "columnsFrom": [ + "issue_id" + ], + "tableTo": "issues", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_commentary_kind_check": { + "name": "agent_commentary_kind_check", + "value": "\"agent_commentary\".\"kind\" in ('complaint', 'suggestion')" + } + }, + "isRLSEnabled": false + }, "public.agent_config_revisions": { "name": "agent_config_revisions", "schema": "", @@ -756,9 +2014,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_config_revisions_agent_created_idx": { "name": "agent_config_revisions_agent_created_idx", @@ -777,50 +2035,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_config_revisions_company_id_companies_id_fk": { "name": "agent_config_revisions_company_id_companies_id_fk", "tableFrom": "agent_config_revisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_config_revisions_agent_id_agents_id_fk": { "name": "agent_config_revisions_agent_id_agents_id_fk", "tableFrom": "agent_config_revisions", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "agent_config_revisions_created_by_agent_id_agents_id_fk": { "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "agent_config_revisions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -882,17 +2140,17 @@ "agent_identity_keys_owner_fk": { "name": "agent_identity_keys_owner_fk", "tableFrom": "agent_identity_keys", - "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], + "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -947,33 +2205,33 @@ "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk": { "name": "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk", "tableFrom": "agent_instruction_heads", - "tableTo": "agent_instruction_revisions", "columnsFrom": [ "company_id", "agent_id", "entry_file", "revision_id" ], + "tableTo": "agent_instruction_revisions", "columnsTo": [ "company_id", "agent_id", "entry_file", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agent_instruction_heads_identity_uq": { "name": "agent_instruction_heads_identity_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "agent_id", "entry_file" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -1119,39 +2377,39 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk": { "name": "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk", "tableFrom": "agent_instruction_revisions", - "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], + "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agent_instruction_revisions_identity_uq": { "name": "agent_instruction_revisions_identity_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "agent_id", "entry_file", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -1311,9 +2569,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_instruction_copies_agent_idx": { "name": "agent_instruction_copies_agent_idx", @@ -1338,39 +2596,39 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk": { "name": "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk", "tableFrom": "agent_instruction_working_copies", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk": { "name": "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk", "tableFrom": "agent_instruction_working_copies", - "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], + "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1454,9 +2712,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_memberships_company_user_starred_idx": { "name": "agent_memberships_company_user_starred_idx", @@ -1481,9 +2739,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_memberships_agent_idx": { "name": "agent_memberships_agent_idx", @@ -1496,9 +2754,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_memberships_company_user_agent_uq": { "name": "agent_memberships_company_user_agent_uq", @@ -1523,37 +2781,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_memberships_company_id_companies_id_fk": { "name": "agent_memberships_company_id_companies_id_fk", "tableFrom": "agent_memberships", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "agent_memberships_agent_id_agents_id_fk": { "name": "agent_memberships_agent_id_agents_id_fk", "tableFrom": "agent_memberships", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1632,7 +2890,7 @@ }, "total_cost_cents": { "name": "total_cost_cents", - "type": "bigint", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -1676,9 +2934,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_runtime_state_company_updated_idx": { "name": "agent_runtime_state_company_updated_idx", @@ -1697,37 +2955,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_runtime_state_agent_id_agents_id_fk": { "name": "agent_runtime_state_agent_id_agents_id_fk", "tableFrom": "agent_runtime_state", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_runtime_state_company_id_companies_id_fk": { "name": "agent_runtime_state_company_id_companies_id_fk", "tableFrom": "agent_runtime_state", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -1841,9 +3099,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_session_goal_actions_company_status_created_idx": { "name": "agent_session_goal_actions_company_status_created_idx", @@ -1868,9 +3126,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_session_goal_actions_session_created_idx": { "name": "agent_session_goal_actions_session_created_idx", @@ -1889,37 +3147,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_session_goal_actions_company_id_companies_id_fk": { "name": "agent_session_goal_actions_company_id_companies_id_fk", "tableFrom": "agent_session_goal_actions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_session_goal_actions_session_id_agent_task_sessions_id_fk": { "name": "agent_session_goal_actions_session_id_agent_task_sessions_id_fk", "tableFrom": "agent_session_goal_actions", - "tableTo": "agent_task_sessions", "columnsFrom": [ "session_id" ], + "tableTo": "agent_task_sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -2081,9 +3339,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_task_sessions_company_agent_updated_idx": { "name": "agent_task_sessions_company_agent_updated_idx", @@ -2108,9 +3366,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_task_sessions_company_task_updated_idx": { "name": "agent_task_sessions_company_task_updated_idx", @@ -2135,50 +3393,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_task_sessions_company_id_companies_id_fk": { "name": "agent_task_sessions_company_id_companies_id_fk", "tableFrom": "agent_task_sessions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_task_sessions_agent_id_agents_id_fk": { "name": "agent_task_sessions_agent_id_agents_id_fk", "tableFrom": "agent_task_sessions", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", "tableFrom": "agent_task_sessions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "last_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -2336,9 +3594,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_wakeup_requests_company_requested_idx": { "name": "agent_wakeup_requests_company_requested_idx", @@ -2357,9 +3615,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_wakeup_requests_agent_requested_idx": { "name": "agent_wakeup_requests_agent_requested_idx", @@ -2378,9 +3636,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agent_wakeup_requests_review_path_recovery_idempotency_uq": { "name": "agent_wakeup_requests_review_path_recovery_idempotency_uq", @@ -2399,10 +3657,10 @@ } ], "isUnique": true, - "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_review_path_lost:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_review_path_lost:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", + "concurrently": false }, "agent_wakeup_requests_disposition_repair_idempotency_uq": { "name": "agent_wakeup_requests_disposition_repair_idempotency_uq", @@ -2421,10 +3679,10 @@ } ], "isUnique": true, - "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_disposition_repair:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_disposition_repair:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", + "concurrently": false }, "agent_wakeup_requests_question_response_delivery_idempotency_uq": { "name": "agent_wakeup_requests_question_response_delivery_idempotency_uq", @@ -2443,10 +3701,10 @@ } ], "isUnique": true, - "where": "(\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%' OR \"agent_wakeup_requests\".\"idempotency_key\" LIKE 'interaction:%') AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "(\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%' OR \"agent_wakeup_requests\".\"idempotency_key\" LIKE 'interaction:%') AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", + "concurrently": false }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", @@ -2465,10 +3723,10 @@ } ], "isUnique": true, - "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'connection-intent:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'connection-intent:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", + "concurrently": false }, "agent_wakeup_requests_tool_action_delivery_uq": { "name": "agent_wakeup_requests_tool_action_delivery_uq", @@ -2487,10 +3745,10 @@ } ], "isUnique": true, - "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'tool-action-response:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'tool-action-response:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", + "concurrently": false }, "agent_wakeup_requests_chat_completion_uq": { "name": "agent_wakeup_requests_chat_completion_uq", @@ -2509,10 +3767,10 @@ } ], "isUnique": true, - "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'chat-completion:%'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'chat-completion:%'", + "concurrently": false }, "agent_wakeup_requests_company_payload_issue_idx": { "name": "agent_wakeup_requests_company_payload_issue_idx", @@ -2525,43 +3783,43 @@ }, { "expression": "(\"payload\" ->> 'issueId')", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agent_wakeup_requests_company_id_companies_id_fk": { "name": "agent_wakeup_requests_company_id_companies_id_fk", "tableFrom": "agent_wakeup_requests", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agent_wakeup_requests_agent_id_agents_id_fk": { "name": "agent_wakeup_requests_agent_id_agents_id_fk", "tableFrom": "agent_wakeup_requests", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -2671,9 +3929,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -2748,9 +4012,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agents_company_reports_to_idx": { "name": "agents_company_reports_to_idx", @@ -2769,9 +4033,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "agents_company_default_environment_idx": { "name": "agents_company_default_environment_idx", @@ -2790,61 +4054,61 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "agents_company_id_companies_id_fk": { "name": "agents_company_id_companies_id_fk", "tableFrom": "agents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agents_reports_to_agents_id_fk": { "name": "agents_reports_to_agents_id_fk", "tableFrom": "agents", - "tableTo": "agents", "columnsFrom": [ "reports_to" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "agents_default_environment_id_environments_id_fk": { "name": "agents_default_environment_id_environments_id_fk", "tableFrom": "agents", - "tableTo": "environments", "columnsFrom": [ "default_environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agents_company_id_uq": { "name": "agents_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -2930,39 +4194,39 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "ai_connection_defaults_company_id_companies_id_fk": { "name": "ai_connection_defaults_company_id_companies_id_fk", "tableFrom": "ai_connection_defaults", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_defaults_company_grant_fk": { "name": "ai_connection_defaults_company_grant_fk", "tableFrom": "ai_connection_defaults", - "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -3035,54 +4299,54 @@ "ai_connection_pools_id_tool_connections_id_fk": { "name": "ai_connection_pools_id_tool_connections_id_fk", "tableFrom": "ai_connection_pools", - "tableTo": "tool_connections", "columnsFrom": [ "id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_pools_company_id_companies_id_fk": { "name": "ai_connection_pools_company_id_companies_id_fk", "tableFrom": "ai_connection_pools", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_pools_company_connection_fk": { "name": "ai_connection_pools_company_connection_fk", "tableFrom": "ai_connection_pools", - "tableTo": "tool_connections", "columnsFrom": [ "company_id", "id" ], + "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "ai_connection_pools_company_id_uq": { "name": "ai_connection_pools_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -3124,43 +4388,43 @@ "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk": { "name": "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk", "tableFrom": "ai_connection_router_cursors", - "tableTo": "ai_connection_pools", "columnsFrom": [ "pool_id" ], + "tableTo": "ai_connection_pools", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_router_cursors_company_id_companies_id_fk": { "name": "ai_connection_router_cursors_company_id_companies_id_fk", "tableFrom": "ai_connection_router_cursors", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_router_cursors_company_pool_fk": { "name": "ai_connection_router_cursors_company_pool_fk", "tableFrom": "ai_connection_router_cursors", - "tableTo": "ai_connection_pools", "columnsFrom": [ "company_id", "pool_id" ], + "tableTo": "ai_connection_pools", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -3254,80 +4518,80 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "ai_connection_task_pins_company_id_companies_id_fk": { "name": "ai_connection_task_pins_company_id_companies_id_fk", "tableFrom": "ai_connection_task_pins", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk": { "name": "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk", "tableFrom": "ai_connection_task_pins", - "tableTo": "ai_connection_pools", "columnsFrom": [ "pool_id" ], + "tableTo": "ai_connection_pools", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_task_pins_agent_id_agents_id_fk": { "name": "ai_connection_task_pins_agent_id_agents_id_fk", "tableFrom": "ai_connection_task_pins", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_task_pins_company_pool_fk": { "name": "ai_connection_task_pins_company_pool_fk", "tableFrom": "ai_connection_task_pins", - "tableTo": "ai_connection_pools", "columnsFrom": [ "company_id", "pool_id" ], + "tableTo": "ai_connection_pools", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_connection_task_pins_company_agent_fk": { "name": "ai_connection_task_pins_company_agent_fk", "tableFrom": "ai_connection_task_pins", - "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], + "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -3403,39 +4667,39 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "ai_provider_defaults_company_id_companies_id_fk": { "name": "ai_provider_defaults_company_id_companies_id_fk", "tableFrom": "ai_provider_defaults", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "ai_provider_defaults_company_grant_fk": { "name": "ai_provider_defaults_company_grant_fk", "tableFrom": "ai_provider_defaults", - "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -3576,9 +4840,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "approval_comments_approval_idx": { "name": "approval_comments_approval_idx", @@ -3591,9 +4855,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "approval_comments_approval_created_idx": { "name": "approval_comments_approval_created_idx", @@ -3612,50 +4876,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "approval_comments_company_id_companies_id_fk": { "name": "approval_comments_company_id_companies_id_fk", "tableFrom": "approval_comments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "approval_comments_approval_id_approvals_id_fk": { "name": "approval_comments_approval_id_approvals_id_fk", "tableFrom": "approval_comments", - "tableTo": "approvals", "columnsFrom": [ "approval_id" ], + "tableTo": "approvals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "approval_comments_author_agent_id_agents_id_fk": { "name": "approval_comments_author_agent_id_agents_id_fk", "tableFrom": "approval_comments", - "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -3769,37 +5033,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "approvals_company_id_companies_id_fk": { "name": "approvals_company_id_companies_id_fk", "tableFrom": "approvals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "approvals_requested_by_agent_id_agents_id_fk": { "name": "approvals_requested_by_agent_id_agents_id_fk", "tableFrom": "approvals", - "tableTo": "agents", "columnsFrom": [ "requested_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -3906,9 +5170,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "assets_company_provider_idx": { "name": "assets_company_provider_idx", @@ -3927,9 +5191,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "assets_company_object_key_uq": { "name": "assets_company_object_key_uq", @@ -3948,37 +5212,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "assets_company_id_companies_id_fk": { "name": "assets_company_id_companies_id_fk", "tableFrom": "assets", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "assets_created_by_agent_id_agents_id_fk": { "name": "assets_created_by_agent_id_agents_id_fk", "tableFrom": "assets", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -4094,24 +5358,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", - "tableTo": "user", "columnsFrom": [ "user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -4178,15 +5442,15 @@ "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", - "tableTo": "user", "columnsFrom": [ "user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -4367,9 +5631,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "board_api_keys_user_idx": { "name": "board_api_keys_user_idx", @@ -4382,24 +5646,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "board_api_keys_user_id_user_id_fk": { "name": "board_api_keys_user_id_user_id_fk", "tableFrom": "board_api_keys", - "tableTo": "user", "columnsFrom": [ "user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -4470,9 +5734,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "browser_use_browsers_session_idx": { "name": "browser_use_browsers_session_idx", @@ -4485,37 +5749,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "browser_use_browsers_company_id_companies_id_fk": { "name": "browser_use_browsers_company_id_companies_id_fk", "tableFrom": "browser_use_browsers", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_browsers_session_id_browser_use_sessions_id_fk": { "name": "browser_use_browsers_session_id_browser_use_sessions_id_fk", "tableFrom": "browser_use_browsers", - "tableTo": "browser_use_sessions", "columnsFrom": [ "session_id" ], + "tableTo": "browser_use_sessions", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -4637,9 +5901,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "browser_use_runs_provider_uq": { "name": "browser_use_runs_provider_uq", @@ -4652,9 +5916,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "browser_use_runs_session_idx": { "name": "browser_use_runs_session_idx", @@ -4667,50 +5931,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "browser_use_runs_company_id_companies_id_fk": { "name": "browser_use_runs_company_id_companies_id_fk", "tableFrom": "browser_use_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_runs_session_id_browser_use_sessions_id_fk": { "name": "browser_use_runs_session_id_browser_use_sessions_id_fk", "tableFrom": "browser_use_runs", - "tableTo": "browser_use_sessions", "columnsFrom": [ "session_id" ], + "tableTo": "browser_use_sessions", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "browser_use_runs", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -4843,9 +6107,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "browser_use_sessions_provider_uq": { "name": "browser_use_sessions_provider_uq", @@ -4858,76 +6122,76 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "browser_use_sessions_company_id_companies_id_fk": { "name": "browser_use_sessions_company_id_companies_id_fk", "tableFrom": "browser_use_sessions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_sessions_issue_id_issues_id_fk": { "name": "browser_use_sessions_issue_id_issues_id_fk", "tableFrom": "browser_use_sessions", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_sessions_agent_id_agents_id_fk": { "name": "browser_use_sessions_agent_id_agents_id_fk", "tableFrom": "browser_use_sessions", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_sessions_connection_id_tool_connections_id_fk": { "name": "browser_use_sessions_connection_id_tool_connections_id_fk", "tableFrom": "browser_use_sessions", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_sessions_grant_id_connection_grants_id_fk": { "name": "browser_use_sessions_grant_id_connection_grants_id_fk", "tableFrom": "browser_use_sessions", - "tableTo": "connection_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -4971,28 +6235,28 @@ "browser_use_settings_grant_id_connection_grants_id_fk": { "name": "browser_use_settings_grant_id_connection_grants_id_fk", "tableFrom": "browser_use_settings", - "tableTo": "connection_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "browser_use_settings_company_id_companies_id_fk": { "name": "browser_use_settings_company_id_companies_id_fk", "tableFrom": "browser_use_settings", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -5074,7 +6338,7 @@ }, "amount_observed": { "name": "amount_observed", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -5130,9 +6394,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "budget_incidents_company_scope_idx": { "name": "budget_incidents_company_scope_idx", @@ -5163,9 +6427,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "budget_incidents_policy_window_threshold_idx": { "name": "budget_incidents_policy_window_threshold_idx", @@ -5190,51 +6454,51 @@ } ], "isUnique": true, - "where": "\"budget_incidents\".\"status\" <> 'dismissed'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"budget_incidents\".\"status\" = 'open'", + "concurrently": false } }, "foreignKeys": { "budget_incidents_company_id_companies_id_fk": { "name": "budget_incidents_company_id_companies_id_fk", "tableFrom": "budget_incidents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "budget_incidents_policy_id_budget_policies_id_fk": { "name": "budget_incidents_policy_id_budget_policies_id_fk", "tableFrom": "budget_incidents", - "tableTo": "budget_policies", "columnsFrom": [ "policy_id" ], + "tableTo": "budget_policies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "budget_incidents_approval_id_approvals_id_fk": { "name": "budget_incidents_approval_id_approvals_id_fk", "tableFrom": "budget_incidents", - "tableTo": "approvals", "columnsFrom": [ "approval_id" ], + "tableTo": "approvals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -5292,6 +6556,13 @@ "notNull": true, "default": 0 }, + "reservation_cents": { + "name": "reservation_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, "warn_percent": { "name": "warn_percent", "type": "integer", @@ -5306,6 +6577,13 @@ "notNull": true, "default": true }, + "unpriced_usage_policy": { + "name": "unpriced_usage_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'block'" + }, "notify_enabled": { "name": "notify_enabled", "type": "boolean", @@ -5320,6 +6598,20 @@ "notNull": true, "default": true }, + "enforcement_version": { + "name": "enforcement_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enforcement_delivered_version": { + "name": "enforcement_delivered_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -5377,9 +6669,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "budget_policies_company_window_idx": { "name": "budget_policies_company_window_idx", @@ -5404,9 +6696,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "budget_policies_company_scope_metric_unique_idx": { "name": "budget_policies_company_scope_metric_unique_idx", @@ -5443,30 +6735,35 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "budget_policies_company_id_companies_id_fk": { "name": "budget_policies_company_id_companies_id_fk", "tableFrom": "budget_policies", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "budget_policies_reservation_nonnegative": { + "name": "budget_policies_reservation_nonnegative", + "value": "\"budget_policies\".\"reservation_cents\" >= 0" + } + }, "isRLSEnabled": false }, "public.built_in_managed_resources": { @@ -5556,9 +6853,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "built_in_managed_resources_resource_idx": { "name": "built_in_managed_resources_resource_idx", @@ -5577,9 +6874,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "built_in_managed_resources_company_bundle_resource_uq": { "name": "built_in_managed_resources_company_bundle_resource_uq", @@ -5610,24 +6907,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "built_in_managed_resources_company_id_companies_id_fk": { "name": "built_in_managed_resources_company_id_companies_id_fk", "tableFrom": "built_in_managed_resources", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -5698,9 +6995,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_attachments_asset_uq": { "name": "case_attachments_asset_uq", @@ -5713,50 +7010,50 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "case_attachments_company_id_companies_id_fk": { "name": "case_attachments_company_id_companies_id_fk", "tableFrom": "case_attachments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_attachments_case_id_cases_id_fk": { "name": "case_attachments_case_id_cases_id_fk", "tableFrom": "case_attachments", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_attachments_asset_id_assets_id_fk": { "name": "case_attachments_asset_id_assets_id_fk", "tableFrom": "case_attachments", - "tableTo": "assets", "columnsFrom": [ "asset_id" ], + "tableTo": "assets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -5839,9 +7136,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_documents_document_uq": { "name": "case_documents_document_uq", @@ -5854,9 +7151,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_documents_company_case_updated_idx": { "name": "case_documents_company_case_updated_idx", @@ -5881,50 +7178,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "case_documents_company_id_companies_id_fk": { "name": "case_documents_company_id_companies_id_fk", "tableFrom": "case_documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_documents_case_id_cases_id_fk": { "name": "case_documents_case_id_cases_id_fk", "tableFrom": "case_documents", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_documents_document_id_documents_id_fk": { "name": "case_documents_document_id_documents_id_fk", "tableFrom": "case_documents", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -6026,9 +7323,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_events_company_case_idx": { "name": "case_events_company_case_idx", @@ -6047,50 +7344,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "case_events_company_id_companies_id_fk": { "name": "case_events_company_id_companies_id_fk", "tableFrom": "case_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_events_case_id_cases_id_fk": { "name": "case_events_case_id_cases_id_fk", "tableFrom": "case_events", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_events_actor_agent_id_agents_id_fk": { "name": "case_events_actor_agent_id_agents_id_fk", "tableFrom": "case_events", - "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -6182,9 +7479,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_issue_links_company_case_idx": { "name": "case_issue_links_company_case_idx", @@ -6203,9 +7500,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_issue_links_issue_idx": { "name": "case_issue_links_issue_idx", @@ -6218,50 +7515,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "case_issue_links_company_id_companies_id_fk": { "name": "case_issue_links_company_id_companies_id_fk", "tableFrom": "case_issue_links", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_issue_links_case_id_cases_id_fk": { "name": "case_issue_links_case_id_cases_id_fk", "tableFrom": "case_issue_links", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_issue_links_issue_id_issues_id_fk": { "name": "case_issue_links_issue_id_issues_id_fk", "tableFrom": "case_issue_links", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -6337,9 +7634,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_labels_company_case_idx": { "name": "case_labels_company_case_idx", @@ -6358,9 +7655,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "case_labels_label_idx": { "name": "case_labels_label_idx", @@ -6373,50 +7670,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "case_labels_company_id_companies_id_fk": { "name": "case_labels_company_id_companies_id_fk", "tableFrom": "case_labels", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_labels_case_id_cases_id_fk": { "name": "case_labels_case_id_cases_id_fk", "tableFrom": "case_labels", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "case_labels_label_id_labels_id_fk": { "name": "case_labels_label_id_labels_id_fk", "tableFrom": "case_labels", - "tableTo": "labels", "columnsFrom": [ "label_id" ], + "tableTo": "labels", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -6555,9 +7852,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_identifier_uq": { "name": "cases_identifier_uq", @@ -6570,9 +7867,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_company_type_key_uq": { "name": "cases_company_type_key_uq", @@ -6597,9 +7894,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_company_status_idx": { "name": "cases_company_status_idx", @@ -6618,9 +7915,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_company_type_idx": { "name": "cases_company_type_idx", @@ -6639,9 +7936,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_company_project_idx": { "name": "cases_company_project_idx", @@ -6660,9 +7957,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_parent_idx": { "name": "cases_parent_idx", @@ -6675,9 +7972,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cases_title_search_idx": { "name": "cases_title_search_idx", @@ -6691,9 +7988,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "cases_identifier_search_idx": { "name": "cases_identifier_search_idx", @@ -6707,9 +8004,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "cases_summary_search_idx": { "name": "cases_summary_search_idx", @@ -6723,63 +8020,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false } }, "foreignKeys": { "cases_company_id_companies_id_fk": { "name": "cases_company_id_companies_id_fk", "tableFrom": "cases", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "cases_project_id_projects_id_fk": { "name": "cases_project_id_projects_id_fk", "tableFrom": "cases", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "cases_parent_case_id_cases_id_fk": { "name": "cases_parent_case_id_cases_id_fk", "tableFrom": "cases", - "tableTo": "cases", "columnsFrom": [ "parent_case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "cases_created_by_agent_id_agents_id_fk": { "name": "cases_created_by_agent_id_agents_id_fk", "tableFrom": "cases", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -6899,97 +8196,97 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_actions_company_id_companies_id_fk": { "name": "chat_actions_company_id_companies_id_fk", "tableFrom": "chat_actions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_actions_delivery_id_chat_deliveries_id_fk": { "name": "chat_actions_delivery_id_chat_deliveries_id_fk", "tableFrom": "chat_actions", - "tableTo": "chat_deliveries", "columnsFrom": [ "delivery_id" ], + "tableTo": "chat_deliveries", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_actions_company_delivery_fk": { "name": "chat_actions_company_delivery_fk", "tableFrom": "chat_actions", - "tableTo": "chat_deliveries", "columnsFrom": [ "company_id", "delivery_id" ], + "tableTo": "chat_deliveries", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_actions_company_conversation_fk": { "name": "chat_actions_company_conversation_fk", "tableFrom": "chat_actions", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_actions_company_principal_fk": { "name": "chat_actions_company_principal_fk", "tableFrom": "chat_actions", - "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], + "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_actions_company_endpoint_fk": { "name": "chat_actions_company_endpoint_fk", "tableFrom": "chat_actions", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -7087,54 +8384,54 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_agent_routes_company_id_companies_id_fk": { "name": "chat_agent_routes_company_id_companies_id_fk", "tableFrom": "chat_agent_routes", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_agent_routes_company_source_fk": { "name": "chat_agent_routes_company_source_fk", "tableFrom": "chat_agent_routes", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "source_endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_agent_routes_company_destination_fk": { "name": "chat_agent_routes_company_destination_fk", "tableFrom": "chat_agent_routes", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "destination_endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -7274,9 +8571,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_conversations_thread_uq": { "name": "chat_conversations_thread_uq", @@ -7307,106 +8604,106 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_conversations_company_id_companies_id_fk": { "name": "chat_conversations_company_id_companies_id_fk", "tableFrom": "chat_conversations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_conversations_resource_id_chat_endpoint_resources_id_fk": { "name": "chat_conversations_resource_id_chat_endpoint_resources_id_fk", "tableFrom": "chat_conversations", - "tableTo": "chat_endpoint_resources", "columnsFrom": [ "resource_id" ], + "tableTo": "chat_endpoint_resources", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_conversations_issue_id_issues_id_fk": { "name": "chat_conversations_issue_id_issues_id_fk", "tableFrom": "chat_conversations", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "chat_conversations_company_issue_fk": { "name": "chat_conversations_company_issue_fk", "tableFrom": "chat_conversations", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_conversations_company_endpoint_fk": { "name": "chat_conversations_company_endpoint_fk", "tableFrom": "chat_conversations", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_conversations_company_resource_fk": { "name": "chat_conversations_company_resource_fk", "tableFrom": "chat_conversations", - "tableTo": "chat_endpoint_resources", "columnsFrom": [ "company_id", "resource_id" ], + "tableTo": "chat_endpoint_resources", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_conversations_company_id_uq": { "name": "chat_conversations_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -7549,9 +8846,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_deliveries_event_uq": { "name": "chat_deliveries_event_uq", @@ -7570,9 +8867,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_deliveries_dedupe_uq": { "name": "chat_deliveries_dedupe_uq", @@ -7591,106 +8888,106 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_deliveries_company_id_companies_id_fk": { "name": "chat_deliveries_company_id_companies_id_fk", "tableFrom": "chat_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_deliveries_conversation_id_chat_conversations_id_fk": { "name": "chat_deliveries_conversation_id_chat_conversations_id_fk", "tableFrom": "chat_deliveries", - "tableTo": "chat_conversations", "columnsFrom": [ "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_deliveries_principal_id_chat_external_principals_id_fk": { "name": "chat_deliveries_principal_id_chat_external_principals_id_fk", "tableFrom": "chat_deliveries", - "tableTo": "chat_external_principals", "columnsFrom": [ "principal_id" ], + "tableTo": "chat_external_principals", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_deliveries_company_endpoint_fk": { "name": "chat_deliveries_company_endpoint_fk", "tableFrom": "chat_deliveries", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_deliveries_company_conversation_fk": { "name": "chat_deliveries_company_conversation_fk", "tableFrom": "chat_deliveries", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_deliveries_company_principal_fk": { "name": "chat_deliveries_company_principal_fk", "tableFrom": "chat_deliveries", - "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], + "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_deliveries_company_id_uq": { "name": "chat_deliveries_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -7776,9 +9073,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoint_leases_expiry_idx": { "name": "chat_endpoint_leases_expiry_idx", @@ -7791,39 +9088,39 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_endpoint_leases_company_id_companies_id_fk": { "name": "chat_endpoint_leases_company_id_companies_id_fk", "tableFrom": "chat_endpoint_leases", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_endpoint_leases_company_endpoint_fk": { "name": "chat_endpoint_leases_company_endpoint_fk", "tableFrom": "chat_endpoint_leases", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -7945,9 +9242,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoint_resources_external_uq": { "name": "chat_endpoint_resources_external_uq", @@ -7972,50 +9269,50 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_endpoint_resources_company_id_companies_id_fk": { "name": "chat_endpoint_resources_company_id_companies_id_fk", "tableFrom": "chat_endpoint_resources", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_endpoint_resources_company_endpoint_fk": { "name": "chat_endpoint_resources_company_endpoint_fk", "tableFrom": "chat_endpoint_resources", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_endpoint_resources_company_id_uq": { "name": "chat_endpoint_resources_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -8250,9 +9547,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoints_agent_idx": { "name": "chat_endpoints_agent_idx", @@ -8271,9 +9568,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoints_status_idx": { "name": "chat_endpoints_status_idx", @@ -8292,9 +9589,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoints_public_id_uq": { "name": "chat_endpoints_public_id_uq", @@ -8307,9 +9604,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoints_agentmail_inbox_uq": { "name": "chat_endpoints_agentmail_inbox_uq", @@ -8322,10 +9619,10 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"provider\" = 'agentmail' and \"chat_endpoints\".\"status\" != 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"provider\" = 'agentmail' and \"chat_endpoints\".\"status\" != 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", + "concurrently": false }, "chat_endpoints_connection_uq": { "name": "chat_endpoints_connection_uq", @@ -8338,9 +9635,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_endpoints_live_bot_external_uq": { "name": "chat_endpoints_live_bot_external_uq", @@ -8365,10 +9662,10 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_external_id\" is not null", + "concurrently": false }, "chat_endpoints_photon_number_uq": { "name": "chat_endpoints_photon_number_uq", @@ -8381,10 +9678,10 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"provider\" = 'imessage-photon' and \"chat_endpoints\".\"status\" <> 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"provider\" = 'imessage-photon' and \"chat_endpoints\".\"status\" <> 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", + "concurrently": false }, "chat_endpoints_live_discord_bot_external_uq": { "name": "chat_endpoints_live_discord_bot_external_uq", @@ -8403,10 +9700,10 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"provider\" = 'discord'\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"provider\" = 'discord'\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", + "concurrently": false }, "chat_endpoints_live_global_app_bot_external_uq": { "name": "chat_endpoints_live_global_app_bot_external_uq", @@ -8425,10 +9722,10 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"provider\" in ('github', 'microsoft-teams')\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"provider\" in ('github', 'microsoft-teams')\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", + "concurrently": false }, "chat_endpoints_live_bot_username_uq": { "name": "chat_endpoints_live_bot_username_uq", @@ -8453,79 +9750,79 @@ } ], "isUnique": true, - "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_username\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_username\" is not null", + "concurrently": false } }, "foreignKeys": { "chat_endpoints_company_id_companies_id_fk": { "name": "chat_endpoints_company_id_companies_id_fk", "tableFrom": "chat_endpoints", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_endpoints_assigned_agent_id_agents_id_fk": { "name": "chat_endpoints_assigned_agent_id_agents_id_fk", "tableFrom": "chat_endpoints", - "tableTo": "agents", "columnsFrom": [ "assigned_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "chat_endpoints_company_agent_fk": { "name": "chat_endpoints_company_agent_fk", "tableFrom": "chat_endpoints", - "tableTo": "agents", "columnsFrom": [ "company_id", "assigned_agent_id" ], + "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_endpoints_company_connection_fk": { "name": "chat_endpoints_company_connection_fk", "tableFrom": "chat_endpoints", - "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_endpoints_company_id_uq": { "name": "chat_endpoints_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -8661,9 +9958,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_external_principals_external_uq": { "name": "chat_external_principals_external_uq", @@ -8694,35 +9991,35 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_external_principals_company_id_companies_id_fk": { "name": "chat_external_principals_company_id_companies_id_fk", "tableFrom": "chat_external_principals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_external_principals_company_id_uq": { "name": "chat_external_principals_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -8837,9 +10134,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_identity_links_endpoint_principal_uq": { "name": "chat_identity_links_endpoint_principal_uq", @@ -8858,54 +10155,54 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_identity_links_company_id_companies_id_fk": { "name": "chat_identity_links_company_id_companies_id_fk", "tableFrom": "chat_identity_links", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_identity_links_company_endpoint_fk": { "name": "chat_identity_links_company_endpoint_fk", "tableFrom": "chat_identity_links", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_identity_links_company_principal_fk": { "name": "chat_identity_links_company_principal_fk", "tableFrom": "chat_identity_links", - "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], + "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -9010,138 +10307,138 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_message_links_company_id_companies_id_fk": { "name": "chat_message_links_company_id_companies_id_fk", "tableFrom": "chat_message_links", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_message_links_delivery_id_chat_deliveries_id_fk": { "name": "chat_message_links_delivery_id_chat_deliveries_id_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_deliveries", "columnsFrom": [ "delivery_id" ], + "tableTo": "chat_deliveries", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_message_links_publication_id_chat_publications_id_fk": { "name": "chat_message_links_publication_id_chat_publications_id_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_publications", "columnsFrom": [ "publication_id" ], + "tableTo": "chat_publications", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_message_links_comment_id_issue_comments_id_fk": { "name": "chat_message_links_comment_id_issue_comments_id_fk", "tableFrom": "chat_message_links", - "tableTo": "issue_comments", "columnsFrom": [ "comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_message_links_company_endpoint_fk": { "name": "chat_message_links_company_endpoint_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_message_links_company_delivery_fk": { "name": "chat_message_links_company_delivery_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_deliveries", "columnsFrom": [ "company_id", "delivery_id" ], + "tableTo": "chat_deliveries", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_message_links_company_publication_fk": { "name": "chat_message_links_company_publication_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], + "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_message_links_company_comment_fk": { "name": "chat_message_links_company_comment_fk", "tableFrom": "chat_message_links", - "tableTo": "issue_comments", "columnsFrom": [ "company_id", "comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_message_links_company_conversation_fk": { "name": "chat_message_links_company_conversation_fk", "tableFrom": "chat_message_links", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -9285,9 +10582,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_publications_work_idx": { "name": "chat_publications_work_idx", @@ -9306,9 +10603,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_publications_idempotency_uq": { "name": "chat_publications_idempotency_uq", @@ -9327,110 +10624,110 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_publications_company_id_companies_id_fk": { "name": "chat_publications_company_id_companies_id_fk", "tableFrom": "chat_publications", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_publications_issue_id_issues_id_fk": { "name": "chat_publications_issue_id_issues_id_fk", "tableFrom": "chat_publications", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "chat_publications_comment_id_issue_comments_id_fk": { "name": "chat_publications_comment_id_issue_comments_id_fk", "tableFrom": "chat_publications", - "tableTo": "issue_comments", "columnsFrom": [ "comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_publications_company_issue_fk": { "name": "chat_publications_company_issue_fk", "tableFrom": "chat_publications", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_publications_company_comment_fk": { "name": "chat_publications_company_comment_fk", "tableFrom": "chat_publications", - "tableTo": "issue_comments", "columnsFrom": [ "company_id", "comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "chat_publications_company_endpoint_fk": { "name": "chat_publications_company_endpoint_fk", "tableFrom": "chat_publications", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_publications_company_conversation_fk": { "name": "chat_publications_company_conversation_fk", "tableFrom": "chat_publications", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -9525,9 +10822,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_sdk_state_expiry_idx": { "name": "chat_sdk_state_expiry_idx", @@ -9540,39 +10837,39 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_sdk_state_company_id_companies_id_fk": { "name": "chat_sdk_state_company_id_companies_id_fk", "tableFrom": "chat_sdk_state", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_sdk_state_company_endpoint_fk": { "name": "chat_sdk_state_company_endpoint_fk", "tableFrom": "chat_sdk_state", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -9675,9 +10972,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_completion_deliveries_pending_idx": { "name": "chat_completion_deliveries_pending_idx", @@ -9696,63 +10993,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_completion_deliveries_company_id_companies_id_fk": { "name": "chat_completion_deliveries_company_id_companies_id_fk", "tableFrom": "chat_completion_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk": { "name": "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk", "tableFrom": "chat_completion_deliveries", - "tableTo": "chat_task_handoffs", "columnsFrom": [ "task_id" ], + "tableTo": "chat_task_handoffs", "columnsTo": [ "task_id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk": { "name": "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk", "tableFrom": "chat_completion_deliveries", - "tableTo": "heartbeat_runs", "columnsFrom": [ "target_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_completion_deliveries_response_comment_id_issue_comments_id_fk": { "name": "chat_completion_deliveries_response_comment_id_issue_comments_id_fk", "tableFrom": "chat_completion_deliveries", - "tableTo": "issue_comments", "columnsFrom": [ "response_comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -9821,63 +11118,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_task_handoffs_task_id_issues_id_fk": { "name": "chat_task_handoffs_task_id_issues_id_fk", "tableFrom": "chat_task_handoffs", - "tableTo": "issues", "columnsFrom": [ "task_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_task_handoffs_company_id_companies_id_fk": { "name": "chat_task_handoffs_company_id_companies_id_fk", "tableFrom": "chat_task_handoffs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_task_handoffs_conversation_id_issues_id_fk": { "name": "chat_task_handoffs_conversation_id_issues_id_fk", "tableFrom": "chat_task_handoffs", - "tableTo": "issues", "columnsFrom": [ "conversation_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_task_handoffs_agent_id_agents_id_fk": { "name": "chat_task_handoffs_agent_id_agents_id_fk", "tableFrom": "chat_task_handoffs", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -9990,17 +11287,17 @@ "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_configurations", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -10094,9 +11391,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_github_registration_endpoint_idx": { "name": "chat_github_registration_endpoint_idx", @@ -10115,26 +11412,26 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_registrations", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -10314,9 +11611,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_github_reviews_pull_idx": { "name": "chat_github_reviews_pull_idx", @@ -10347,9 +11644,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_github_reviews_task_idx": { "name": "chat_github_reviews_task_idx", @@ -10374,65 +11671,65 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_github_reviews_run_id_heartbeat_runs_id_fk": { "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk", "tableFrom": "chat_github_reviews", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_reviews", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": { "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk", "tableFrom": "chat_github_reviews", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_github_reviews_company_id_uq": { "name": "chat_github_reviews_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -10698,9 +11995,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_teams_file_transfers_token_uq": { "name": "chat_teams_file_transfers_token_uq", @@ -10719,9 +12016,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "chat_teams_file_transfers_work_idx": { "name": "chat_teams_file_transfers_work_idx", @@ -10746,97 +12043,97 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "chat_teams_file_transfers_company_id_companies_id_fk": { "name": "chat_teams_file_transfers_company_id_companies_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_teams_file_transfers_issue_id_issues_id_fk": { "name": "chat_teams_file_transfers_issue_id_issues_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "chat_teams_file_transfers_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_teams_file_transfers_company_id_publication_id_chat_publications_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_publication_id_chat_publications_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], + "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_teams_file_transfers_company_id_conversation_id_chat_conversations_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_conversation_id_chat_conversations_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "chat_teams_file_transfers_company_id_principal_id_chat_external_principals_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_principal_id_chat_external_principals_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", - "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], + "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -10973,9 +12270,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cli_auth_challenges_approved_by_idx": { "name": "cli_auth_challenges_approved_by_idx", @@ -10988,9 +12285,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cli_auth_challenges_requested_company_idx": { "name": "cli_auth_challenges_requested_company_idx", @@ -11003,50 +12300,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "cli_auth_challenges_requested_company_id_companies_id_fk": { "name": "cli_auth_challenges_requested_company_id_companies_id_fk", "tableFrom": "cli_auth_challenges", - "tableTo": "companies", "columnsFrom": [ "requested_company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "cli_auth_challenges_approved_by_user_id_user_id_fk": { "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", "tableFrom": "cli_auth_challenges", - "tableTo": "user", "columnsFrom": [ "approved_by_user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", "tableFrom": "cli_auth_challenges", - "tableTo": "board_api_keys", "columnsFrom": [ "board_api_key_id" ], + "tableTo": "board_api_keys", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -11118,9 +12415,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -11197,9 +12500,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -11259,9 +12562,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_logos_asset_uq": { "name": "company_logos_asset_uq", @@ -11274,37 +12577,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_logos_company_id_companies_id_fk": { "name": "company_logos_company_id_companies_id_fk", "tableFrom": "company_logos", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_logos_asset_id_assets_id_fk": { "name": "company_logos_asset_id_assets_id_fk", "tableFrom": "company_logos", - "tableTo": "assets", "columnsFrom": [ "asset_id" ], + "tableTo": "assets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -11394,9 +12697,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_memberships_principal_status_idx": { "name": "company_memberships_principal_status_idx", @@ -11421,9 +12724,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_memberships_company_status_idx": { "name": "company_memberships_company_status_idx", @@ -11442,24 +12745,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_memberships_company_id_companies_id_fk": { "name": "company_memberships_company_id_companies_id_fk", "tableFrom": "company_memberships", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -11573,63 +12876,63 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_onboarding_seeds_company_id_companies_id_fk": { "name": "company_onboarding_seeds_company_id_companies_id_fk", "tableFrom": "company_onboarding_seeds", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_onboarding_seeds_goal_id_goals_id_fk": { "name": "company_onboarding_seeds_goal_id_goals_id_fk", "tableFrom": "company_onboarding_seeds", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_onboarding_seeds_agent_id_agents_id_fk": { "name": "company_onboarding_seeds_agent_id_agents_id_fk", "tableFrom": "company_onboarding_seeds", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_onboarding_seeds_issue_id_issues_id_fk": { "name": "company_onboarding_seeds_issue_id_issues_id_fk", "tableFrom": "company_onboarding_seeds", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -11739,9 +13042,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_bindings_secret_idx": { "name": "company_secret_bindings_secret_idx", @@ -11754,9 +13057,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_bindings_target_idx": { "name": "company_secret_bindings_target_idx", @@ -11781,9 +13084,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_bindings_target_path_uq": { "name": "company_secret_bindings_target_path_uq", @@ -11814,37 +13117,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_secret_bindings_company_id_companies_id_fk": { "name": "company_secret_bindings_company_id_companies_id_fk", "tableFrom": "company_secret_bindings", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "company_secret_bindings_secret_id_company_secrets_id_fk": { "name": "company_secret_bindings_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_bindings", - "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -12079,9 +13382,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_proposals_proposer_status_idx": { "name": "company_secret_proposals_proposer_status_idx", @@ -12100,9 +13403,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_proposals_expiry_idx": { "name": "company_secret_proposals_expiry_idx", @@ -12121,9 +13424,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_proposals_secret_proposal_idx": { "name": "company_secret_proposals_secret_proposal_idx", @@ -12136,9 +13439,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_proposals_interaction_idx": { "name": "company_secret_proposals_interaction_idx", @@ -12151,128 +13454,128 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_secret_proposals_company_id_companies_id_fk": { "name": "company_secret_proposals_company_id_companies_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "company_secret_proposals_secret_id_company_secrets_id_fk": { "name": "company_secret_proposals_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk": { "name": "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "company_secret_proposals", "columnsFrom": [ "secret_proposal_id" ], + "tableTo": "company_secret_proposals", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_proposals_target_id_agents_id_fk": { "name": "company_secret_proposals_target_id_agents_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "agents", "columnsFrom": [ "target_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_proposals_proposed_by_agent_id_agents_id_fk": { "name": "company_secret_proposals_proposed_by_agent_id_agents_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "agents", "columnsFrom": [ "proposed_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_proposals_origin_issue_id_issues_id_fk": { "name": "company_secret_proposals_origin_issue_id_issues_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk": { "name": "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk": { "name": "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_secret_proposals_created_secret_id_company_secrets_id_fk": { "name": "company_secret_proposals_created_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_proposals", - "tableTo": "company_secrets", "columnsFrom": [ "created_secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -12417,9 +13720,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_provider_configs_company_provider_idx": { "name": "company_secret_provider_configs_company_provider_idx", @@ -12438,9 +13741,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_provider_configs_default_uq": { "name": "company_secret_provider_configs_default_uq", @@ -12459,38 +13762,38 @@ } ], "isUnique": true, - "where": "\"company_secret_provider_configs\".\"is_default\" = true", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"company_secret_provider_configs\".\"is_default\" = true", + "concurrently": false } }, "foreignKeys": { "company_secret_provider_configs_company_id_companies_id_fk": { "name": "company_secret_provider_configs_company_id_companies_id_fk", "tableFrom": "company_secret_provider_configs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_provider_configs_created_by_agent_id_agents_id_fk": { "name": "company_secret_provider_configs_created_by_agent_id_agents_id_fk", "tableFrom": "company_secret_provider_configs", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -12603,9 +13906,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_versions_value_sha256_idx": { "name": "company_secret_versions_value_sha256_idx", @@ -12618,9 +13921,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_versions_fingerprint_idx": { "name": "company_secret_versions_fingerprint_idx", @@ -12633,9 +13936,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secret_versions_secret_version_uq": { "name": "company_secret_versions_secret_version_uq", @@ -12654,37 +13957,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_secret_versions_secret_id_company_secrets_id_fk": { "name": "company_secret_versions_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_versions", - "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_secret_versions_created_by_agent_id_agents_id_fk": { "name": "company_secret_versions_created_by_agent_id_agents_id_fk", "tableFrom": "company_secret_versions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -12857,9 +14160,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_company_scope_idx": { "name": "company_secrets_company_scope_idx", @@ -12878,9 +14181,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_company_owner_idx": { "name": "company_secrets_company_owner_idx", @@ -12899,9 +14202,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_user_definition_owner_idx": { "name": "company_secrets_user_definition_owner_idx", @@ -12926,9 +14229,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_company_provider_idx": { "name": "company_secrets_company_provider_idx", @@ -12947,9 +14250,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_provider_config_idx": { "name": "company_secrets_provider_config_idx", @@ -12962,9 +14265,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_secrets_company_name_uq": { "name": "company_secrets_company_name_uq", @@ -12983,10 +14286,10 @@ } ], "isUnique": true, - "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false }, "company_secrets_company_key_uq": { "name": "company_secrets_company_key_uq", @@ -13005,10 +14308,10 @@ } ], "isUnique": true, - "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false }, "company_secrets_user_definition_owner_uq": { "name": "company_secrets_user_definition_owner_uq", @@ -13033,64 +14336,64 @@ } ], "isUnique": true, - "where": "\"company_secrets\".\"scope\" = 'user' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"company_secrets\".\"scope\" = 'user' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false } }, "foreignKeys": { "company_secrets_company_id_companies_id_fk": { "name": "company_secrets_company_id_companies_id_fk", "tableFrom": "company_secrets", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "company_secrets", - "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], + "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_secrets_provider_config_id_company_secret_provider_configs_id_fk": { "name": "company_secrets_provider_config_id_company_secret_provider_configs_id_fk", "tableFrom": "company_secrets", - "tableTo": "company_secret_provider_configs", "columnsFrom": [ "provider_config_id" ], + "tableTo": "company_secret_provider_configs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_secrets_created_by_agent_id_agents_id_fk": { "name": "company_secrets_created_by_agent_id_agents_id_fk", "tableFrom": "company_secrets", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -13160,15 +14463,15 @@ "company_skill_policies_company_id_companies_id_fk": { "name": "company_skill_policies_company_id_companies_id_fk", "tableFrom": "company_skill_policies", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -13269,9 +14572,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_source_entries_company_skill_idx": { "name": "company_skill_source_entries_company_skill_idx", @@ -13290,50 +14593,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_source_entries_company_id_companies_id_fk": { "name": "company_skill_source_entries_company_id_companies_id_fk", "tableFrom": "company_skill_source_entries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_source_entries_source_id_company_skill_sources_id_fk": { "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", "tableFrom": "company_skill_source_entries", - "tableTo": "company_skill_sources", "columnsFrom": [ "source_id" ], + "tableTo": "company_skill_sources", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_source_entries_skill_id_company_skills_id_fk": { "name": "company_skill_source_entries_skill_id_company_skills_id_fk", "tableFrom": "company_skill_source_entries", - "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -13478,9 +14781,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_sources_identity_ref_idx": { "name": "company_skill_sources_identity_ref_idx", @@ -13505,37 +14808,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_sources_company_id_companies_id_fk": { "name": "company_skill_sources_company_id_companies_id_fk", "tableFrom": "company_skill_sources", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_sources_connection_id_tool_connections_id_fk": { "name": "company_skill_sources_connection_id_tool_connections_id_fk", "tableFrom": "company_skill_sources", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -13636,9 +14939,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_comments_parent_idx": { "name": "company_skill_comments_parent_idx", @@ -13651,63 +14954,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_comments_company_id_companies_id_fk": { "name": "company_skill_comments_company_id_companies_id_fk", "tableFrom": "company_skill_comments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_comments_company_skill_id_company_skills_id_fk": { "name": "company_skill_comments_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_comments", - "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_comments_parent_comment_id_company_skill_comments_id_fk": { "name": "company_skill_comments_parent_comment_id_company_skill_comments_id_fk", "tableFrom": "company_skill_comments", - "tableTo": "company_skill_comments", "columnsFrom": [ "parent_comment_id" ], + "tableTo": "company_skill_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skill_comments_author_agent_id_agents_id_fk": { "name": "company_skill_comments_author_agent_id_agents_id_fk", "tableFrom": "company_skill_comments", - "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -13777,9 +15080,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_stars_skill_user_idx": { "name": "company_skill_stars_skill_user_idx", @@ -13798,9 +15101,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_stars_company_skill_created_idx": { "name": "company_skill_stars_company_skill_created_idx", @@ -13825,50 +15128,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_stars_company_id_companies_id_fk": { "name": "company_skill_stars_company_id_companies_id_fk", "tableFrom": "company_skill_stars", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_stars_company_skill_id_company_skills_id_fk": { "name": "company_skill_stars_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_stars", - "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_stars_agent_id_agents_id_fk": { "name": "company_skill_stars_agent_id_agents_id_fk", "tableFrom": "company_skill_stars", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -13963,9 +15266,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_test_inputs_company_skill_active_idx": { "name": "company_skill_test_inputs_company_skill_active_idx", @@ -13990,37 +15293,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_test_inputs_company_id_companies_id_fk": { "name": "company_skill_test_inputs_company_id_companies_id_fk", "tableFrom": "company_skill_test_inputs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_test_inputs_skill_id_company_skills_id_fk": { "name": "company_skill_test_inputs_skill_id_company_skills_id_fk", "tableFrom": "company_skill_test_inputs", - "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -14133,50 +15436,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_test_run_templates_company_id_companies_id_fk": { "name": "company_skill_test_run_templates_company_id_companies_id_fk", "tableFrom": "company_skill_test_run_templates", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_test_run_templates_created_by_agent_id_agents_id_fk": { "name": "company_skill_test_run_templates_created_by_agent_id_agents_id_fk", "tableFrom": "company_skill_test_run_templates", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk": { "name": "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk", "tableFrom": "company_skill_test_run_templates", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -14366,9 +15669,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_test_runs_company_issue_idx": { "name": "company_skill_test_runs_company_issue_idx", @@ -14387,9 +15690,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_test_runs_company_input_created_idx": { "name": "company_skill_test_runs_company_input_created_idx", @@ -14414,9 +15717,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_test_runs_company_status_idx": { "name": "company_skill_test_runs_company_status_idx", @@ -14435,9 +15738,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_test_runs_company_harness_expires_idx": { "name": "company_skill_test_runs_company_harness_expires_idx", @@ -14456,89 +15759,89 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_test_runs_company_id_companies_id_fk": { "name": "company_skill_test_runs_company_id_companies_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_test_runs_skill_id_company_skills_id_fk": { "name": "company_skill_test_runs_skill_id_company_skills_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk": { "name": "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "company_skill_test_inputs", "columnsFrom": [ "input_id" ], + "tableTo": "company_skill_test_inputs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk": { "name": "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "company_skill_versions", "columnsFrom": [ "skill_version_id" ], + "tableTo": "company_skill_versions", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "company_skill_test_runs_agent_id_agents_id_fk": { "name": "company_skill_test_runs_agent_id_agents_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "company_skill_test_runs_issue_id_issues_id_fk": { "name": "company_skill_test_runs_issue_id_issues_id_fk", "tableFrom": "company_skill_test_runs", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" } }, "compositePrimaryKeys": {}, @@ -14645,9 +15948,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skill_versions_skill_release_idx": { "name": "company_skill_versions_skill_release_idx", @@ -14666,10 +15969,10 @@ } ], "isUnique": true, - "where": "\"company_skill_versions\".\"release_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"company_skill_versions\".\"release_id\" is not null", + "concurrently": false }, "company_skill_versions_company_skill_created_idx": { "name": "company_skill_versions_company_skill_created_idx", @@ -14694,50 +15997,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skill_versions_company_id_companies_id_fk": { "name": "company_skill_versions_company_id_companies_id_fk", "tableFrom": "company_skill_versions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_versions_company_skill_id_company_skills_id_fk": { "name": "company_skill_versions_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_versions", - "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", - "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -14967,9 +16270,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skills_company_name_idx": { "name": "company_skills_company_name_idx", @@ -14988,9 +16291,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skills_company_folder_idx": { "name": "company_skills_company_folder_idx", @@ -15009,9 +16312,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skills_company_categories_idx": { "name": "company_skills_company_categories_idx", @@ -15024,9 +16327,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "company_skills_company_sharing_scope_idx": { "name": "company_skills_company_sharing_scope_idx", @@ -15045,9 +16348,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skills_company_current_version_idx": { "name": "company_skills_company_current_version_idx", @@ -15066,9 +16369,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_skills_company_forked_from_idx": { "name": "company_skills_company_forked_from_idx", @@ -15087,76 +16390,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_skills_company_id_companies_id_fk": { "name": "company_skills_company_id_companies_id_fk", "tableFrom": "company_skills", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "company_skills_folder_id_folders_id_fk": { "name": "company_skills_folder_id_folders_id_fk", "tableFrom": "company_skills", - "tableTo": "folders", "columnsFrom": [ "folder_id" ], + "tableTo": "folders", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skills_forked_from_skill_id_company_skills_id_fk": { "name": "company_skills_forked_from_skill_id_company_skills_id_fk", "tableFrom": "company_skills", - "tableTo": "company_skills", "columnsFrom": [ "forked_from_skill_id" ], + "tableTo": "company_skills", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skills_forked_from_company_id_companies_id_fk": { "name": "company_skills_forked_from_company_id_companies_id_fk", "tableFrom": "company_skills", - "tableTo": "companies", "columnsFrom": [ "forked_from_company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "company_skills_current_version_id_company_skill_versions_id_fk": { "name": "company_skills_current_version_id_company_skill_versions_id_fk", "tableFrom": "company_skills", - "tableTo": "company_skill_versions", "columnsFrom": [ "current_version_id" ], + "tableTo": "company_skill_versions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -15291,9 +16594,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_transfer_runs_idempotency_direction_idx": { "name": "company_transfer_runs_idempotency_direction_idx", @@ -15312,9 +16615,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_transfer_runs_actor_status_idx": { "name": "company_transfer_runs_actor_status_idx", @@ -15333,24 +16636,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_transfer_runs_company_id_companies_id_fk": { "name": "company_transfer_runs_company_id_companies_id_fk", "tableFrom": "company_transfer_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -15416,9 +16719,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_user_sidebar_preferences_user_idx": { "name": "company_user_sidebar_preferences_user_idx", @@ -15431,9 +16734,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "company_user_sidebar_preferences_company_user_uq": { "name": "company_user_sidebar_preferences_company_user_uq", @@ -15452,24 +16755,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "company_user_sidebar_preferences_company_id_companies_id_fk": { "name": "company_user_sidebar_preferences_company_id_companies_id_fk", "tableFrom": "company_user_sidebar_preferences", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -15593,9 +16896,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "completion_contracts_issue_hash_uq": { "name": "completion_contracts_issue_hash_uq", @@ -15614,68 +16917,68 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "completion_contracts_company_id_companies_id_fk": { "name": "completion_contracts_company_id_companies_id_fk", "tableFrom": "completion_contracts", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "completion_contracts_issue_company_fk": { "name": "completion_contracts_issue_company_fk", "tableFrom": "completion_contracts", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "completion_contracts_supersedes_owner_fk": { "name": "completion_contracts_supersedes_owner_fk", "tableFrom": "completion_contracts", - "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "supersedes_contract_id" ], + "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "completion_contracts_company_issue_id_uq": { "name": "completion_contracts_company_issue_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -15813,9 +17116,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_event_deliveries_company_status_idx": { "name": "connection_event_deliveries_company_status_idx", @@ -15840,24 +17143,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "connection_event_deliveries_company_id_companies_id_fk": { "name": "connection_event_deliveries_company_id_companies_id_fk", "tableFrom": "connection_event_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -15921,37 +17224,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "connection_intent_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "connection_intent_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "connection_intent_deliveries", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_intent_deliveries_company_id_companies_id_fk": { "name": "connection_intent_deliveries_company_id_companies_id_fk", "tableFrom": "connection_intent_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -16013,6 +17316,36 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_cost_cents": { + "name": "reported_cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": false + }, + "pricing_provenance": { + "name": "pricing_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "provider": { "name": "provider", "type": "text", @@ -16069,7 +17402,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -16088,6 +17421,109 @@ } }, "indexes": { + "cost_events_company_receipt_idx": { + "name": "cost_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cost_events_provider_request_idx": { + "name": "cost_events_provider_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cost_events_company_project_occurred_idx": { + "name": "cost_events_company_project_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "cost_events_unpriced_idx": { + "name": "cost_events_unpriced_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included'", + "concurrently": false + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -16105,9 +17541,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cost_events_company_agent_occurred_idx": { "name": "cost_events_company_agent_occurred_idx", @@ -16132,9 +17568,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cost_events_company_provider_occurred_idx": { "name": "cost_events_company_provider_occurred_idx", @@ -16159,9 +17595,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cost_events_company_biller_occurred_idx": { "name": "cost_events_company_biller_occurred_idx", @@ -16186,9 +17622,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "cost_events_company_heartbeat_run_idx": { "name": "cost_events_company_heartbeat_run_idx", @@ -16207,95 +17643,100 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "cost_events_company_id_companies_id_fk": { "name": "cost_events_company_id_companies_id_fk", "tableFrom": "cost_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "cost_events_agent_id_agents_id_fk": { "name": "cost_events_agent_id_agents_id_fk", "tableFrom": "cost_events", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "cost_events_issue_id_issues_id_fk": { "name": "cost_events_issue_id_issues_id_fk", "tableFrom": "cost_events", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "cost_events_project_id_projects_id_fk": { "name": "cost_events_project_id_projects_id_fk", "tableFrom": "cost_events", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "cost_events_goal_id_goals_id_fk": { "name": "cost_events_goal_id_goals_id_fk", "tableFrom": "cost_events", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "cost_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "cost_events_nonnegative_amounts": { + "name": "cost_events_nonnegative_amounts", + "value": "\"cost_events\".\"cost_cents\" >= 0 and \"cost_events\".\"input_tokens\" >= 0 and \"cost_events\".\"cached_input_tokens\" >= 0 and \"cost_events\".\"output_tokens\" >= 0" + } + }, "isRLSEnabled": false }, "public.decision_archive_notification_outbox": { @@ -16422,9 +17863,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_archive_notification_outbox_pending_idx": { "name": "decision_archive_notification_outbox_pending_idx", @@ -16443,37 +17884,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_archive_notification_outbox_company_id_companies_id_fk": { "name": "decision_archive_notification_outbox_company_id_companies_id_fk", "tableFrom": "decision_archive_notification_outbox", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_archive_notification_outbox_origin_agent_id_agents_id_fk": { "name": "decision_archive_notification_outbox_origin_agent_id_agents_id_fk", "tableFrom": "decision_archive_notification_outbox", - "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -16590,9 +18031,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_queue_items_company_source_idx": { "name": "decision_queue_items_company_source_idx", @@ -16617,78 +18058,78 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_queue_items_company_id_companies_id_fk": { "name": "decision_queue_items_company_id_companies_id_fk", "tableFrom": "decision_queue_items", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_queue_items_added_by_agent_id_agents_id_fk": { "name": "decision_queue_items_added_by_agent_id_agents_id_fk", "tableFrom": "decision_queue_items", - "tableTo": "agents", "columnsFrom": [ "added_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk": { "name": "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_queue_items", - "tableTo": "heartbeat_runs", "columnsFrom": [ "added_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_queue_items", - "tableTo": "agent_api_keys", "columnsFrom": [ "added_by_agent_api_key_id" ], + "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_queue_items_queue_company_fk": { "name": "decision_queue_items_queue_company_fk", "tableFrom": "decision_queue_items", - "tableTo": "decision_queues", "columnsFrom": [ "queue_id", "company_id" ], + "tableTo": "decision_queues", "columnsTo": [ "id", "company_id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -16820,9 +18261,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_queues_company_updated_idx": { "name": "decision_queues_company_updated_idx", @@ -16841,74 +18282,74 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_queues_company_id_companies_id_fk": { "name": "decision_queues_company_id_companies_id_fk", "tableFrom": "decision_queues", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_queues_created_by_agent_id_agents_id_fk": { "name": "decision_queues_created_by_agent_id_agents_id_fk", "tableFrom": "decision_queues", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_queues_created_by_run_id_heartbeat_runs_id_fk": { "name": "decision_queues_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_queues", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_queues", - "tableTo": "agent_api_keys", "columnsFrom": [ "created_by_agent_api_key_id" ], + "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "decision_queues_id_company_uq": { "name": "decision_queues_id_company_uq", - "nullsNotDistinct": false, "columns": [ "id", "company_id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -17055,9 +18496,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_retention_company_archived_idx": { "name": "decision_retention_company_archived_idx", @@ -17076,50 +18517,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_retention_company_id_companies_id_fk": { "name": "decision_retention_company_id_companies_id_fk", "tableFrom": "decision_retention", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_retention_archived_by_agent_id_agents_id_fk": { "name": "decision_retention_archived_by_agent_id_agents_id_fk", "tableFrom": "decision_retention", - "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_retention_archived_by_run_id_heartbeat_runs_id_fk": { "name": "decision_retention_archived_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_retention", - "tableTo": "heartbeat_runs", "columnsFrom": [ "archived_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -17262,9 +18703,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_triage_company_decide_by_idx": { "name": "decision_triage_company_decide_by_idx", @@ -17283,63 +18724,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_triage_company_id_companies_id_fk": { "name": "decision_triage_company_id_companies_id_fk", "tableFrom": "decision_triage", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_triage_set_by_agent_id_agents_id_fk": { "name": "decision_triage_set_by_agent_id_agents_id_fk", "tableFrom": "decision_triage", - "tableTo": "agents", "columnsFrom": [ "set_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_triage_set_by_run_id_heartbeat_runs_id_fk": { "name": "decision_triage_set_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_triage", - "tableTo": "heartbeat_runs", "columnsFrom": [ "set_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_triage", - "tableTo": "agent_api_keys", "columnsFrom": [ "set_by_agent_api_key_id" ], + "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -17479,9 +18920,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_triage_events_queue_created_idx": { "name": "decision_triage_events_queue_created_idx", @@ -17500,76 +18941,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_triage_events_company_id_companies_id_fk": { "name": "decision_triage_events_company_id_companies_id_fk", "tableFrom": "decision_triage_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_triage_events_queue_id_decision_queues_id_fk": { "name": "decision_triage_events_queue_id_decision_queues_id_fk", "tableFrom": "decision_triage_events", - "tableTo": "decision_queues", "columnsFrom": [ "queue_id" ], + "tableTo": "decision_queues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_triage_events_actor_agent_id_agents_id_fk": { "name": "decision_triage_events_actor_agent_id_agents_id_fk", "tableFrom": "decision_triage_events", - "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_triage_events_actor_run_id_heartbeat_runs_id_fk": { "name": "decision_triage_events_actor_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_triage_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "actor_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_triage_events", - "tableTo": "agent_api_keys", "columnsFrom": [ "agent_api_key_id" ], + "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -17696,9 +19137,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_training_examples_issue_idx": { "name": "decision_training_examples_issue_idx", @@ -17711,9 +19152,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_training_examples_source_author_uq": { "name": "decision_training_examples_source_author_uq", @@ -17738,37 +19179,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_training_examples_company_id_companies_id_fk": { "name": "decision_training_examples_company_id_companies_id_fk", "tableFrom": "decision_training_examples", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_training_examples_issue_id_issues_id_fk": { "name": "decision_training_examples_issue_id_issues_id_fk", "tableFrom": "decision_training_examples", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -17850,63 +19291,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_bundles_company_id_companies_id_fk": { "name": "decision_bundles_company_id_companies_id_fk", "tableFrom": "decision_bundles", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_bundles_origin_agent_id_agents_id_fk": { "name": "decision_bundles_origin_agent_id_agents_id_fk", "tableFrom": "decision_bundles", - "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_bundles_origin_issue_id_issues_id_fk": { "name": "decision_bundles_origin_issue_id_issues_id_fk", "tableFrom": "decision_bundles", - "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_bundles_origin_run_id_heartbeat_runs_id_fk": { "name": "decision_bundles_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_bundles", - "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -18000,9 +19441,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_effect_executions_target_issue_idx": { "name": "decision_effect_executions_target_issue_idx", @@ -18015,50 +19456,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_effect_executions_decision_id_decisions_id_fk": { "name": "decision_effect_executions_decision_id_decisions_id_fk", "tableFrom": "decision_effect_executions", - "tableTo": "decisions", "columnsFrom": [ "decision_id" ], + "tableTo": "decisions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_effect_executions_target_issue_id_issues_id_fk": { "name": "decision_effect_executions_target_issue_id_issues_id_fk", "tableFrom": "decision_effect_executions", - "tableTo": "issues", "columnsFrom": [ "target_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decision_effect_executions_activity_log_id_activity_log_id_fk": { "name": "decision_effect_executions_activity_log_id_activity_log_id_fk", "tableFrom": "decision_effect_executions", - "tableTo": "activity_log", "columnsFrom": [ "activity_log_id" ], + "tableTo": "activity_log", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -18102,9 +19543,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decision_target_issues_issue_idx": { "name": "decision_target_issues_issue_idx", @@ -18117,50 +19558,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "decision_target_issues_decision_id_decisions_id_fk": { "name": "decision_target_issues_decision_id_decisions_id_fk", "tableFrom": "decision_target_issues", - "tableTo": "decisions", "columnsFrom": [ "decision_id" ], + "tableTo": "decisions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_target_issues_issue_id_issues_id_fk": { "name": "decision_target_issues_issue_id_issues_id_fk", "tableFrom": "decision_target_issues", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "decision_target_issues_company_id_companies_id_fk": { "name": "decision_target_issues_company_id_companies_id_fk", "tableFrom": "decision_target_issues", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": { @@ -18362,9 +19803,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decisions_bundle_idx": { "name": "decisions_bundle_idx", @@ -18377,9 +19818,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decisions_origin_issue_idx": { "name": "decisions_origin_issue_idx", @@ -18392,9 +19833,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "decisions_company_idempotency_uq": { "name": "decisions_company_idempotency_uq", @@ -18413,77 +19854,77 @@ } ], "isUnique": true, - "where": "\"decisions\".\"idempotency_key\" IS NOT NULL", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"decisions\".\"idempotency_key\" IS NOT NULL", + "concurrently": false } }, "foreignKeys": { "decisions_company_id_companies_id_fk": { "name": "decisions_company_id_companies_id_fk", "tableFrom": "decisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decisions_bundle_id_decision_bundles_id_fk": { "name": "decisions_bundle_id_decision_bundles_id_fk", "tableFrom": "decisions", - "tableTo": "decision_bundles", "columnsFrom": [ "bundle_id" ], + "tableTo": "decision_bundles", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "decisions_origin_agent_id_agents_id_fk": { "name": "decisions_origin_agent_id_agents_id_fk", "tableFrom": "decisions", - "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decisions_origin_issue_id_issues_id_fk": { "name": "decisions_origin_issue_id_issues_id_fk", "tableFrom": "decisions", - "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "decisions_origin_run_id_heartbeat_runs_id_fk": { "name": "decisions_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "decisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -18607,9 +20048,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_anchor_snapshots_company_document_revision_idx": { "name": "document_annotation_anchor_snapshots_company_document_revision_idx", @@ -18634,76 +20075,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "document_annotation_anchor_snapshots_company_id_companies_id_fk": { "name": "document_annotation_anchor_snapshots_company_id_companies_id_fk", "tableFrom": "document_annotation_anchor_snapshots", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk": { "name": "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk", "tableFrom": "document_annotation_anchor_snapshots", - "tableTo": "document_annotation_threads", "columnsFrom": [ "thread_id" ], + "tableTo": "document_annotation_threads", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_anchor_snapshots_document_id_documents_id_fk": { "name": "document_annotation_anchor_snapshots_document_id_documents_id_fk", "tableFrom": "document_annotation_anchor_snapshots", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk": { "name": "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_anchor_snapshots", - "tableTo": "document_revisions", "columnsFrom": [ "from_revision_id" ], + "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk": { "name": "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_anchor_snapshots", - "tableTo": "document_revisions", "columnsFrom": [ "to_revision_id" ], + "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -18840,9 +20281,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_company_issue_created_at_idx": { "name": "document_annotation_comments_company_issue_created_at_idx", @@ -18867,9 +20308,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_company_routine_created_at_idx": { "name": "document_annotation_comments_company_routine_created_at_idx", @@ -18894,9 +20335,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_company_case_created_at_idx": { "name": "document_annotation_comments_company_case_created_at_idx", @@ -18921,9 +20362,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_company_document_created_at_idx": { "name": "document_annotation_comments_company_document_created_at_idx", @@ -18948,9 +20389,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_issue_comment_idx": { "name": "document_annotation_comments_issue_comment_idx", @@ -18963,9 +20404,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_comments_body_search_idx": { "name": "document_annotation_comments_body_search_idx", @@ -18979,128 +20420,128 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false } }, "foreignKeys": { "document_annotation_comments_company_id_companies_id_fk": { "name": "document_annotation_comments_company_id_companies_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "document_annotation_comments_thread_id_document_annotation_threads_id_fk": { "name": "document_annotation_comments_thread_id_document_annotation_threads_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "document_annotation_threads", "columnsFrom": [ "thread_id" ], + "tableTo": "document_annotation_threads", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_comments_issue_id_issues_id_fk": { "name": "document_annotation_comments_issue_id_issues_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_comments_routine_id_routines_id_fk": { "name": "document_annotation_comments_routine_id_routines_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_comments_case_id_cases_id_fk": { "name": "document_annotation_comments_case_id_cases_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_comments_document_id_documents_id_fk": { "name": "document_annotation_comments_document_id_documents_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_comments_author_agent_id_agents_id_fk": { "name": "document_annotation_comments_author_agent_id_agents_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk": { "name": "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_comments_issue_comment_id_issue_comments_id_fk": { "name": "document_annotation_comments_issue_comment_id_issue_comments_id_fk", "tableFrom": "document_annotation_comments", - "tableTo": "issue_comments", "columnsFrom": [ "issue_comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -19325,9 +20766,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_threads_company_issue_status_idx": { "name": "document_annotation_threads_company_issue_status_idx", @@ -19352,9 +20793,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_threads_company_routine_status_idx": { "name": "document_annotation_threads_company_routine_status_idx", @@ -19379,9 +20820,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_threads_company_case_status_idx": { "name": "document_annotation_threads_company_case_status_idx", @@ -19406,9 +20847,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_threads_company_current_revision_open_idx": { "name": "document_annotation_threads_company_current_revision_open_idx", @@ -19439,9 +20880,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_annotation_threads_company_anchor_state_idx": { "name": "document_annotation_threads_company_anchor_state_idx", @@ -19460,128 +20901,128 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "document_annotation_threads_company_id_companies_id_fk": { "name": "document_annotation_threads_company_id_companies_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "document_annotation_threads_issue_id_issues_id_fk": { "name": "document_annotation_threads_issue_id_issues_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_threads_routine_id_routines_id_fk": { "name": "document_annotation_threads_routine_id_routines_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_threads_case_id_cases_id_fk": { "name": "document_annotation_threads_case_id_cases_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "cases", "columnsFrom": [ "case_id" ], + "tableTo": "cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_threads_document_id_documents_id_fk": { "name": "document_annotation_threads_document_id_documents_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_annotation_threads_original_revision_id_document_revisions_id_fk": { "name": "document_annotation_threads_original_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "document_revisions", "columnsFrom": [ "original_revision_id" ], + "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_threads_current_revision_id_document_revisions_id_fk": { "name": "document_annotation_threads_current_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "document_revisions", "columnsFrom": [ "current_revision_id" ], + "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_threads_created_by_agent_id_agents_id_fk": { "name": "document_annotation_threads_created_by_agent_id_agents_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_annotation_threads_resolved_by_agent_id_agents_id_fk": { "name": "document_annotation_threads_resolved_by_agent_id_agents_id_fk", "tableFrom": "document_annotation_threads", - "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -19669,9 +21110,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_memberships_company_user_document_uq": { "name": "document_memberships_company_user_document_uq", @@ -19696,37 +21137,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "document_memberships_company_id_companies_id_fk": { "name": "document_memberships_company_id_companies_id_fk", "tableFrom": "document_memberships", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_memberships_document_id_documents_id_fk": { "name": "document_memberships_document_id_documents_id_fk", "tableFrom": "document_memberships", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -19833,9 +21274,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "document_revisions_company_document_created_idx": { "name": "document_revisions_company_document_created_idx", @@ -19860,63 +21301,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "document_revisions_company_id_companies_id_fk": { "name": "document_revisions_company_id_companies_id_fk", "tableFrom": "document_revisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_revisions_document_id_documents_id_fk": { "name": "document_revisions_document_id_documents_id_fk", "tableFrom": "document_revisions", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "document_revisions_created_by_agent_id_agents_id_fk": { "name": "document_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "document_revisions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "document_revisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -20055,9 +21496,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "documents_company_created_idx": { "name": "documents_company_created_idx", @@ -20076,9 +21517,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "documents_title_search_idx": { "name": "documents_title_search_idx", @@ -20092,9 +21533,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "documents_latest_body_search_idx": { "name": "documents_latest_body_search_idx", @@ -20108,63 +21549,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false } }, "foreignKeys": { "documents_company_id_companies_id_fk": { "name": "documents_company_id_companies_id_fk", "tableFrom": "documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "documents_created_by_agent_id_agents_id_fk": { "name": "documents_created_by_agent_id_agents_id_fk", "tableFrom": "documents", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "documents_updated_by_agent_id_agents_id_fk": { "name": "documents_updated_by_agent_id_agents_id_fk", "tableFrom": "documents", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "documents_locked_by_agent_id_agents_id_fk": { "name": "documents_locked_by_agent_id_agents_id_fk", "tableFrom": "documents", - "tableTo": "agents", "columnsFrom": [ "locked_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -20231,17 +21672,17 @@ "email_endpoints_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_endpoints_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_endpoints", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -20354,9 +21795,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "email_messages_conversation_idx": { "name": "email_messages_conversation_idx", @@ -20381,41 +21822,41 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "email_messages_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_messages_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_messages", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "email_messages_company_id_conversation_id_chat_conversations_company_id_id_fk": { "name": "email_messages_company_id_conversation_id_chat_conversations_company_id_id_fk", "tableFrom": "email_messages", - "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], + "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -20501,42 +21942,42 @@ } ], "isUnique": false, - "where": "\"email_sends\".\"outcome\" in ('queued', 'uncertain')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"email_sends\".\"outcome\" in ('queued', 'uncertain')", + "concurrently": false } }, "foreignKeys": { "email_sends_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_sends_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_sends", - "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], + "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "email_sends_company_id_publication_id_chat_publications_company_id_id_fk": { "name": "email_sends_company_id_publication_id_chat_publications_company_id_id_fk", "tableFrom": "email_sends", - "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], + "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -20691,9 +22132,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_setup_sessions_environment_active_uq": { "name": "environment_custom_image_setup_sessions_environment_active_uq", @@ -20706,10 +22147,10 @@ } ], "isUnique": true, - "where": "\"environment_custom_image_setup_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'capturing')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"environment_custom_image_setup_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'capturing')", + "concurrently": false }, "environment_custom_image_setup_sessions_template_idx": { "name": "environment_custom_image_setup_sessions_template_idx", @@ -20722,9 +22163,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_setup_sessions_promoted_template_idx": { "name": "environment_custom_image_setup_sessions_promoted_template_idx", @@ -20737,9 +22178,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_setup_sessions_expires_idx": { "name": "environment_custom_image_setup_sessions_expires_idx", @@ -20752,9 +22193,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_setup_sessions_provider_lease_idx": { "name": "environment_custom_image_setup_sessions_provider_lease_idx", @@ -20773,76 +22214,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "environment_custom_image_setup_sessions_environment_id_environments_id_fk": { "name": "environment_custom_image_setup_sessions_environment_id_environments_id_fk", "tableFrom": "environment_custom_image_setup_sessions", - "tableTo": "environments", "columnsFrom": [ "environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_setup_sessions", - "tableTo": "environment_custom_image_templates", "columnsFrom": [ "template_id" ], + "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_setup_sessions", - "tableTo": "environment_custom_image_templates", "columnsFrom": [ "promoted_template_id" ], + "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk": { "name": "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk", "tableFrom": "environment_custom_image_setup_sessions", - "tableTo": "environment_leases", "columnsFrom": [ "environment_lease_id" ], + "tableTo": "environment_leases", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk": { "name": "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk", "tableFrom": "environment_custom_image_setup_sessions", - "tableTo": "agents", "columnsFrom": [ "started_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -20975,9 +22416,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_templates_environment_provider_status_idx": { "name": "environment_custom_image_templates_environment_provider_status_idx", @@ -21002,9 +22443,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_templates_environment_active_uq": { "name": "environment_custom_image_templates_environment_active_uq", @@ -21017,10 +22458,10 @@ } ], "isUnique": true, - "where": "\"environment_custom_image_templates\".\"status\" = 'active'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"environment_custom_image_templates\".\"status\" = 'active'", + "concurrently": false }, "environment_custom_image_templates_superseded_by_idx": { "name": "environment_custom_image_templates_superseded_by_idx", @@ -21033,9 +22474,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_custom_image_templates_last_used_idx": { "name": "environment_custom_image_templates_last_used_idx", @@ -21048,50 +22489,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "environment_custom_image_templates_environment_id_environments_id_fk": { "name": "environment_custom_image_templates_environment_id_environments_id_fk", "tableFrom": "environment_custom_image_templates", - "tableTo": "environments", "columnsFrom": [ "environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "environment_custom_image_templates_created_by_agent_id_agents_id_fk": { "name": "environment_custom_image_templates_created_by_agent_id_agents_id_fk", "tableFrom": "environment_custom_image_templates", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_templates", - "tableTo": "environment_custom_image_templates", "columnsFrom": [ "superseded_by_template_id" ], + "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -21250,9 +22691,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_leases_company_execution_workspace_idx": { "name": "environment_leases_company_execution_workspace_idx", @@ -21271,9 +22712,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_leases_company_issue_idx": { "name": "environment_leases_company_issue_idx", @@ -21292,9 +22733,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_leases_heartbeat_run_idx": { "name": "environment_leases_heartbeat_run_idx", @@ -21307,9 +22748,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_leases_company_last_used_idx": { "name": "environment_leases_company_last_used_idx", @@ -21328,9 +22769,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environment_leases_provider_lease_idx": { "name": "environment_leases_provider_lease_idx", @@ -21343,76 +22784,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "environment_leases_company_id_companies_id_fk": { "name": "environment_leases_company_id_companies_id_fk", "tableFrom": "environment_leases", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "environment_leases_environment_id_environments_id_fk": { "name": "environment_leases_environment_id_environments_id_fk", "tableFrom": "environment_leases", - "tableTo": "environments", "columnsFrom": [ "environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_leases_execution_workspace_id_execution_workspaces_id_fk": { "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "environment_leases", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_leases_issue_id_issues_id_fk": { "name": "environment_leases_issue_id_issues_id_fk", "tableFrom": "environment_leases", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "environment_leases", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -21505,9 +22946,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "environments_local_driver_idx": { "name": "environments_local_driver_idx", @@ -21520,10 +22961,10 @@ } ], "isUnique": true, - "where": "\"environments\".\"driver\" = 'local'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"environments\".\"driver\" = 'local'", + "concurrently": false }, "environments_managed_sandbox_idx": { "name": "environments_managed_sandbox_idx", @@ -21536,10 +22977,10 @@ } ], "isUnique": true, - "where": "\"environments\".\"driver\" = 'sandbox' AND (\"environments\".\"metadata\" ->> 'managedByPaperclip')::boolean = true", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"environments\".\"driver\" = 'sandbox' AND (\"environments\".\"metadata\" ->> 'managedByPaperclip')::boolean = true", + "concurrently": false }, "environments_name_idx": { "name": "environments_name_idx", @@ -21552,9 +22993,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -21676,9 +23117,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspace_runtime_leases_company_owner_idx": { "name": "execution_workspace_runtime_leases_company_owner_idx", @@ -21697,9 +23138,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspace_runtime_leases_expires_at_idx": { "name": "execution_workspace_runtime_leases_expires_at_idx", @@ -21712,86 +23153,86 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "execution_workspace_runtime_leases_company_id_companies_id_fk": { "name": "execution_workspace_runtime_leases_company_id_companies_id_fk", "tableFrom": "execution_workspace_runtime_leases", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk": { "name": "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "execution_workspace_runtime_leases", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk": { "name": "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk", "tableFrom": "execution_workspace_runtime_leases", - "tableTo": "issues", "columnsFrom": [ "owner_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk": { "name": "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk", "tableFrom": "execution_workspace_runtime_leases", - "tableTo": "heartbeat_runs", "columnsFrom": [ "owner_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk": { "name": "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk", "tableFrom": "execution_workspace_runtime_leases", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "execution_workspace_runtime_leases_execution_workspace_id_unique": { "name": "execution_workspace_runtime_leases_execution_workspace_id_unique", - "nullsNotDistinct": false, "columns": [ "execution_workspace_id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -21978,9 +23419,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspaces_company_project_workspace_status_idx": { "name": "execution_workspaces_company_project_workspace_status_idx", @@ -22005,9 +23446,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspaces_company_source_issue_idx": { "name": "execution_workspaces_company_source_issue_idx", @@ -22026,9 +23467,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspaces_company_last_used_idx": { "name": "execution_workspaces_company_last_used_idx", @@ -22047,9 +23488,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "execution_workspaces_company_branch_idx": { "name": "execution_workspaces_company_branch_idx", @@ -22068,76 +23509,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "execution_workspaces_company_id_companies_id_fk": { "name": "execution_workspaces_company_id_companies_id_fk", "tableFrom": "execution_workspaces", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "execution_workspaces_project_id_projects_id_fk": { "name": "execution_workspaces_project_id_projects_id_fk", "tableFrom": "execution_workspaces", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", "tableFrom": "execution_workspaces", - "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], + "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "execution_workspaces_source_issue_id_issues_id_fk": { "name": "execution_workspaces_source_issue_id_issues_id_fk", "tableFrom": "execution_workspaces", - "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "execution_workspaces", - "tableTo": "execution_workspaces", "columnsFrom": [ "derived_from_execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -22287,9 +23728,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_object_mentions_company_object_idx": { "name": "external_object_mentions_company_object_idx", @@ -22308,9 +23749,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_object_mentions_company_provider_idx": { "name": "external_object_mentions_company_provider_idx", @@ -22335,9 +23776,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_object_mentions_company_source_record_uq": { "name": "external_object_mentions_company_source_record_uq", @@ -22386,10 +23827,10 @@ } ], "isUnique": true, - "where": "\"external_object_mentions\".\"source_record_id\" is not null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"external_object_mentions\".\"source_record_id\" is not null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", + "concurrently": false }, "external_object_mentions_company_source_null_record_uq": { "name": "external_object_mentions_company_source_null_record_uq", @@ -22432,64 +23873,64 @@ } ], "isUnique": true, - "where": "\"external_object_mentions\".\"source_record_id\" is null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"external_object_mentions\".\"source_record_id\" is null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", + "concurrently": false } }, "foreignKeys": { "external_object_mentions_company_id_companies_id_fk": { "name": "external_object_mentions_company_id_companies_id_fk", "tableFrom": "external_object_mentions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "external_object_mentions_source_issue_id_issues_id_fk": { "name": "external_object_mentions_source_issue_id_issues_id_fk", "tableFrom": "external_object_mentions", - "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "external_object_mentions_object_id_external_objects_id_fk": { "name": "external_object_mentions_object_id_external_objects_id_fk", "tableFrom": "external_object_mentions", - "tableTo": "external_objects", "columnsFrom": [ "object_id" ], + "tableTo": "external_objects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "external_object_mentions_created_by_plugin_id_plugins_id_fk": { "name": "external_object_mentions_created_by_plugin_id_plugins_id_fk", "tableFrom": "external_object_mentions", - "tableTo": "plugins", "columnsFrom": [ "created_by_plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -22721,9 +24162,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_objects_company_provider_status_idx": { "name": "external_objects_company_provider_status_idx", @@ -22748,9 +24189,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_objects_company_refresh_idx": { "name": "external_objects_company_refresh_idx", @@ -22769,9 +24210,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_objects_company_external_id_uq": { "name": "external_objects_company_external_id_uq", @@ -22802,9 +24243,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "external_objects_company_identity_uq": { "name": "external_objects_company_identity_uq", @@ -22835,37 +24276,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "external_objects_company_id_companies_id_fk": { "name": "external_objects_company_id_companies_id_fk", "tableFrom": "external_objects", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "external_objects_plugin_id_plugins_id_fk": { "name": "external_objects_plugin_id_plugins_id_fk", "tableFrom": "external_objects", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -23055,9 +24496,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_exports_company_created_idx": { "name": "feedback_exports_company_created_idx", @@ -23076,9 +24517,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_exports_company_status_idx": { "name": "feedback_exports_company_status_idx", @@ -23103,9 +24544,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_exports_company_issue_idx": { "name": "feedback_exports_company_issue_idx", @@ -23130,9 +24571,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_exports_company_project_idx": { "name": "feedback_exports_company_project_idx", @@ -23157,9 +24598,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_exports_company_author_idx": { "name": "feedback_exports_company_author_idx", @@ -23184,63 +24625,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "feedback_exports_company_id_companies_id_fk": { "name": "feedback_exports_company_id_companies_id_fk", "tableFrom": "feedback_exports", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", "tableFrom": "feedback_exports", - "tableTo": "feedback_votes", "columnsFrom": [ "feedback_vote_id" ], + "tableTo": "feedback_votes", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "feedback_exports_issue_id_issues_id_fk": { "name": "feedback_exports_issue_id_issues_id_fk", "tableFrom": "feedback_exports", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "feedback_exports_project_id_projects_id_fk": { "name": "feedback_exports_project_id_projects_id_fk", "tableFrom": "feedback_exports", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -23360,9 +24801,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_votes_issue_target_idx": { "name": "feedback_votes_issue_target_idx", @@ -23387,9 +24828,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_votes_author_idx": { "name": "feedback_votes_author_idx", @@ -23408,9 +24849,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "feedback_votes_company_target_author_idx": { "name": "feedback_votes_company_target_author_idx", @@ -23441,37 +24882,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "feedback_votes_company_id_companies_id_fk": { "name": "feedback_votes_company_id_companies_id_fk", "tableFrom": "feedback_votes", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "feedback_votes_issue_id_issues_id_fk": { "name": "feedback_votes_issue_id_issues_id_fk", "tableFrom": "feedback_votes", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -23533,6 +24974,18 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, "billing_code": { "name": "billing_code", "type": "text", @@ -23608,7 +25061,7 @@ }, "amount_cents": { "name": "amount_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -23653,6 +25106,27 @@ } }, "indexes": { + "finance_events_company_receipt_idx": { + "name": "finance_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, "finance_events_company_occurred_idx": { "name": "finance_events_company_occurred_idx", "columns": [ @@ -23670,9 +25144,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "finance_events_company_biller_occurred_idx": { "name": "finance_events_company_biller_occurred_idx", @@ -23697,9 +25171,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "finance_events_company_kind_occurred_idx": { "name": "finance_events_company_kind_occurred_idx", @@ -23724,9 +25198,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "finance_events_company_direction_occurred_idx": { "name": "finance_events_company_direction_occurred_idx", @@ -23751,9 +25225,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "finance_events_company_heartbeat_run_idx": { "name": "finance_events_company_heartbeat_run_idx", @@ -23772,9 +25246,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "finance_events_company_cost_event_idx": { "name": "finance_events_company_cost_event_idx", @@ -23793,102 +25267,102 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "finance_events_company_id_companies_id_fk": { "name": "finance_events_company_id_companies_id_fk", "tableFrom": "finance_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "finance_events_agent_id_agents_id_fk": { "name": "finance_events_agent_id_agents_id_fk", "tableFrom": "finance_events", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "finance_events_issue_id_issues_id_fk": { "name": "finance_events_issue_id_issues_id_fk", "tableFrom": "finance_events", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "finance_events_project_id_projects_id_fk": { "name": "finance_events_project_id_projects_id_fk", "tableFrom": "finance_events", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "finance_events_goal_id_goals_id_fk": { "name": "finance_events_goal_id_goals_id_fk", "tableFrom": "finance_events", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "finance_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "finance_events_cost_event_id_cost_events_id_fk": { "name": "finance_events_cost_event_id_cost_events_id_fk", "tableFrom": "finance_events", - "tableTo": "cost_events", "columnsFrom": [ "cost_event_id" ], + "tableTo": "cost_events", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -24002,9 +25476,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "folders_company_kind_root_slug_uq": { "name": "folders_company_kind_root_slug_uq", @@ -24029,10 +25503,10 @@ } ], "isUnique": true, - "where": "\"folders\".\"parent_id\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"folders\".\"parent_id\" is null", + "concurrently": false }, "folders_company_kind_parent_slug_uq": { "name": "folders_company_kind_parent_slug_uq", @@ -24063,10 +25537,10 @@ } ], "isUnique": true, - "where": "\"folders\".\"parent_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"folders\".\"parent_id\" is not null", + "concurrently": false }, "folders_company_kind_system_key_uq": { "name": "folders_company_kind_system_key_uq", @@ -24091,10 +25565,10 @@ } ], "isUnique": true, - "where": "\"folders\".\"system_key\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"folders\".\"system_key\" is not null", + "concurrently": false }, "folders_company_kind_parent_position_idx": { "name": "folders_company_kind_parent_position_idx", @@ -24131,37 +25605,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "folders_company_id_companies_id_fk": { "name": "folders_company_id_companies_id_fk", "tableFrom": "folders", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "folders_parent_id_folders_id_fk": { "name": "folders_parent_id_folders_id_fk", "tableFrom": "folders", - "tableTo": "folders", "columnsFrom": [ "parent_id" ], + "tableTo": "folders", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" } }, "compositePrimaryKeys": {}, @@ -24252,50 +25726,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "goals_company_id_companies_id_fk": { "name": "goals_company_id_companies_id_fk", "tableFrom": "goals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "goals_parent_id_goals_id_fk": { "name": "goals_parent_id_goals_id_fk", "tableFrom": "goals", - "tableTo": "goals", "columnsFrom": [ "parent_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "goals_owner_agent_id_agents_id_fk": { "name": "goals_owner_agent_id_agents_id_fk", "tableFrom": "goals", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -24430,9 +25904,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_run_events_run_source_event_uq": { "name": "heartbeat_run_events_run_source_event_uq", @@ -24451,10 +25925,10 @@ } ], "isUnique": true, - "where": "\"heartbeat_run_events\".\"source_event_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"heartbeat_run_events\".\"source_event_id\" is not null", + "concurrently": false }, "heartbeat_run_events_run_source_seq_uq": { "name": "heartbeat_run_events_run_source_seq_uq", @@ -24479,10 +25953,10 @@ } ], "isUnique": true, - "where": "\"heartbeat_run_events\".\"source_instance_id\" is not null and \"heartbeat_run_events\".\"source_seq\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"heartbeat_run_events\".\"source_instance_id\" is not null and \"heartbeat_run_events\".\"source_seq\" is not null", + "concurrently": false }, "heartbeat_run_events_company_run_idx": { "name": "heartbeat_run_events_company_run_idx", @@ -24501,9 +25975,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_run_events_company_created_idx": { "name": "heartbeat_run_events_company_created_idx", @@ -24522,50 +25996,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "heartbeat_run_events_company_id_companies_id_fk": { "name": "heartbeat_run_events_company_id_companies_id_fk", "tableFrom": "heartbeat_run_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "heartbeat_run_events_agent_id_agents_id_fk": { "name": "heartbeat_run_events_agent_id_agents_id_fk", "tableFrom": "heartbeat_run_events", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -24671,9 +26145,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_run_watchdog_decisions_company_run_snooze_idx": { "name": "heartbeat_run_watchdog_decisions_company_run_snooze_idx", @@ -24698,76 +26172,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "heartbeat_run_watchdog_decisions_company_id_companies_id_fk": { "name": "heartbeat_run_watchdog_decisions_company_id_companies_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk": { "name": "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", - "tableTo": "issues", "columnsFrom": [ "evaluation_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk": { "name": "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -24799,6 +26273,19 @@ "primaryKey": false, "notNull": true }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "invocation_source": { "name": "invocation_source", "type": "text", @@ -24879,6 +26366,44 @@ "primaryKey": false, "notNull": false }, + "cost_accounting_pending": { + "name": "cost_accounting_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_accounted_at": { + "name": "cost_accounted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_projection_version": { + "name": "accounting_projection_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_last_attempt_at": { + "name": "accounting_last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_last_error": { + "name": "accounting_last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_attempt_count": { + "name": "accounting_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "usage_json": { "name": "usage_json", "type": "jsonb", @@ -25215,6 +26740,28 @@ } }, "indexes": { + "heartbeat_runs_cost_accounting_pending_idx": { + "name": "heartbeat_runs_cost_accounting_pending_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true", + "concurrently": false + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -25226,10 +26773,10 @@ } ], "isUnique": false, - "where": "\"heartbeat_runs\".\"execution_status_delivery_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"heartbeat_runs\".\"execution_status_delivery_id\" is not null", + "concurrently": false }, "heartbeat_runs_execution_control_deadline_idx": { "name": "heartbeat_runs_execution_control_deadline_idx", @@ -25242,10 +26789,10 @@ } ], "isUnique": false, - "where": "\"heartbeat_runs\".\"execution_control_deadline_at\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"heartbeat_runs\".\"execution_control_deadline_at\" is not null", + "concurrently": false }, "heartbeat_runs_native_replacement_predecessor_uq": { "name": "heartbeat_runs_native_replacement_predecessor_uq", @@ -25264,10 +26811,10 @@ } ], "isUnique": true, - "where": "\"heartbeat_runs\".\"scheduled_retry_reason\" = 'native_safe_replacement'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"heartbeat_runs\".\"scheduled_retry_reason\" = 'native_safe_replacement'", + "concurrently": false }, "heartbeat_runs_company_agent_started_idx": { "name": "heartbeat_runs_company_agent_started_idx", @@ -25292,9 +26839,36 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false + }, + "heartbeat_runs_company_issue_created_idx": { + "name": "heartbeat_runs_company_issue_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false }, "heartbeat_runs_company_responsible_user_idx": { "name": "heartbeat_runs_company_responsible_user_idx", @@ -25319,9 +26893,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_liveness_idx": { "name": "heartbeat_runs_company_liveness_idx", @@ -25346,9 +26920,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_status_last_output_idx": { "name": "heartbeat_runs_company_status_last_output_idx", @@ -25373,9 +26947,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_status_process_started_idx": { "name": "heartbeat_runs_company_status_process_started_idx", @@ -25400,9 +26974,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_created_at_desc_idx": { "name": "heartbeat_runs_company_created_at_desc_idx", @@ -25421,9 +26995,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_ctx_issue_created_idx": { "name": "heartbeat_runs_company_ctx_issue_created_idx", @@ -25436,8 +27010,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'issueId')", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" }, { @@ -25448,9 +27022,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_ctx_task_created_idx": { "name": "heartbeat_runs_company_ctx_task_created_idx", @@ -25463,8 +27037,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'taskId')", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" }, { @@ -25475,9 +27049,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "heartbeat_runs_company_ctx_taskkey_created_idx": { "name": "heartbeat_runs_company_ctx_taskkey_created_idx", @@ -25490,8 +27064,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'taskKey')", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" }, { @@ -25502,89 +27076,107 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "heartbeat_runs_company_id_companies_id_fk": { "name": "heartbeat_runs_company_id_companies_id_fk", "tableFrom": "heartbeat_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "heartbeat_runs_agent_id_agents_id_fk": { "name": "heartbeat_runs_agent_id_agents_id_fk", "tableFrom": "heartbeat_runs", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" + }, + "heartbeat_runs_issue_id_issues_id_fk": { + "name": "heartbeat_runs_issue_id_issues_id_fk", + "tableFrom": "heartbeat_runs", + "columnsFrom": [ + "issue_id" + ], + "tableTo": "issues", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" }, "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", "tableFrom": "heartbeat_runs", - "tableTo": "agent_wakeup_requests", "columnsFrom": [ "wakeup_request_id" ], + "tableTo": "agent_wakeup_requests", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_runs", - "tableTo": "heartbeat_runs", "columnsFrom": [ "retry_of_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "heartbeat_runs_company_native_issue_id_uq": { "name": "heartbeat_runs_company_native_issue_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "native_issue_id", "id" - ] + ], + "nullsNotDistinct": false }, "heartbeat_runs_company_native_issue_contract_id_uq": { "name": "heartbeat_runs_company_native_issue_contract_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "native_issue_id", "id", "completion_contract_id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "heartbeat_runs_scope_binding_check": { + "name": "heartbeat_runs_scope_binding_check", + "value": "(\"heartbeat_runs\".\"scope_kind\" = 'company' AND \"heartbeat_runs\".\"issue_id\" IS NULL)\n OR \"heartbeat_runs\".\"scope_kind\" = 'issue'" + } + }, "isRLSEnabled": false }, "public.inbox_dismissals": { @@ -25669,9 +27261,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "inbox_dismissals_company_item_idx": { "name": "inbox_dismissals_company_item_idx", @@ -25690,9 +27282,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "inbox_dismissals_company_user_item_idx": { "name": "inbox_dismissals_company_user_item_idx", @@ -25717,24 +27309,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "inbox_dismissals_company_id_companies_id_fk": { "name": "inbox_dismissals_company_id_companies_id_fk", "tableFrom": "inbox_dismissals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -25804,9 +27396,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grant_delegations_grant_agent_uq": { "name": "connection_grant_delegations_grant_agent_uq", @@ -25825,52 +27417,52 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "connection_grant_delegations_company_id_companies_id_fk": { "name": "connection_grant_delegations_company_id_companies_id_fk", "tableFrom": "connection_grant_delegations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_grant_delegations_agent_id_agents_id_fk": { "name": "connection_grant_delegations_agent_id_agents_id_fk", "tableFrom": "connection_grant_delegations", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_grant_delegations_company_grant_fk": { "name": "connection_grant_delegations_company_grant_fk", "tableFrom": "connection_grant_delegations", - "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -25946,9 +27538,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grant_members_grant_subject_uq": { "name": "connection_grant_members_grant_subject_uq", @@ -25973,39 +27565,39 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "connection_grant_members_company_id_companies_id_fk": { "name": "connection_grant_members_company_id_companies_id_fk", "tableFrom": "connection_grant_members", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_grant_members_company_grant_fk": { "name": "connection_grant_members_company_grant_fk", "tableFrom": "connection_grant_members", - "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], + "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -26162,9 +27754,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grants_subject_user_idx": { "name": "connection_grants_subject_user_idx", @@ -26183,9 +27775,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grants_subject_agent_idx": { "name": "connection_grants_subject_agent_idx", @@ -26204,9 +27796,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grants_user_uq": { "name": "connection_grants_user_uq", @@ -26225,9 +27817,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grants_agent_uq": { "name": "connection_grants_agent_uq", @@ -26246,9 +27838,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_grants_default_uq": { "name": "connection_grants_default_uq", @@ -26261,90 +27853,90 @@ } ], "isUnique": true, - "where": "\"connection_grants\".\"is_default\" = true and \"connection_grants\".\"kind\" = 'organization'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"connection_grants\".\"is_default\" = true and \"connection_grants\".\"kind\" = 'organization'", + "concurrently": false } }, "foreignKeys": { "connection_grants_company_id_companies_id_fk": { "name": "connection_grants_company_id_companies_id_fk", "tableFrom": "connection_grants", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_grants_subject_agent_id_agents_id_fk": { "name": "connection_grants_subject_agent_id_agents_id_fk", "tableFrom": "connection_grants", - "tableTo": "agents", "columnsFrom": [ "subject_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_grants_created_by_agent_id_agents_id_fk": { "name": "connection_grants_created_by_agent_id_agents_id_fk", "tableFrom": "connection_grants", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "connection_grants_revoked_by_agent_id_agents_id_fk": { "name": "connection_grants_revoked_by_agent_id_agents_id_fk", "tableFrom": "connection_grants", - "tableTo": "agents", "columnsFrom": [ "revoked_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "connection_grants_company_connection_fk": { "name": "connection_grants_company_connection_fk", "tableFrom": "connection_grants", - "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "connection_grants_company_id_uq": { "name": "connection_grants_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -26514,9 +28106,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_token_issuances_connection_created_idx": { "name": "connection_token_issuances_connection_created_idx", @@ -26541,9 +28133,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_token_issuances_agent_connection_idx": { "name": "connection_token_issuances_agent_connection_idx", @@ -26574,9 +28166,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "connection_token_issuances_run_idx": { "name": "connection_token_issuances_run_idx", @@ -26595,102 +28187,102 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "connection_token_issuances_company_id_companies_id_fk": { "name": "connection_token_issuances_company_id_companies_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_token_issuances_application_id_tool_applications_id_fk": { "name": "connection_token_issuances_application_id_tool_applications_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "connection_token_issuances_connection_id_tool_connections_id_fk": { "name": "connection_token_issuances_connection_id_tool_connections_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_token_issuances_agent_id_agents_id_fk": { "name": "connection_token_issuances_agent_id_agents_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "connection_token_issuances_run_id_heartbeat_runs_id_fk": { "name": "connection_token_issuances_run_id_heartbeat_runs_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "connection_token_issuances_issue_id_issues_id_fk": { "name": "connection_token_issuances_issue_id_issues_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "connection_token_issuances_project_id_projects_id_fk": { "name": "connection_token_issuances_project_id_projects_id_fk", "tableFrom": "connection_token_issuances", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -26764,24 +28356,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "instance_settings_default_environment_id_environments_id_fk": { "name": "instance_settings_default_environment_id_environments_id_fk", "tableFrom": "instance_settings", - "tableTo": "environments", "columnsFrom": [ "default_environment_id" ], + "tableTo": "environments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -26847,9 +28439,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "instance_user_roles_role_idx": { "name": "instance_user_roles_role_idx", @@ -26862,9 +28454,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -26968,9 +28560,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "invites_company_invite_state_idx": { "name": "invites_company_invite_state_idx", @@ -27001,24 +28593,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "invites_company_id_companies_id_fk": { "name": "invites_company_id_companies_id_fk", "tableFrom": "invites", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -27027,6 +28619,166 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.issue_access_grants": { + "name": "issue_access_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by_agent_id": { + "name": "granted_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_access_grants_subject_active_issue_idx": { + "name": "issue_access_grants_subject_active_issue_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "issue_access_grants_issue_idx": { + "name": "issue_access_grants_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "issue_access_grants_issue_id_issues_id_fk": { + "name": "issue_access_grants_issue_id_issues_id_fk", + "tableFrom": "issue_access_grants", + "columnsFrom": [ + "issue_id" + ], + "tableTo": "issues", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "issue_access_grants_granted_by_agent_id_agents_id_fk": { + "name": "issue_access_grants_granted_by_agent_id_agents_id_fk", + "tableFrom": "issue_access_grants", + "columnsFrom": [ + "granted_by_agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "issue_access_grants_subject_type_check": { + "name": "issue_access_grants_subject_type_check", + "value": "\"issue_access_grants\".\"subject_type\" in ('user', 'agent')" + }, + "issue_access_grants_source_check": { + "name": "issue_access_grants_source_check", + "value": "\"issue_access_grants\".\"source\" in ('explicit', 'assignment', 'project')" + } + }, + "isRLSEnabled": false + }, "public.issue_approvals": { "name": "issue_approvals", "schema": "", @@ -27081,9 +28833,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_approvals_approval_idx": { "name": "issue_approvals_approval_idx", @@ -27096,9 +28848,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_approvals_company_idx": { "name": "issue_approvals_company_idx", @@ -27111,63 +28863,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_approvals_company_id_companies_id_fk": { "name": "issue_approvals_company_id_companies_id_fk", "tableFrom": "issue_approvals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_approvals_issue_id_issues_id_fk": { "name": "issue_approvals_issue_id_issues_id_fk", "tableFrom": "issue_approvals", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_approvals_approval_id_approvals_id_fk": { "name": "issue_approvals_approval_id_approvals_id_fk", "tableFrom": "issue_approvals", - "tableTo": "approvals", "columnsFrom": [ "approval_id" ], + "tableTo": "approvals", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_approvals_linked_by_agent_id_agents_id_fk": { "name": "issue_approvals_linked_by_agent_id_agents_id_fk", "tableFrom": "issue_approvals", - "tableTo": "agents", "columnsFrom": [ "linked_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": { @@ -27258,9 +29010,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_attachments_issue_comment_idx": { "name": "issue_attachments_issue_comment_idx", @@ -27273,9 +29025,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_attachments_originating_run_idx": { "name": "issue_attachments_originating_run_idx", @@ -27288,9 +29040,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_attachments_asset_uq": { "name": "issue_attachments_asset_uq", @@ -27303,76 +29055,76 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_attachments_company_id_companies_id_fk": { "name": "issue_attachments_company_id_companies_id_fk", "tableFrom": "issue_attachments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_attachments_issue_id_issues_id_fk": { "name": "issue_attachments_issue_id_issues_id_fk", "tableFrom": "issue_attachments", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_attachments_asset_id_assets_id_fk": { "name": "issue_attachments_asset_id_assets_id_fk", "tableFrom": "issue_attachments", - "tableTo": "assets", "columnsFrom": [ "asset_id" ], + "tableTo": "assets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_attachments_issue_comment_id_issue_comments_id_fk": { "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", "tableFrom": "issue_attachments", - "tableTo": "issue_comments", "columnsFrom": [ "issue_comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_attachments_originating_run_id_heartbeat_runs_id_fk": { "name": "issue_attachments_originating_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_attachments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "originating_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -27545,9 +29297,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_comments_company_idx": { "name": "issue_comments_company_idx", @@ -27560,9 +29312,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_comments_company_issue_created_at_idx": { "name": "issue_comments_company_issue_created_at_idx", @@ -27587,9 +29339,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_comments_company_author_issue_created_at_idx": { "name": "issue_comments_company_author_issue_created_at_idx", @@ -27620,9 +29372,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_comments_body_search_idx": { "name": "issue_comments_body_search_idx", @@ -27636,148 +29388,148 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_comments_company_id_companies_id_fk": { "name": "issue_comments_company_id_companies_id_fk", "tableFrom": "issue_comments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_comments_issue_id_issues_id_fk": { "name": "issue_comments_issue_id_issues_id_fk", "tableFrom": "issue_comments", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_comments_author_agent_id_agents_id_fk": { "name": "issue_comments_author_agent_id_agents_id_fk", "tableFrom": "issue_comments", - "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_comments_on_behalf_of_user_id_user_id_fk": { "name": "issue_comments_on_behalf_of_user_id_user_id_fk", "tableFrom": "issue_comments", - "tableTo": "user", "columnsFrom": [ "on_behalf_of_user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_comments_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_comments_derived_author_agent_id_agents_id_fk": { "name": "issue_comments_derived_author_agent_id_agents_id_fk", "tableFrom": "issue_comments", - "tableTo": "agents", "columnsFrom": [ "derived_author_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "derived_created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_comments_deleted_by_agent_id_agents_id_fk": { "name": "issue_comments_deleted_by_agent_id_agents_id_fk", "tableFrom": "issue_comments", - "tableTo": "agents", "columnsFrom": [ "deleted_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "deleted_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "issue_comments_client_request_uq": { "name": "issue_comments_client_request_uq", - "nullsNotDistinct": false, "columns": [ "issue_id", "author_user_id", "client_request_id" - ] + ], + "nullsNotDistinct": false }, "issue_comments_company_id_uq": { "name": "issue_comments_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -27839,9 +29591,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_create_idempotency_keys_issue_idx": { "name": "issue_create_idempotency_keys_issue_idx", @@ -27854,9 +29606,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_create_idempotency_keys_company_created_at_idx": { "name": "issue_create_idempotency_keys_company_created_at_idx", @@ -27875,37 +29627,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_create_idempotency_keys_company_id_companies_id_fk": { "name": "issue_create_idempotency_keys_company_id_companies_id_fk", "tableFrom": "issue_create_idempotency_keys", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_create_idempotency_keys_issue_id_issues_id_fk": { "name": "issue_create_idempotency_keys_issue_id_issues_id_fk", "tableFrom": "issue_create_idempotency_keys", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -27988,9 +29740,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_documents_document_uq": { "name": "issue_documents_document_uq", @@ -28003,9 +29755,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_documents_company_issue_updated_idx": { "name": "issue_documents_company_issue_updated_idx", @@ -28030,50 +29782,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_documents_company_id_companies_id_fk": { "name": "issue_documents_company_id_companies_id_fk", "tableFrom": "issue_documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_documents_issue_id_issues_id_fk": { "name": "issue_documents_issue_id_issues_id_fk", "tableFrom": "issue_documents", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_documents_document_id_documents_id_fk": { "name": "issue_documents_document_id_documents_id_fk", "tableFrom": "issue_documents", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -28180,9 +29932,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_execution_decisions_stage_idx": { "name": "issue_execution_decisions_stage_idx", @@ -28207,63 +29959,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_execution_decisions_company_id_companies_id_fk": { "name": "issue_execution_decisions_company_id_companies_id_fk", "tableFrom": "issue_execution_decisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_execution_decisions_issue_id_issues_id_fk": { "name": "issue_execution_decisions_issue_id_issues_id_fk", "tableFrom": "issue_execution_decisions", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_execution_decisions_actor_agent_id_agents_id_fk": { "name": "issue_execution_decisions_actor_agent_id_agents_id_fk", "tableFrom": "issue_execution_decisions", - "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_execution_decisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -28360,9 +30112,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_inbox_archives_company_user_idx": { "name": "issue_inbox_archives_company_user_idx", @@ -28381,9 +30133,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_inbox_archives_company_issue_user_idx": { "name": "issue_inbox_archives_company_issue_user_idx", @@ -28408,63 +30160,63 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_inbox_archives_company_id_companies_id_fk": { "name": "issue_inbox_archives_company_id_companies_id_fk", "tableFrom": "issue_inbox_archives", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_inbox_archives_issue_id_issues_id_fk": { "name": "issue_inbox_archives_issue_id_issues_id_fk", "tableFrom": "issue_inbox_archives", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_inbox_archives_archived_by_agent_id_agents_id_fk": { "name": "issue_inbox_archives_archived_by_agent_id_agents_id_fk", "tableFrom": "issue_inbox_archives", - "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk": { "name": "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_inbox_archives", - "tableTo": "heartbeat_runs", "columnsFrom": [ "archived_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -28520,9 +30272,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_labels_label_idx": { "name": "issue_labels_label_idx", @@ -28535,9 +30287,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_labels_company_idx": { "name": "issue_labels_company_idx", @@ -28550,50 +30302,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_labels_issue_id_issues_id_fk": { "name": "issue_labels_issue_id_issues_id_fk", "tableFrom": "issue_labels", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_labels_label_id_labels_id_fk": { "name": "issue_labels_label_id_labels_id_fk", "tableFrom": "issue_labels", - "tableTo": "labels", "columnsFrom": [ "label_id" ], + "tableTo": "labels", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_labels_company_id_companies_id_fk": { "name": "issue_labels_company_id_companies_id_fk", "tableFrom": "issue_labels", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": { @@ -28742,9 +30494,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_plan_decompositions_active_owner_idx": { "name": "issue_plan_decompositions_active_owner_idx", @@ -28763,10 +30515,10 @@ } ], "isUnique": false, - "where": "\"issue_plan_decompositions\".\"status\" = 'in_flight'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_plan_decompositions\".\"status\" = 'in_flight'", + "concurrently": false }, "issue_plan_decompositions_source_revision_uq": { "name": "issue_plan_decompositions_source_revision_uq", @@ -28791,89 +30543,89 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_plan_decompositions_company_id_companies_id_fk": { "name": "issue_plan_decompositions_company_id_companies_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_plan_decompositions_source_issue_id_issues_id_fk": { "name": "issue_plan_decompositions_source_issue_id_issues_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk": { "name": "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "document_revisions", "columnsFrom": [ "accepted_plan_revision_id" ], + "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk": { "name": "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "accepted_interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_plan_decompositions_owner_agent_id_agents_id_fk": { "name": "issue_plan_decompositions_owner_agent_id_agents_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk": { "name": "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_plan_decompositions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "owner_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -29013,9 +30765,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_question_response_deliveries_correlation_uq": { "name": "issue_question_response_deliveries_correlation_uq", @@ -29028,9 +30780,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_question_response_deliveries_pending_idx": { "name": "issue_question_response_deliveries_pending_idx", @@ -29049,9 +30801,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_question_response_deliveries_company_issue_idx": { "name": "issue_question_response_deliveries_company_issue_idx", @@ -29076,76 +30828,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_question_response_deliveries_company_id_companies_id_fk": { "name": "issue_question_response_deliveries_company_id_companies_id_fk", "tableFrom": "issue_question_response_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_question_response_deliveries_issue_id_issues_id_fk": { "name": "issue_question_response_deliveries_issue_id_issues_id_fk", "tableFrom": "issue_question_response_deliveries", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_question_response_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "issue_question_response_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "issue_question_response_deliveries", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_question_response_deliveries_source_run_id_heartbeat_runs_id_fk": { "name": "issue_question_response_deliveries_source_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_question_response_deliveries", - "tableTo": "heartbeat_runs", "columnsFrom": [ "source_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_question_response_deliveries_target_run_id_heartbeat_runs_id_fk": { "name": "issue_question_response_deliveries_target_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_question_response_deliveries", - "tableTo": "heartbeat_runs", "columnsFrom": [ "target_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -29232,9 +30984,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_read_states_company_user_idx": { "name": "issue_read_states_company_user_idx", @@ -29253,9 +31005,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_read_states_company_issue_user_idx": { "name": "issue_read_states_company_issue_user_idx", @@ -29280,37 +31032,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_read_states_company_id_companies_id_fk": { "name": "issue_read_states_company_id_companies_id_fk", "tableFrom": "issue_read_states", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_read_states_issue_id_issues_id_fk": { "name": "issue_read_states_issue_id_issues_id_fk", "tableFrom": "issue_read_states", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -29511,9 +31263,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_recovery_actions_company_owner_status_idx": { "name": "issue_recovery_actions_company_owner_status_idx", @@ -29538,9 +31290,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_recovery_actions_company_recovery_issue_idx": { "name": "issue_recovery_actions_company_recovery_issue_idx", @@ -29559,9 +31311,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_recovery_actions_active_source_uq": { "name": "issue_recovery_actions_active_source_uq", @@ -29580,10 +31332,10 @@ } ], "isUnique": true, - "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false }, "issue_recovery_actions_active_fingerprint_uq": { "name": "issue_recovery_actions_active_fingerprint_uq", @@ -29614,90 +31366,90 @@ } ], "isUnique": true, - "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false } }, "foreignKeys": { "issue_recovery_actions_company_id_companies_id_fk": { "name": "issue_recovery_actions_company_id_companies_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_recovery_actions_source_issue_id_issues_id_fk": { "name": "issue_recovery_actions_source_issue_id_issues_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_recovery_actions_recovery_issue_id_issues_id_fk": { "name": "issue_recovery_actions_recovery_issue_id_issues_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "issues", "columnsFrom": [ "recovery_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_recovery_actions_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_recovery_actions_previous_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_previous_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "agents", "columnsFrom": [ "previous_owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_recovery_actions_return_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_return_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", - "tableTo": "agents", "columnsFrom": [ "return_owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -29792,9 +31544,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_reference_mentions_company_target_issue_idx": { "name": "issue_reference_mentions_company_target_issue_idx", @@ -29813,9 +31565,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_reference_mentions_company_issue_pair_idx": { "name": "issue_reference_mentions_company_issue_pair_idx", @@ -29840,9 +31592,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_reference_mentions_company_source_mention_record_uq": { "name": "issue_reference_mentions_company_source_mention_record_uq", @@ -29879,10 +31631,10 @@ } ], "isUnique": true, - "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", + "concurrently": false }, "issue_reference_mentions_company_source_mention_null_record_uq": { "name": "issue_reference_mentions_company_source_mention_null_record_uq", @@ -29913,51 +31665,51 @@ } ], "isUnique": true, - "where": "\"issue_reference_mentions\".\"source_record_id\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_reference_mentions\".\"source_record_id\" is null", + "concurrently": false } }, "foreignKeys": { "issue_reference_mentions_company_id_companies_id_fk": { "name": "issue_reference_mentions_company_id_companies_id_fk", "tableFrom": "issue_reference_mentions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_reference_mentions_source_issue_id_issues_id_fk": { "name": "issue_reference_mentions_source_issue_id_issues_id_fk", "tableFrom": "issue_reference_mentions", - "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_reference_mentions_target_issue_id_issues_id_fk": { "name": "issue_reference_mentions_target_issue_id_issues_id_fk", "tableFrom": "issue_reference_mentions", - "tableTo": "issues", "columnsFrom": [ "target_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -30046,9 +31798,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_relations_company_related_issue_idx": { "name": "issue_relations_company_related_issue_idx", @@ -30067,9 +31819,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_relations_company_type_idx": { "name": "issue_relations_company_type_idx", @@ -30088,9 +31840,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_relations_company_edge_uq": { "name": "issue_relations_company_edge_uq", @@ -30121,63 +31873,63 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_relations_company_id_companies_id_fk": { "name": "issue_relations_company_id_companies_id_fk", "tableFrom": "issue_relations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_relations_issue_id_issues_id_fk": { "name": "issue_relations_issue_id_issues_id_fk", "tableFrom": "issue_relations", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_relations_related_issue_id_issues_id_fk": { "name": "issue_relations_related_issue_id_issues_id_fk", "tableFrom": "issue_relations", - "tableTo": "issues", "columnsFrom": [ "related_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_relations_created_by_agent_id_agents_id_fk": { "name": "issue_relations_created_by_agent_id_agents_id_fk", "tableFrom": "issue_relations", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -30387,9 +32139,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_thread_interactions_company_issue_created_at_idx": { "name": "issue_thread_interactions_company_issue_created_at_idx", @@ -30414,9 +32166,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_thread_interactions_company_issue_status_idx": { "name": "issue_thread_interactions_company_issue_status_idx", @@ -30441,9 +32193,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_thread_interactions_company_issue_idempotency_uq": { "name": "issue_thread_interactions_company_issue_idempotency_uq", @@ -30468,10 +32220,10 @@ } ], "isUnique": true, - "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL", + "concurrently": false }, "issue_thread_interactions_source_comment_idx": { "name": "issue_thread_interactions_source_comment_idx", @@ -30484,9 +32236,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_thread_interactions_addressee_agent_idx": { "name": "issue_thread_interactions_addressee_agent_idx", @@ -30499,9 +32251,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_thread_interactions_addressee_user_idx": { "name": "issue_thread_interactions_addressee_user_idx", @@ -30514,115 +32266,115 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_thread_interactions_company_id_companies_id_fk": { "name": "issue_thread_interactions_company_id_companies_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_thread_interactions_issue_id_issues_id_fk": { "name": "issue_thread_interactions_issue_id_issues_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_thread_interactions_source_comment_id_issue_comments_id_fk": { "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "issue_comments", "columnsFrom": [ "source_comment_id" ], + "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": { "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "source_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_thread_interactions_created_by_agent_id_agents_id_fk": { "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_thread_interactions_addressee_agent_id_agents_id_fk": { "name": "issue_thread_interactions_addressee_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "agents", "columnsFrom": [ "addressee_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_thread_interactions_resolved_by_agent_id_agents_id_fk": { "name": "issue_thread_interactions_resolved_by_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk": { "name": "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_thread_interactions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "resolved_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -30754,9 +32506,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_tree_hold_members_company_issue_idx": { "name": "issue_tree_hold_members_company_issue_idx", @@ -30775,9 +32527,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_tree_hold_members_hold_depth_idx": { "name": "issue_tree_hold_members_hold_depth_idx", @@ -30796,89 +32548,89 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_tree_hold_members_company_id_companies_id_fk": { "name": "issue_tree_hold_members_company_id_companies_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk": { "name": "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "issue_tree_holds", "columnsFrom": [ "hold_id" ], + "tableTo": "issue_tree_holds", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_tree_hold_members_issue_id_issues_id_fk": { "name": "issue_tree_hold_members_issue_id_issues_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_tree_hold_members_parent_issue_id_issues_id_fk": { "name": "issue_tree_hold_members_parent_issue_id_issues_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "issues", "columnsFrom": [ "parent_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_tree_hold_members_assignee_agent_id_agents_id_fk": { "name": "issue_tree_hold_members_assignee_agent_id_agents_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_hold_members", - "tableTo": "heartbeat_runs", "columnsFrom": [ "active_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -31041,9 +32793,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_tree_holds_company_status_mode_idx": { "name": "issue_tree_holds_company_status_mode_idx", @@ -31068,89 +32820,89 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_tree_holds_company_id_companies_id_fk": { "name": "issue_tree_holds_company_id_companies_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_tree_holds_root_issue_id_issues_id_fk": { "name": "issue_tree_holds_root_issue_id_issues_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "issues", "columnsFrom": [ "root_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_tree_holds_created_by_agent_id_agents_id_fk": { "name": "issue_tree_holds_created_by_agent_id_agents_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_tree_holds_released_by_agent_id_agents_id_fk": { "name": "issue_tree_holds_released_by_agent_id_agents_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "agents", "columnsFrom": [ "released_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_holds", - "tableTo": "heartbeat_runs", "columnsFrom": [ "released_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -31319,9 +33071,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_watchdogs_company_status_idx": { "name": "issue_watchdogs_company_status_idx", @@ -31340,9 +33092,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_watchdogs_company_agent_idx": { "name": "issue_watchdogs_company_agent_idx", @@ -31361,9 +33113,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_watchdogs_company_watchdog_issue_uq": { "name": "issue_watchdogs_company_watchdog_issue_uq", @@ -31382,116 +33134,116 @@ } ], "isUnique": true, - "where": "\"issue_watchdogs\".\"watchdog_issue_id\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issue_watchdogs\".\"watchdog_issue_id\" is not null", + "concurrently": false } }, "foreignKeys": { "issue_watchdogs_company_id_companies_id_fk": { "name": "issue_watchdogs_company_id_companies_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_watchdogs_issue_id_issues_id_fk": { "name": "issue_watchdogs_issue_id_issues_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_watchdogs_watchdog_agent_id_agents_id_fk": { "name": "issue_watchdogs_watchdog_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "agents", "columnsFrom": [ "watchdog_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_watchdogs_watchdog_issue_id_issues_id_fk": { "name": "issue_watchdogs_watchdog_issue_id_issues_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "issues", "columnsFrom": [ "watchdog_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_watchdogs_created_by_agent_id_agents_id_fk": { "name": "issue_watchdogs_created_by_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_watchdogs_updated_by_agent_id_agents_id_fk": { "name": "issue_watchdogs_updated_by_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk": { "name": "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_watchdogs", - "tableTo": "heartbeat_runs", "columnsFrom": [ "updated_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -31661,9 +33413,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_work_products_company_execution_workspace_type_idx": { "name": "issue_work_products_company_execution_workspace_type_idx", @@ -31688,9 +33440,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_work_products_company_provider_external_id_idx": { "name": "issue_work_products_company_provider_external_id_idx", @@ -31715,9 +33467,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issue_work_products_company_updated_idx": { "name": "issue_work_products_company_updated_idx", @@ -31736,89 +33488,89 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "issue_work_products_company_id_companies_id_fk": { "name": "issue_work_products_company_id_companies_id_fk", "tableFrom": "issue_work_products", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issue_work_products_project_id_projects_id_fk": { "name": "issue_work_products_project_id_projects_id_fk", "tableFrom": "issue_work_products", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_work_products_issue_id_issues_id_fk": { "name": "issue_work_products_issue_id_issues_id_fk", "tableFrom": "issue_work_products", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "issue_work_products", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", "tableFrom": "issue_work_products", - "tableTo": "workspace_runtime_services", "columnsFrom": [ "runtime_service_id" ], + "tableTo": "workspace_runtime_services", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_work_products", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -31899,6 +33651,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_root_issue_id": { + "name": "privacy_root_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "privacy_parent_issue_id": { + "name": "privacy_parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "title": { "name": "title", "type": "text", @@ -32241,9 +34012,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_status_idx": { "name": "issues_company_status_idx", @@ -32262,9 +34033,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_harness_kind_idx": { "name": "issues_company_harness_kind_idx", @@ -32283,9 +34054,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_assignee_status_idx": { "name": "issues_company_assignee_status_idx", @@ -32310,9 +34081,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_assignee_user_status_idx": { "name": "issues_company_assignee_user_status_idx", @@ -32337,9 +34108,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_responsible_user_idx": { "name": "issues_company_responsible_user_idx", @@ -32358,9 +34129,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_parent_idx": { "name": "issues_company_parent_idx", @@ -32379,9 +34150,51 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false + }, + "issues_company_privacy_parent_idx": { + "name": "issues_company_privacy_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_parent_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "issues_company_privacy_root_idx": { + "name": "issues_company_privacy_root_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_root_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false }, "issues_company_project_idx": { "name": "issues_company_project_idx", @@ -32400,9 +34213,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_origin_idx": { "name": "issues_company_origin_idx", @@ -32427,9 +34240,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", @@ -32448,9 +34261,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_execution_workspace_idx": { "name": "issues_company_execution_workspace_idx", @@ -32469,9 +34282,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_monitor_due_idx": { "name": "issues_company_monitor_due_idx", @@ -32490,9 +34303,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_updated_idx": { "name": "issues_company_updated_idx", @@ -32511,9 +34324,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_company_created_idx": { "name": "issues_company_created_idx", @@ -32532,9 +34345,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_open_normalized_title_created_idx": { "name": "issues_open_normalized_title_created_idx", @@ -32553,8 +34366,8 @@ }, { "expression": "lower(regexp_replace(btrim(\"title\"), '\\s+', ' ', 'g'))", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" }, { @@ -32565,10 +34378,10 @@ } ], "isUnique": false, - "where": "\"issues\".\"hidden_at\" is null and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"hidden_at\" is null and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_company_priority_idx": { "name": "issues_company_priority_idx", @@ -32587,9 +34400,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_identifier_idx": { "name": "issues_identifier_idx", @@ -32602,9 +34415,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "issues_title_search_idx": { "name": "issues_title_search_idx", @@ -32618,9 +34431,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "issues_identifier_search_idx": { "name": "issues_identifier_search_idx", @@ -32634,9 +34447,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "issues_description_search_idx": { "name": "issues_description_search_idx", @@ -32650,9 +34463,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false }, "issues_open_routine_execution_uq": { "name": "issues_open_routine_execution_uq", @@ -32683,10 +34496,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", + "concurrently": false }, "issues_active_liveness_recovery_incident_uq": { "name": "issues_active_liveness_recovery_incident_uq", @@ -32711,10 +34524,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_active_liveness_recovery_leaf_uq": { "name": "issues_active_liveness_recovery_leaf_uq", @@ -32739,10 +34552,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_active_stale_run_evaluation_uq": { "name": "issues_active_stale_run_evaluation_uq", @@ -32767,10 +34580,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_active_task_watchdog_uq": { "name": "issues_active_task_watchdog_uq", @@ -32795,10 +34608,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'task_watchdog'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'task_watchdog'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_active_productivity_review_uq": { "name": "issues_active_productivity_review_uq", @@ -32823,10 +34636,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_active_stranded_issue_recovery_uq": { "name": "issues_active_stranded_issue_recovery_uq", @@ -32851,10 +34664,10 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false }, "issues_onboarding_first_task_uq": { "name": "issues_onboarding_first_task_uq", @@ -32867,170 +34680,200 @@ } ], "isUnique": true, - "where": "\"issues\".\"origin_kind\" = 'onboarding_first_task'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"issues\".\"origin_kind\" = 'onboarding_first_task'", + "concurrently": false } }, "foreignKeys": { "issues_company_id_companies_id_fk": { "name": "issues_company_id_companies_id_fk", "tableFrom": "issues", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_conversation_agent_id_agents_id_fk": { "name": "issues_conversation_agent_id_agents_id_fk", "tableFrom": "issues", - "tableTo": "agents", "columnsFrom": [ "conversation_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_project_id_projects_id_fk": { "name": "issues_project_id_projects_id_fk", "tableFrom": "issues", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_project_workspace_id_project_workspaces_id_fk": { "name": "issues_project_workspace_id_project_workspaces_id_fk", "tableFrom": "issues", - "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], + "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issues_goal_id_goals_id_fk": { "name": "issues_goal_id_goals_id_fk", "tableFrom": "issues", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_parent_id_issues_id_fk": { "name": "issues_parent_id_issues_id_fk", "tableFrom": "issues", - "tableTo": "issues", "columnsFrom": [ "parent_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" + }, + "issues_privacy_root_issue_id_issues_id_fk": { + "name": "issues_privacy_root_issue_id_issues_id_fk", + "tableFrom": "issues", + "columnsFrom": [ + "privacy_root_issue_id" + ], + "tableTo": "issues", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "issues_privacy_parent_issue_id_issues_id_fk": { + "name": "issues_privacy_parent_issue_id_issues_id_fk", + "tableFrom": "issues", + "columnsFrom": [ + "privacy_parent_issue_id" + ], + "tableTo": "issues", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" }, "issues_assignee_agent_id_agents_id_fk": { "name": "issues_assignee_agent_id_agents_id_fk", "tableFrom": "issues", - "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_checkout_run_id_heartbeat_runs_id_fk": { "name": "issues_checkout_run_id_heartbeat_runs_id_fk", "tableFrom": "issues", - "tableTo": "heartbeat_runs", "columnsFrom": [ "checkout_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issues_execution_run_id_heartbeat_runs_id_fk": { "name": "issues_execution_run_id_heartbeat_runs_id_fk", "tableFrom": "issues", - "tableTo": "heartbeat_runs", "columnsFrom": [ "execution_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "issues_created_by_agent_id_agents_id_fk": { "name": "issues_created_by_agent_id_agents_id_fk", "tableFrom": "issues", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "issues_execution_workspace_id_execution_workspaces_id_fk": { "name": "issues_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "issues", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "issues_company_id_uq": { "name": "issues_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, "checkConstraints": { + "issues_visibility_check": { + "name": "issues_visibility_check", + "value": "\"issues\".\"visibility\" in ('open', 'private')" + }, "issues_conversation_identity_check": { "name": "issues_conversation_identity_check", "value": "(\n \"issues\".\"conversation_agent_id\" is null and \"issues\".\"conversation_user_id\" is null and \"issues\".\"conversation_state\" is null\n ) or (\n \"issues\".\"conversation_agent_id\" is not null and \"issues\".\"conversation_user_id\" is not null\n and \"issues\".\"assignee_agent_id\" = \"issues\".\"conversation_agent_id\" and \"issues\".\"assignee_agent_id\" is not null\n and \"issues\".\"assignee_user_id\" is null and \"issues\".\"conversation_state\" is not null\n and \"issues\".\"conversation_state\" in ('active', 'waiting')\n and \"issues\".\"status\" not in ('done', 'cancelled')\n )" @@ -33191,9 +35034,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "join_requests_company_status_type_created_idx": { "name": "join_requests_company_status_type_created_idx", @@ -33224,9 +35067,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "join_requests_pending_human_user_uq": { "name": "join_requests_pending_human_user_uq", @@ -33245,10 +35088,10 @@ } ], "isUnique": true, - "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false }, "join_requests_pending_human_email_uq": { "name": "join_requests_pending_human_email_uq", @@ -33261,57 +35104,57 @@ }, { "expression": "lower(\"request_email_snapshot\")", - "asc": true, "isExpression": true, + "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false } }, "foreignKeys": { "join_requests_invite_id_invites_id_fk": { "name": "join_requests_invite_id_invites_id_fk", "tableFrom": "join_requests", - "tableTo": "invites", "columnsFrom": [ "invite_id" ], + "tableTo": "invites", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "join_requests_company_id_companies_id_fk": { "name": "join_requests_company_id_companies_id_fk", "tableFrom": "join_requests", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "join_requests_created_agent_id_agents_id_fk": { "name": "join_requests_created_agent_id_agents_id_fk", "tableFrom": "join_requests", - "tableTo": "agents", "columnsFrom": [ "created_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -33376,9 +35219,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "labels_company_name_idx": { "name": "labels_company_name_idx", @@ -33397,24 +35240,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "labels_company_id_companies_id_fk": { "name": "labels_company_id_companies_id_fk", "tableFrom": "labels", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -33564,9 +35407,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "managed_agent_profiles_company_key_uq": { "name": "managed_agent_profiles_company_key_uq", @@ -33585,9 +35428,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "managed_agent_profiles_company_resource_uq": { "name": "managed_agent_profiles_company_resource_uq", @@ -33618,37 +35461,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "managed_agent_profiles_company_id_companies_id_fk": { "name": "managed_agent_profiles_company_id_companies_id_fk", "tableFrom": "managed_agent_profiles", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "managed_agent_profiles_api_key_secret_id_company_secrets_id_fk": { "name": "managed_agent_profiles_api_key_secret_id_company_secrets_id_fk", "tableFrom": "managed_agent_profiles", - "tableTo": "company_secrets", "columnsFrom": [ "api_key_secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" } }, "compositePrimaryKeys": {}, @@ -33834,100 +35677,99 @@ } ], "isUnique": false, - "where": "\"native_run_finalizations\".\"control_deadline_at\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"native_run_finalizations\".\"control_deadline_at\" is not null", + "concurrently": false } }, "foreignKeys": { "native_run_finalizations_company_id_companies_id_fk": { "name": "native_run_finalizations_company_id_companies_id_fk", "tableFrom": "native_run_finalizations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_finalizations_issue_company_fk": { "name": "native_run_finalizations_issue_company_fk", "tableFrom": "native_run_finalizations", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_finalizations_run_owner_fk": { "name": "native_run_finalizations_run_owner_fk", "tableFrom": "native_run_finalizations", - "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_finalizations_result_owner_fk": { "name": "native_run_finalizations_result_owner_fk", "tableFrom": "native_run_finalizations", - "tableTo": "native_run_results", "columnsFrom": [ "company_id", "issue_id", "run_id", "result_id" ], + "tableTo": "native_run_results", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_finalizations_assessment_owner_fk": { "name": "native_run_finalizations_assessment_owner_fk", "tableFrom": "native_run_finalizations", - "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "assessment_id" ], + "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_finalizations_decision_owner_fk": { "name": "native_run_finalizations_decision_owner_fk", "tableFrom": "native_run_finalizations", - "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", @@ -33935,6 +35777,7 @@ "assessment_id", "decision_id" ], + "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", @@ -33942,8 +35785,8 @@ "assessment_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -34070,9 +35913,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "native_run_results_run_caller_result_uq": { "name": "native_run_results_run_caller_result_uq", @@ -34091,9 +35934,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "native_run_results_run_caller_dedupe_uq": { "name": "native_run_results_run_caller_dedupe_uq", @@ -34112,88 +35955,88 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "native_run_results_company_id_companies_id_fk": { "name": "native_run_results_company_id_companies_id_fk", "tableFrom": "native_run_results", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_results_issue_company_fk": { "name": "native_run_results_issue_company_fk", "tableFrom": "native_run_results", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_results_run_contract_owner_fk": { "name": "native_run_results_run_contract_owner_fk", "tableFrom": "native_run_results", - "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id", "completion_contract_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id", "completion_contract_id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "native_run_results_completion_contract_owner_fk": { "name": "native_run_results_completion_contract_owner_fk", "tableFrom": "native_run_results", - "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "completion_contract_id" ], + "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "native_run_results_company_issue_run_id_uq": { "name": "native_run_results_company_issue_run_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -34311,9 +36154,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_automation_executions_company_case_idx": { "name": "pipeline_automation_executions_company_case_idx", @@ -34332,9 +36175,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_automation_executions_routine_idx": { "name": "pipeline_automation_executions_routine_idx", @@ -34347,9 +36190,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_automation_executions_execution_issue_idx": { "name": "pipeline_automation_executions_execution_issue_idx", @@ -34362,9 +36205,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_automation_executions_retry_of_execution_idx": { "name": "pipeline_automation_executions_retry_of_execution_idx", @@ -34377,63 +36220,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_automation_executions_company_id_companies_id_fk": { "name": "pipeline_automation_executions_company_id_companies_id_fk", "tableFrom": "pipeline_automation_executions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_automation_executions_case_id_pipeline_cases_id_fk": { "name": "pipeline_automation_executions_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_automation_executions", - "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_automation_executions_routine_id_routines_id_fk": { "name": "pipeline_automation_executions_routine_id_routines_id_fk", "tableFrom": "pipeline_automation_executions", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_automation_executions_execution_issue_id_issues_id_fk": { "name": "pipeline_automation_executions_execution_issue_id_issues_id_fk", "tableFrom": "pipeline_automation_executions", - "tableTo": "issues", "columnsFrom": [ "execution_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -34509,9 +36352,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_blockers_blocked_by_idx": { "name": "pipeline_case_blockers_blocked_by_idx", @@ -34524,9 +36367,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_blockers_company_case_idx": { "name": "pipeline_case_blockers_company_case_idx", @@ -34545,50 +36388,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_case_blockers_company_id_companies_id_fk": { "name": "pipeline_case_blockers_company_id_companies_id_fk", "tableFrom": "pipeline_case_blockers", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_blockers_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_blockers_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_blockers", - "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_blockers", - "tableTo": "pipeline_cases", "columnsFrom": [ "blocked_by_case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -34676,9 +36519,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_documents_document_uq": { "name": "pipeline_case_documents_document_uq", @@ -34691,9 +36534,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_documents_company_case_updated_idx": { "name": "pipeline_case_documents_company_case_updated_idx", @@ -34718,50 +36561,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_case_documents_company_id_companies_id_fk": { "name": "pipeline_case_documents_company_id_companies_id_fk", "tableFrom": "pipeline_case_documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_documents_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_documents_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_documents", - "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_documents_document_id_documents_id_fk": { "name": "pipeline_case_documents_document_id_documents_id_fk", "tableFrom": "pipeline_case_documents", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -34875,9 +36718,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_events_company_case_idx": { "name": "pipeline_case_events_company_case_idx", @@ -34896,76 +36739,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_case_events_company_id_companies_id_fk": { "name": "pipeline_case_events_company_id_companies_id_fk", "tableFrom": "pipeline_case_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_events_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_events_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_events", - "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_events_actor_agent_id_agents_id_fk": { "name": "pipeline_case_events_actor_agent_id_agents_id_fk", "tableFrom": "pipeline_case_events", - "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "pipeline_case_events_from_stage_id_pipeline_stages_id_fk": { "name": "pipeline_case_events_from_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_case_events", - "tableTo": "pipeline_stages", "columnsFrom": [ "from_stage_id" ], + "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "pipeline_case_events_to_stage_id_pipeline_stages_id_fk": { "name": "pipeline_case_events_to_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_case_events", - "tableTo": "pipeline_stages", "columnsFrom": [ "to_stage_id" ], + "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -35085,9 +36928,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_issue_links_issue_idx": { "name": "pipeline_case_issue_links_issue_idx", @@ -35100,9 +36943,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_issue_links_company_case_idx": { "name": "pipeline_case_issue_links_company_case_idx", @@ -35121,9 +36964,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_case_issue_links_automation_attempt_idx": { "name": "pipeline_case_issue_links_automation_attempt_idx", @@ -35136,50 +36979,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_case_issue_links_company_id_companies_id_fk": { "name": "pipeline_case_issue_links_company_id_companies_id_fk", "tableFrom": "pipeline_case_issue_links", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_issue_links_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_issue_links_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_issue_links", - "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_case_issue_links_issue_id_issues_id_fk": { "name": "pipeline_case_issue_links_issue_id_issues_id_fk", "tableFrom": "pipeline_case_issue_links", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -35421,9 +37264,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_parent_request_key_uq": { "name": "pipeline_cases_parent_request_key_uq", @@ -35442,10 +37285,10 @@ } ], "isUnique": true, - "where": "\"pipeline_cases\".\"request_key\" is not null and \"pipeline_cases\".\"retired_at\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"pipeline_cases\".\"request_key\" is not null and \"pipeline_cases\".\"retired_at\" is null", + "concurrently": false }, "pipeline_cases_company_idx": { "name": "pipeline_cases_company_idx", @@ -35458,9 +37301,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_pipeline_stage_idx": { "name": "pipeline_cases_pipeline_stage_idx", @@ -35479,9 +37322,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_parent_idx": { "name": "pipeline_cases_parent_idx", @@ -35494,9 +37337,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_automation_attempt_idx": { "name": "pipeline_cases_automation_attempt_idx", @@ -35509,9 +37352,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_retired_idx": { "name": "pipeline_cases_retired_idx", @@ -35530,9 +37373,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_cases_lease_expires_idx": { "name": "pipeline_cases_lease_expires_idx", @@ -35545,90 +37388,90 @@ } ], "isUnique": false, - "where": "\"pipeline_cases\".\"lease_expires_at\" is not null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"pipeline_cases\".\"lease_expires_at\" is not null", + "concurrently": false } }, "foreignKeys": { "pipeline_cases_company_id_companies_id_fk": { "name": "pipeline_cases_company_id_companies_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_cases_pipeline_id_pipelines_id_fk": { "name": "pipeline_cases_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], + "tableTo": "pipelines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_cases_stage_id_pipeline_stages_id_fk": { "name": "pipeline_cases_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "pipeline_stages", "columnsFrom": [ "stage_id" ], + "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "pipeline_cases_parent_case_id_pipeline_cases_id_fk": { "name": "pipeline_cases_parent_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "pipeline_cases", "columnsFrom": [ "parent_case_id" ], + "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "pipeline_cases_lease_agent_id_agents_id_fk": { "name": "pipeline_cases_lease_agent_id_agents_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "agents", "columnsFrom": [ "lease_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "pipeline_cases_created_by_agent_id_agents_id_fk": { "name": "pipeline_cases_created_by_agent_id_agents_id_fk", "tableFrom": "pipeline_cases", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -35720,9 +37563,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_documents_document_uq": { "name": "pipeline_documents_document_uq", @@ -35735,9 +37578,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_documents_company_pipeline_updated_idx": { "name": "pipeline_documents_company_pipeline_updated_idx", @@ -35762,50 +37605,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_documents_company_id_companies_id_fk": { "name": "pipeline_documents_company_id_companies_id_fk", "tableFrom": "pipeline_documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_documents_pipeline_id_pipelines_id_fk": { "name": "pipeline_documents_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_documents", - "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], + "tableTo": "pipelines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_documents_document_id_documents_id_fk": { "name": "pipeline_documents_document_id_documents_id_fk", "tableFrom": "pipeline_documents", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -35895,9 +37738,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_stages_pipeline_position_idx": { "name": "pipeline_stages_pipeline_position_idx", @@ -35916,24 +37759,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_stages_pipeline_id_pipelines_id_fk": { "name": "pipeline_stages_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_stages", - "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], + "tableTo": "pipelines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -36021,9 +37864,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_transitions_pipeline_from_idx": { "name": "pipeline_transitions_pipeline_from_idx", @@ -36042,9 +37885,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipeline_transitions_pipeline_to_idx": { "name": "pipeline_transitions_pipeline_to_idx", @@ -36063,50 +37906,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipeline_transitions_pipeline_id_pipelines_id_fk": { "name": "pipeline_transitions_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_transitions", - "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], + "tableTo": "pipelines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_transitions_from_stage_id_pipeline_stages_id_fk": { "name": "pipeline_transitions_from_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_transitions", - "tableTo": "pipeline_stages", "columnsFrom": [ "from_stage_id" ], + "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipeline_transitions_to_stage_id_pipeline_stages_id_fk": { "name": "pipeline_transitions_to_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_transitions", - "tableTo": "pipeline_stages", "columnsFrom": [ "to_stage_id" ], + "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -36214,9 +38057,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipelines_company_idx": { "name": "pipelines_company_idx", @@ -36229,9 +38072,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "pipelines_company_project_idx": { "name": "pipelines_company_project_idx", @@ -36250,50 +38093,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "pipelines_company_id_companies_id_fk": { "name": "pipelines_company_id_companies_id_fk", "tableFrom": "pipelines", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "pipelines_project_id_projects_id_fk": { "name": "pipelines_project_id_projects_id_fk", "tableFrom": "pipelines", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "pipelines_created_by_agent_id_agents_id_fk": { "name": "pipelines_created_by_agent_id_agents_id_fk", "tableFrom": "pipelines", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -36372,9 +38215,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_company_settings_plugin_idx": { "name": "plugin_company_settings_plugin_idx", @@ -36387,9 +38230,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_company_settings_company_plugin_uq": { "name": "plugin_company_settings_company_plugin_uq", @@ -36408,37 +38251,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_company_settings_company_id_companies_id_fk": { "name": "plugin_company_settings_company_id_companies_id_fk", "tableFrom": "plugin_company_settings", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_company_settings_plugin_id_plugins_id_fk": { "name": "plugin_company_settings_plugin_id_plugins_id_fk", "tableFrom": "plugin_company_settings", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -36516,37 +38359,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_config_plugin_id_plugins_id_fk": { "name": "plugin_config_plugin_id_plugins_id_fk", "tableFrom": "plugin_config", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_config_company_id_companies_id_fk": { "name": "plugin_config_company_id_companies_id_fk", "tableFrom": "plugin_config", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -36625,9 +38468,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_database_namespaces_namespace_idx": { "name": "plugin_database_namespaces_namespace_idx", @@ -36640,9 +38483,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_database_namespaces_status_idx": { "name": "plugin_database_namespaces_status_idx", @@ -36655,24 +38498,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_database_namespaces_plugin_id_plugins_id_fk": { "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", "tableFrom": "plugin_database_namespaces", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -36774,9 +38617,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_entities_company_idx": { "name": "plugin_entities_company_idx", @@ -36789,9 +38632,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_entities_type_idx": { "name": "plugin_entities_type_idx", @@ -36804,9 +38647,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_entities_scope_idx": { "name": "plugin_entities_scope_idx", @@ -36825,50 +38668,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_entities_plugin_id_plugins_id_fk": { "name": "plugin_entities_plugin_id_plugins_id_fk", "tableFrom": "plugin_entities", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_entities_company_id_companies_id_fk": { "name": "plugin_entities_company_id_companies_id_fk", "tableFrom": "plugin_entities", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "plugin_entities_external_idx": { "name": "plugin_entities_external_idx", - "nullsNotDistinct": true, "columns": [ "company_id", "plugin_id", "entity_type", "external_id" - ] + ], + "nullsNotDistinct": true } }, "policies": {}, @@ -36968,9 +38811,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_job_runs_plugin_idx": { "name": "plugin_job_runs_plugin_idx", @@ -36983,9 +38826,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_job_runs_company_idx": { "name": "plugin_job_runs_company_idx", @@ -36998,9 +38841,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_job_runs_status_idx": { "name": "plugin_job_runs_status_idx", @@ -37013,50 +38856,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_job_runs_job_id_plugin_jobs_id_fk": { "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", "tableFrom": "plugin_job_runs", - "tableTo": "plugin_jobs", "columnsFrom": [ "job_id" ], + "tableTo": "plugin_jobs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_job_runs_plugin_id_plugins_id_fk": { "name": "plugin_job_runs_plugin_id_plugins_id_fk", "tableFrom": "plugin_job_runs", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_job_runs_company_id_companies_id_fk": { "name": "plugin_job_runs_company_id_companies_id_fk", "tableFrom": "plugin_job_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -37140,9 +38983,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_jobs_next_run_idx": { "name": "plugin_jobs_next_run_idx", @@ -37155,9 +38998,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_jobs_unique_idx": { "name": "plugin_jobs_unique_idx", @@ -37176,24 +39019,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_jobs_plugin_id_plugins_id_fk": { "name": "plugin_jobs_plugin_id_plugins_id_fk", "tableFrom": "plugin_jobs", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -37238,37 +39081,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk": { "name": "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk", "tableFrom": "plugin_lifecycle_acknowledgments", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk": { "name": "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk", "tableFrom": "plugin_lifecycle_acknowledgments", - "tableTo": "resource_lifecycle_events", "columnsFrom": [ "event_id" ], + "tableTo": "resource_lifecycle_events", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": { @@ -37353,9 +39196,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_logs_company_idx": { "name": "plugin_logs_company_idx", @@ -37368,9 +39211,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_logs_level_idx": { "name": "plugin_logs_level_idx", @@ -37383,37 +39226,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_logs_plugin_id_plugins_id_fk": { "name": "plugin_logs_plugin_id_plugins_id_fk", "tableFrom": "plugin_logs", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_logs_company_id_companies_id_fk": { "name": "plugin_logs_company_id_companies_id_fk", "tableFrom": "plugin_logs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -37503,9 +39346,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_managed_resources_plugin_idx": { "name": "plugin_managed_resources_plugin_idx", @@ -37518,9 +39361,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_managed_resources_resource_idx": { "name": "plugin_managed_resources_resource_idx", @@ -37539,9 +39382,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_managed_resources_company_plugin_resource_uq": { "name": "plugin_managed_resources_company_plugin_resource_uq", @@ -37572,37 +39415,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_managed_resources_company_id_companies_id_fk": { "name": "plugin_managed_resources_company_id_companies_id_fk", "tableFrom": "plugin_managed_resources", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_managed_resources_plugin_id_plugins_id_fk": { "name": "plugin_managed_resources_plugin_id_plugins_id_fk", "tableFrom": "plugin_managed_resources", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -37702,9 +39545,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_migrations_plugin_idx": { "name": "plugin_migrations_plugin_idx", @@ -37717,9 +39560,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_migrations_status_idx": { "name": "plugin_migrations_status_idx", @@ -37732,24 +39575,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_migrations_plugin_id_plugins_id_fk": { "name": "plugin_migrations_plugin_id_plugins_id_fk", "tableFrom": "plugin_migrations", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -37832,38 +39675,38 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_state_plugin_id_plugins_id_fk": { "name": "plugin_state_plugin_id_plugins_id_fk", "tableFrom": "plugin_state", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "plugin_state_unique_entry_idx": { "name": "plugin_state_unique_entry_idx", - "nullsNotDistinct": true, "columns": [ "plugin_id", "scope_kind", "scope_id", "namespace", "state_key" - ] + ], + "nullsNotDistinct": true } }, "policies": {}, @@ -37969,9 +39812,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_webhook_deliveries_company_idx": { "name": "plugin_webhook_deliveries_company_idx", @@ -37984,9 +39827,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_webhook_deliveries_status_idx": { "name": "plugin_webhook_deliveries_status_idx", @@ -37999,9 +39842,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugin_webhook_deliveries_key_idx": { "name": "plugin_webhook_deliveries_key_idx", @@ -38014,37 +39857,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", "tableFrom": "plugin_webhook_deliveries", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "plugin_webhook_deliveries_company_id_companies_id_fk": { "name": "plugin_webhook_deliveries_company_id_companies_id_fk", "tableFrom": "plugin_webhook_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -38154,9 +39997,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "plugins_status_idx": { "name": "plugins_status_idx", @@ -38169,9 +40012,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -38273,9 +40116,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "principal_permission_grants_company_permission_idx": { "name": "principal_permission_grants_company_permission_idx", @@ -38294,24 +40137,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "principal_permission_grants_company_id_companies_id_fk": { "name": "principal_permission_grants_company_id_companies_id_fk", "tableFrom": "principal_permission_grants", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -38320,6 +40163,150 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.project_access_members": { + "name": "project_access_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_access_members_project_subject_uq": { + "name": "project_access_members_project_subject_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "project_access_members_subject_lookup_idx": { + "name": "project_access_members_subject_lookup_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "project_access_members_company_id_companies_id_fk": { + "name": "project_access_members_company_id_companies_id_fk", + "tableFrom": "project_access_members", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_access_members_project_id_projects_id_fk": { + "name": "project_access_members_project_id_projects_id_fk", + "tableFrom": "project_access_members", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_members_subject_type_check": { + "name": "project_access_members_subject_type_check", + "value": "\"project_access_members\".\"subject_type\" in ('user', 'agent')" + } + }, + "isRLSEnabled": false + }, "public.project_goals": { "name": "project_goals", "schema": "", @@ -38369,9 +40356,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_goals_goal_idx": { "name": "project_goals_goal_idx", @@ -38384,9 +40371,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_goals_company_idx": { "name": "project_goals_company_idx", @@ -38399,50 +40386,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "project_goals_project_id_projects_id_fk": { "name": "project_goals_project_id_projects_id_fk", "tableFrom": "project_goals", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "project_goals_goal_id_goals_id_fk": { "name": "project_goals_goal_id_goals_id_fk", "tableFrom": "project_goals", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "project_goals_company_id_companies_id_fk": { "name": "project_goals_company_id_companies_id_fk", "tableFrom": "project_goals", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": { @@ -38534,9 +40521,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_memberships_company_user_starred_idx": { "name": "project_memberships_company_user_starred_idx", @@ -38561,9 +40548,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_memberships_project_idx": { "name": "project_memberships_project_idx", @@ -38576,9 +40563,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_memberships_company_user_project_uq": { "name": "project_memberships_company_user_project_uq", @@ -38603,37 +40590,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "project_memberships_company_id_companies_id_fk": { "name": "project_memberships_company_id_companies_id_fk", "tableFrom": "project_memberships", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "project_memberships_project_id_projects_id_fk": { "name": "project_memberships_project_id_projects_id_fk", "tableFrom": "project_memberships", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -38785,9 +40772,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_workspaces_project_primary_idx": { "name": "project_workspaces_project_primary_idx", @@ -38806,9 +40793,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_workspaces_project_source_type_idx": { "name": "project_workspaces_project_source_type_idx", @@ -38827,9 +40814,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_workspaces_company_shared_key_idx": { "name": "project_workspaces_company_shared_key_idx", @@ -38848,9 +40835,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "project_workspaces_project_remote_ref_idx": { "name": "project_workspaces_project_remote_ref_idx", @@ -38875,37 +40862,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "project_workspaces_company_id_companies_id_fk": { "name": "project_workspaces_company_id_companies_id_fk", "tableFrom": "project_workspaces", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "project_workspaces_project_id_projects_id_fk": { "name": "project_workspaces_project_id_projects_id_fk", "tableFrom": "project_workspaces", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -38949,6 +40936,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_owner_user_id": { + "name": "privacy_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_owner_user_id": { + "name": "personal_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -39037,56 +41043,83 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false + }, + "projects_company_personal_owner_uq": { + "name": "projects_company_personal_owner_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "personal_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"projects\".\"personal_owner_user_id\" is not null", + "concurrently": false } }, "foreignKeys": { "projects_company_id_companies_id_fk": { "name": "projects_company_id_companies_id_fk", "tableFrom": "projects", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "projects_goal_id_goals_id_fk": { "name": "projects_goal_id_goals_id_fk", "tableFrom": "projects", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "projects_lead_agent_id_agents_id_fk": { "name": "projects_lead_agent_id_agents_id_fk", "tableFrom": "projects", - "tableTo": "agents", "columnsFrom": [ "lead_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "projects_visibility_check": { + "name": "projects_visibility_check", + "value": "\"projects\".\"visibility\" in ('open', 'private')" + } + }, "isRLSEnabled": false }, "public.provider_trace_records": { @@ -39202,9 +41235,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "provider_trace_records_expiry_idx": { "name": "provider_trace_records_expiry_idx", @@ -39223,9 +41256,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "provider_trace_records_company_created_idx": { "name": "provider_trace_records_company_created_idx", @@ -39244,37 +41277,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "provider_trace_records_company_id_companies_id_fk": { "name": "provider_trace_records_company_id_companies_id_fk", "tableFrom": "provider_trace_records", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "provider_trace_records_run_id_heartbeat_runs_id_fk": { "name": "provider_trace_records_run_id_heartbeat_runs_id_fk", "tableFrom": "provider_trace_records", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -39385,9 +41418,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "remote_agent_profiles_company_key_uq": { "name": "remote_agent_profiles_company_key_uq", @@ -39406,24 +41439,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "remote_agent_profiles_company_id_companies_id_fk": { "name": "remote_agent_profiles_company_id_companies_id_fk", "tableFrom": "remote_agent_profiles", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -39451,15 +41484,15 @@ "primaryKey": true, "notNull": true, "identity": { - "type": "always", "name": "resource_lifecycle_events_id_seq", - "schema": "public", "increment": "1", - "startWith": "1", "minValue": "1", "maxValue": "9223372036854775807", + "startWith": "1", "cache": "1", - "cycle": false + "cycle": false, + "schema": "public", + "type": "always" } }, "company_id": { @@ -39518,10 +41551,10 @@ } ], "isUnique": true, - "where": "\"resource_lifecycle_events\".\"action\" = 'create'", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"resource_lifecycle_events\".\"action\" = 'create'", + "concurrently": false }, "resource_lifecycle_events_company_idx": { "name": "resource_lifecycle_events_company_idx", @@ -39540,9 +41573,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "resource_lifecycle_events_resource_order_idx": { "name": "resource_lifecycle_events_resource_order_idx", @@ -39573,24 +41606,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "resource_lifecycle_events_company_id_companies_id_fk": { "name": "resource_lifecycle_events_company_id_companies_id_fk", "tableFrom": "resource_lifecycle_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -39682,9 +41715,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_documents_document_uq": { "name": "routine_documents_document_uq", @@ -39697,9 +41730,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_documents_company_routine_updated_idx": { "name": "routine_documents_company_routine_updated_idx", @@ -39724,50 +41757,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routine_documents_company_id_companies_id_fk": { "name": "routine_documents_company_id_companies_id_fk", "tableFrom": "routine_documents", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "routine_documents_routine_id_routines_id_fk": { "name": "routine_documents_routine_id_routines_id_fk", "tableFrom": "routine_documents", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_documents_document_id_documents_id_fk": { "name": "routine_documents_document_id_documents_id_fk", "tableFrom": "routine_documents", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -39885,9 +41918,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_revisions_company_routine_created_idx": { "name": "routine_revisions_company_routine_created_idx", @@ -39912,9 +41945,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_revisions_company_responsible_user_idx": { "name": "routine_revisions_company_responsible_user_idx", @@ -39939,76 +41972,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routine_revisions_company_id_companies_id_fk": { "name": "routine_revisions_company_id_companies_id_fk", "tableFrom": "routine_revisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_revisions_routine_id_routines_id_fk": { "name": "routine_revisions_routine_id_routines_id_fk", "tableFrom": "routine_revisions", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": { "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk", "tableFrom": "routine_revisions", - "tableTo": "routine_revisions", "columnsFrom": [ "restored_from_revision_id" ], + "tableTo": "routine_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_revisions_created_by_agent_id_agents_id_fk": { "name": "routine_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "routine_revisions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "routine_revisions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -40159,9 +42192,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_revision_idx": { "name": "routine_runs_revision_idx", @@ -40174,9 +42207,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_company_responsible_user_idx": { "name": "routine_runs_company_responsible_user_idx", @@ -40201,9 +42234,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_trigger_idx": { "name": "routine_runs_trigger_idx", @@ -40222,9 +42255,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_dispatch_fingerprint_idx": { "name": "routine_runs_dispatch_fingerprint_idx", @@ -40243,9 +42276,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_linked_issue_idx": { "name": "routine_runs_linked_issue_idx", @@ -40258,9 +42291,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_runs_trigger_idempotency_idx": { "name": "routine_runs_trigger_idempotency_idx", @@ -40279,76 +42312,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routine_runs_company_id_companies_id_fk": { "name": "routine_runs_company_id_companies_id_fk", "tableFrom": "routine_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_runs_routine_id_routines_id_fk": { "name": "routine_runs_routine_id_routines_id_fk", "tableFrom": "routine_runs", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_runs_trigger_id_routine_triggers_id_fk": { "name": "routine_runs_trigger_id_routine_triggers_id_fk", "tableFrom": "routine_runs", - "tableTo": "routine_triggers", "columnsFrom": [ "trigger_id" ], + "tableTo": "routine_triggers", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_runs_routine_revision_id_routine_revisions_id_fk": { "name": "routine_runs_routine_revision_id_routine_revisions_id_fk", "tableFrom": "routine_runs", - "tableTo": "routine_revisions", "columnsFrom": [ "routine_revision_id" ], + "tableTo": "routine_revisions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_runs_linked_issue_id_issues_id_fk": { "name": "routine_runs_linked_issue_id_issues_id_fk", "tableFrom": "routine_runs", - "tableTo": "issues", "columnsFrom": [ "linked_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -40536,9 +42569,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_triggers_company_kind_idx": { "name": "routine_triggers_company_kind_idx", @@ -40557,9 +42590,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_triggers_next_run_idx": { "name": "routine_triggers_next_run_idx", @@ -40572,9 +42605,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_triggers_public_id_idx": { "name": "routine_triggers_public_id_idx", @@ -40587,9 +42620,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routine_triggers_public_id_uq": { "name": "routine_triggers_public_id_uq", @@ -40602,76 +42635,76 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routine_triggers_company_id_companies_id_fk": { "name": "routine_triggers_company_id_companies_id_fk", "tableFrom": "routine_triggers", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_triggers_routine_id_routines_id_fk": { "name": "routine_triggers_routine_id_routines_id_fk", "tableFrom": "routine_triggers", - "tableTo": "routines", "columnsFrom": [ "routine_id" ], + "tableTo": "routines", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_triggers_secret_id_company_secrets_id_fk": { "name": "routine_triggers_secret_id_company_secrets_id_fk", "tableFrom": "routine_triggers", - "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_triggers_created_by_agent_id_agents_id_fk": { "name": "routine_triggers_created_by_agent_id_agents_id_fk", "tableFrom": "routine_triggers", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routine_triggers_updated_by_agent_id_agents_id_fk": { "name": "routine_triggers_updated_by_agent_id_agents_id_fk", "tableFrom": "routine_triggers", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -40735,37 +42768,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routine_webhook_test_receipts_company_id_companies_id_fk": { "name": "routine_webhook_test_receipts_company_id_companies_id_fk", "tableFrom": "routine_webhook_test_receipts", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": { "name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk", "tableFrom": "routine_webhook_test_receipts", - "tableTo": "routine_triggers", "columnsFrom": [ "trigger_id" ], + "tableTo": "routine_triggers", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -40989,9 +43022,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routines_company_assignee_idx": { "name": "routines_company_assignee_idx", @@ -41010,9 +43043,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routines_company_project_idx": { "name": "routines_company_project_idx", @@ -41031,9 +43064,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routines_company_folder_idx": { "name": "routines_company_folder_idx", @@ -41052,9 +43085,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routines_company_responsible_user_idx": { "name": "routines_company_responsible_user_idx", @@ -41073,9 +43106,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "routines_company_origin_idx": { "name": "routines_company_origin_idx", @@ -41100,115 +43133,115 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "routines_company_id_companies_id_fk": { "name": "routines_company_id_companies_id_fk", "tableFrom": "routines", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routines_project_id_projects_id_fk": { "name": "routines_project_id_projects_id_fk", "tableFrom": "routines", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "routines_folder_id_folders_id_fk": { "name": "routines_folder_id_folders_id_fk", "tableFrom": "routines", - "tableTo": "folders", "columnsFrom": [ "folder_id" ], + "tableTo": "folders", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routines_goal_id_goals_id_fk": { "name": "routines_goal_id_goals_id_fk", "tableFrom": "routines", - "tableTo": "goals", "columnsFrom": [ "goal_id" ], + "tableTo": "goals", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routines_parent_issue_id_issues_id_fk": { "name": "routines_parent_issue_id_issues_id_fk", "tableFrom": "routines", - "tableTo": "issues", "columnsFrom": [ "parent_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routines_assignee_agent_id_agents_id_fk": { "name": "routines_assignee_agent_id_agents_id_fk", "tableFrom": "routines", - "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "routines_created_by_agent_id_agents_id_fk": { "name": "routines_created_by_agent_id_agents_id_fk", "tableFrom": "routines", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "routines_updated_by_agent_id_agents_id_fk": { "name": "routines_updated_by_agent_id_agents_id_fk", "tableFrom": "routines", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -41321,9 +43354,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "run_identity_contexts_run_correlation_idx": { "name": "run_identity_contexts_run_correlation_idx", @@ -41342,9 +43375,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "run_identity_contexts_company_run_idx": { "name": "run_identity_contexts_company_run_idx", @@ -41363,24 +43396,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "run_identity_contexts_company_id_companies_id_fk": { "name": "run_identity_contexts_company_id_companies_id_fk", "tableFrom": "run_identity_contexts", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -41444,50 +43477,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "runner_api_response_reservations_company_id_companies_id_fk": { "name": "runner_api_response_reservations_company_id_companies_id_fk", "tableFrom": "runner_api_response_reservations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "runner_api_response_reservations_run_id_heartbeat_runs_id_fk": { "name": "runner_api_response_reservations_run_id_heartbeat_runs_id_fk", "tableFrom": "runner_api_response_reservations", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "runner_api_response_reservations_asset_id_assets_id_fk": { "name": "runner_api_response_reservations_asset_id_assets_id_fk", "tableFrom": "runner_api_response_reservations", - "tableTo": "assets", "columnsFrom": [ "asset_id" ], + "tableTo": "assets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -41654,9 +43687,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "secret_access_events_secret_created_idx": { "name": "secret_access_events_secret_created_idx", @@ -41675,9 +43708,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "secret_access_events_user_definition_created_idx": { "name": "secret_access_events_user_definition_created_idx", @@ -41696,9 +43729,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "secret_access_events_company_credential_owner_idx": { "name": "secret_access_events_company_credential_owner_idx", @@ -41723,9 +43756,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "secret_access_events_consumer_idx": { "name": "secret_access_events_consumer_idx", @@ -41750,9 +43783,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "secret_access_events_run_idx": { "name": "secret_access_events_run_idx", @@ -41765,89 +43798,89 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "secret_access_events_company_id_companies_id_fk": { "name": "secret_access_events_company_id_companies_id_fk", "tableFrom": "secret_access_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "secret_access_events_secret_id_company_secrets_id_fk": { "name": "secret_access_events_secret_id_company_secrets_id_fk", "tableFrom": "secret_access_events", - "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], + "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "secret_access_events", - "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], + "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "secret_access_events_issue_id_issues_id_fk": { "name": "secret_access_events_issue_id_issues_id_fk", "tableFrom": "secret_access_events", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "secret_access_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "secret_access_events_plugin_id_plugins_id_fk": { "name": "secret_access_events_plugin_id_plugins_id_fk", "tableFrom": "secret_access_events", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -41948,9 +43981,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "smoke_run_steps_company_path_idx": { "name": "smoke_run_steps_company_path_idx", @@ -41969,37 +44002,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "smoke_run_steps_company_id_companies_id_fk": { "name": "smoke_run_steps_company_id_companies_id_fk", "tableFrom": "smoke_run_steps", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "smoke_run_steps_run_id_smoke_runs_id_fk": { "name": "smoke_run_steps_run_id_smoke_runs_id_fk", "tableFrom": "smoke_run_steps", - "tableTo": "smoke_runs", "columnsFrom": [ "run_id" ], + "tableTo": "smoke_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -42091,9 +44124,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "smoke_runs_company_status_idx": { "name": "smoke_runs_company_status_idx", @@ -42112,24 +44145,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "smoke_runs_company_id_companies_id_fk": { "name": "smoke_runs_company_id_companies_id_fk", "tableFrom": "smoke_runs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -42202,7 +44235,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -42269,9 +44302,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "status_card_updates_generation_issue_idx": { "name": "status_card_updates_generation_issue_idx", @@ -42284,50 +44317,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "status_card_updates_card_id_status_cards_id_fk": { "name": "status_card_updates_card_id_status_cards_id_fk", "tableFrom": "status_card_updates", - "tableTo": "status_cards", "columnsFrom": [ "card_id" ], + "tableTo": "status_cards", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "status_card_updates_generation_issue_id_issues_id_fk": { "name": "status_card_updates_generation_issue_id_issues_id_fk", "tableFrom": "status_card_updates", - "tableTo": "issues", "columnsFrom": [ "generation_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_card_updates_run_id_heartbeat_runs_id_fk": { "name": "status_card_updates_run_id_heartbeat_runs_id_fk", "tableFrom": "status_card_updates", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -42560,9 +44593,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "status_cards_company_next_eval_idx": { "name": "status_cards_company_next_eval_idx", @@ -42581,102 +44614,102 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "status_cards_company_id_companies_id_fk": { "name": "status_cards_company_id_companies_id_fk", "tableFrom": "status_cards", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "status_cards_created_by_agent_id_agents_id_fk": { "name": "status_cards_created_by_agent_id_agents_id_fk", "tableFrom": "status_cards", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_cards_query_compiled_by_agent_id_agents_id_fk": { "name": "status_cards_query_compiled_by_agent_id_agents_id_fk", "tableFrom": "status_cards", - "tableTo": "agents", "columnsFrom": [ "query_compiled_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_cards_agent_id_agents_id_fk": { "name": "status_cards_agent_id_agents_id_fk", "tableFrom": "status_cards", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_cards_document_id_documents_id_fk": { "name": "status_cards_document_id_documents_id_fk", "tableFrom": "status_cards", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_cards_generating_issue_id_issues_id_fk": { "name": "status_cards_generating_issue_id_issues_id_fk", "tableFrom": "status_cards", - "tableTo": "issues", "columnsFrom": [ "generating_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "status_cards_archived_by_agent_id_agents_id_fk": { "name": "status_cards_archived_by_agent_id_agents_id_fk", "tableFrom": "status_cards", - "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -42815,9 +44848,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "status_decision_effects_company_idempotency_uq": { "name": "status_decision_effects_company_idempotency_uq", @@ -42836,56 +44869,56 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "status_decision_effects_company_id_companies_id_fk": { "name": "status_decision_effects_company_id_companies_id_fk", "tableFrom": "status_decision_effects", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "status_decision_effects_issue_company_fk": { "name": "status_decision_effects_issue_company_fk", "tableFrom": "status_decision_effects", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "status_decision_effects_decision_owner_fk": { "name": "status_decision_effects_decision_owner_fk", "tableFrom": "status_decision_effects", - "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", "decision_id" ], + "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, @@ -43022,9 +45055,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "status_decisions_company_assessment_uq": { "name": "status_decisions_company_assessment_uq", @@ -43043,9 +45076,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "status_decisions_company_issue_digest_uq": { "name": "status_decisions_company_issue_digest_uq", @@ -43070,98 +45103,98 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "status_decisions_company_id_companies_id_fk": { "name": "status_decisions_company_id_companies_id_fk", "tableFrom": "status_decisions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "status_decisions_issue_company_fk": { "name": "status_decisions_issue_company_fk", "tableFrom": "status_decisions", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "status_decisions_assessment_owner_fk": { "name": "status_decisions_assessment_owner_fk", "tableFrom": "status_decisions", - "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "assessment_id" ], + "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "status_decisions_supersedes_owner_fk": { "name": "status_decisions_supersedes_owner_fk", "tableFrom": "status_decisions", - "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", "supersedes_decision_id" ], + "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "status_decisions_company_issue_id_uq": { "name": "status_decisions_company_issue_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "id" - ] + ], + "nullsNotDistinct": false }, "status_decisions_company_issue_run_assessment_id_uq": { "name": "status_decisions_company_issue_run_assessment_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "assessment_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -43273,9 +45306,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "summary_slots_company_scope_idx": { "name": "summary_slots_company_scope_idx", @@ -43300,9 +45333,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "summary_slots_company_generating_issue_idx": { "name": "summary_slots_company_generating_issue_idx", @@ -43321,9 +45354,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "summary_slots_company_updated_idx": { "name": "summary_slots_company_updated_idx", @@ -43342,76 +45375,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "summary_slots_company_id_companies_id_fk": { "name": "summary_slots_company_id_companies_id_fk", "tableFrom": "summary_slots", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "summary_slots_document_id_documents_id_fk": { "name": "summary_slots_document_id_documents_id_fk", "tableFrom": "summary_slots", - "tableTo": "documents", "columnsFrom": [ "document_id" ], + "tableTo": "documents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "summary_slots_generating_issue_id_issues_id_fk": { "name": "summary_slots_generating_issue_id_issues_id_fk", "tableFrom": "summary_slots", - "tableTo": "issues", "columnsFrom": [ "generating_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "summary_slots_last_generated_by_agent_id_agents_id_fk": { "name": "summary_slots_last_generated_by_agent_id_agents_id_fk", "tableFrom": "summary_slots", - "tableTo": "agents", "columnsFrom": [ "last_generated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "summary_slots_company_scope_slot_uq": { "name": "summary_slots_company_scope_slot_uq", - "nullsNotDistinct": true, "columns": [ "company_id", "scope_kind", "scope_id", "slot_key" - ] + ], + "nullsNotDistinct": true } }, "policies": {}, @@ -43541,9 +45574,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_access_audit_connection_idx": { "name": "tool_access_audit_connection_idx", @@ -43556,9 +45589,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_access_audit_gateway_idx": { "name": "tool_access_audit_gateway_idx", @@ -43577,76 +45610,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_access_audit_events_company_id_companies_id_fk": { "name": "tool_access_audit_events_company_id_companies_id_fk", "tableFrom": "tool_access_audit_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_access_audit_events", - "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], + "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_access_audit_events", - "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], + "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_access_audit_events_connection_id_tool_connections_id_fk": { "name": "tool_access_audit_events_connection_id_tool_connections_id_fk", "tableFrom": "tool_access_audit_events", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_access_audit_events", - "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], + "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -43715,63 +45748,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_action_deliveries_action_request_id_tool_action_requests_id_fk": { "name": "tool_action_deliveries_action_request_id_tool_action_requests_id_fk", "tableFrom": "tool_action_deliveries", - "tableTo": "tool_action_requests", "columnsFrom": [ "action_request_id" ], + "tableTo": "tool_action_requests", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_action_deliveries_company_id_companies_id_fk": { "name": "tool_action_deliveries_company_id_companies_id_fk", "tableFrom": "tool_action_deliveries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_action_deliveries_issue_id_issues_id_fk": { "name": "tool_action_deliveries_issue_id_issues_id_fk", "tableFrom": "tool_action_deliveries", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_action_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "tool_action_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "tool_action_deliveries", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -43939,9 +45972,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_action_requests_invocation_idx": { "name": "tool_action_requests_invocation_idx", @@ -43954,9 +45987,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_action_requests_issue_idx": { "name": "tool_action_requests_issue_idx", @@ -43975,115 +46008,115 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_action_requests_company_id_companies_id_fk": { "name": "tool_action_requests_company_id_companies_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_action_requests_invocation_id_tool_invocations_id_fk": { "name": "tool_action_requests_invocation_id_tool_invocations_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "tool_invocations", "columnsFrom": [ "invocation_id" ], + "tableTo": "tool_invocations", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_action_requests_issue_id_issues_id_fk": { "name": "tool_action_requests_issue_id_issues_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_action_requests_interaction_id_issue_thread_interactions_id_fk": { "name": "tool_action_requests_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], + "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_action_requests_approval_id_approvals_id_fk": { "name": "tool_action_requests_approval_id_approvals_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "approvals", "columnsFrom": [ "approval_id" ], + "tableTo": "approvals", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_action_requests_requested_by_agent_id_agents_id_fk": { "name": "tool_action_requests_requested_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "agents", "columnsFrom": [ "requested_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_action_requests_resolved_by_agent_id_agents_id_fk": { "name": "tool_action_requests_resolved_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_action_requests_decided_by_agent_id_agents_id_fk": { "name": "tool_action_requests_decided_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", - "tableTo": "agents", "columnsFrom": [ "decided_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -44198,9 +46231,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_applications_company_status_idx": { "name": "tool_applications_company_status_idx", @@ -44219,9 +46252,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_applications_company_name_uq": { "name": "tool_applications_company_name_uq", @@ -44240,9 +46273,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_applications_company_key_uq": { "name": "tool_applications_company_key_uq", @@ -44261,50 +46294,50 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_applications_company_id_companies_id_fk": { "name": "tool_applications_company_id_companies_id_fk", "tableFrom": "tool_applications", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_applications_plugin_id_plugins_id_fk": { "name": "tool_applications_plugin_id_plugins_id_fk", "tableFrom": "tool_applications", - "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], + "tableTo": "plugins", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_applications_owner_agent_id_agents_id_fk": { "name": "tool_applications_owner_agent_id_agents_id_fk", "tableFrom": "tool_applications", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -44599,9 +46632,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_call_events_run_idx": { "name": "tool_call_events_run_idx", @@ -44620,9 +46653,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_call_events_issue_idx": { "name": "tool_call_events_issue_idx", @@ -44641,9 +46674,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_call_events_invocation_idx": { "name": "tool_call_events_invocation_idx", @@ -44656,9 +46689,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_call_events_gateway_idx": { "name": "tool_call_events_gateway_idx", @@ -44677,167 +46710,167 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_call_events_company_id_companies_id_fk": { "name": "tool_call_events_company_id_companies_id_fk", "tableFrom": "tool_call_events", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_call_events_agent_id_agents_id_fk": { "name": "tool_call_events_agent_id_agents_id_fk", "tableFrom": "tool_call_events", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_run_id_heartbeat_runs_id_fk": { "name": "tool_call_events_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_call_events", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_issue_id_issues_id_fk": { "name": "tool_call_events_issue_id_issues_id_fk", "tableFrom": "tool_call_events", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_call_events_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], + "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], + "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_application_id_tool_applications_id_fk": { "name": "tool_call_events_application_id_tool_applications_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_connection_id_tool_connections_id_fk": { "name": "tool_call_events_connection_id_tool_connections_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], + "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_invocation_id_tool_invocations_id_fk": { "name": "tool_call_events_invocation_id_tool_invocations_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_invocations", "columnsFrom": [ "invocation_id" ], + "tableTo": "tool_invocations", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_action_request_id_tool_action_requests_id_fk": { "name": "tool_call_events_action_request_id_tool_action_requests_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_action_requests", "columnsFrom": [ "action_request_id" ], + "tableTo": "tool_action_requests", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk": { "name": "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk", "tableFrom": "tool_call_events", - "tableTo": "tool_runtime_slots", "columnsFrom": [ "runtime_slot_id" ], + "tableTo": "tool_runtime_slots", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -45050,9 +47083,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_catalog_entries_application_idx": { "name": "tool_catalog_entries_application_idx", @@ -45065,9 +47098,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_catalog_entries_connection_idx": { "name": "tool_catalog_entries_connection_idx", @@ -45080,9 +47113,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_catalog_entries_company_status_idx": { "name": "tool_catalog_entries_company_status_idx", @@ -45101,9 +47134,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_catalog_entries_connection_name_uq": { "name": "tool_catalog_entries_connection_name_uq", @@ -45122,63 +47155,63 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_catalog_entries_company_id_companies_id_fk": { "name": "tool_catalog_entries_company_id_companies_id_fk", "tableFrom": "tool_catalog_entries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_catalog_entries_application_id_tool_applications_id_fk": { "name": "tool_catalog_entries_application_id_tool_applications_id_fk", "tableFrom": "tool_catalog_entries", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_catalog_entries_connection_id_tool_connections_id_fk": { "name": "tool_catalog_entries_connection_id_tool_connections_id_fk", "tableFrom": "tool_catalog_entries", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk": { "name": "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk", "tableFrom": "tool_catalog_entries", - "tableTo": "agents", "columnsFrom": [ "reviewed_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -45285,39 +47318,39 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_connection_app_snapshots_company_id_companies_id_fk": { "name": "tool_connection_app_snapshots_company_id_companies_id_fk", "tableFrom": "tool_connection_app_snapshots", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_connection_app_snapshots_company_connection_fk": { "name": "tool_connection_app_snapshots_company_connection_fk", "tableFrom": "tool_connection_app_snapshots", - "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -45438,39 +47471,39 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_connection_app_syncs_company_id_companies_id_fk": { "name": "tool_connection_app_syncs_company_id_companies_id_fk", "tableFrom": "tool_connection_app_syncs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_connection_app_syncs_company_connection_fk": { "name": "tool_connection_app_syncs_company_connection_fk", "tableFrom": "tool_connection_app_syncs", - "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -45558,9 +47591,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_connection_installs_connection_idx": { "name": "tool_connection_installs_connection_idx", @@ -45579,9 +47612,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_connection_installs_target_uq": { "name": "tool_connection_installs_target_uq", @@ -45612,50 +47645,50 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_connection_installs_company_id_companies_id_fk": { "name": "tool_connection_installs_company_id_companies_id_fk", "tableFrom": "tool_connection_installs", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_connection_installs_connection_id_tool_connections_id_fk": { "name": "tool_connection_installs_connection_id_tool_connections_id_fk", "tableFrom": "tool_connection_installs", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_connection_installs_created_by_agent_id_agents_id_fk": { "name": "tool_connection_installs_created_by_agent_id_agents_id_fk", "tableFrom": "tool_connection_installs", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -45882,9 +47915,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_connections_application_idx": { "name": "tool_connections_application_idx", @@ -45897,9 +47930,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_connections_company_enabled_idx": { "name": "tool_connections_company_enabled_idx", @@ -45918,9 +47951,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_connections_company_uid_uq": { "name": "tool_connections_company_uid_uq", @@ -45939,61 +47972,61 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_connections_company_id_companies_id_fk": { "name": "tool_connections_company_id_companies_id_fk", "tableFrom": "tool_connections", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_connections_application_id_tool_applications_id_fk": { "name": "tool_connections_application_id_tool_applications_id_fk", "tableFrom": "tool_connections", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "tool_connections_created_by_agent_id_agents_id_fk": { "name": "tool_connections_created_by_agent_id_agents_id_fk", "tableFrom": "tool_connections", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "tool_connections_company_id_uq": { "name": "tool_connections_company_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -46114,9 +48147,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_rate_limit_counters_window_uq": { "name": "tool_gateway_rate_limit_counters_window_uq", @@ -46141,24 +48174,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_gateway_rate_limit_counters_company_id_companies_id_fk": { "name": "tool_gateway_rate_limit_counters_company_id_companies_id_fk", "tableFrom": "tool_gateway_rate_limit_counters", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -46307,9 +48340,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_sessions_company_agent_idx": { "name": "tool_gateway_sessions_company_agent_idx", @@ -46328,9 +48361,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_sessions_company_expires_idx": { "name": "tool_gateway_sessions_company_expires_idx", @@ -46349,9 +48382,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_sessions_run_idx": { "name": "tool_gateway_sessions_run_idx", @@ -46370,9 +48403,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_sessions_issue_idx": { "name": "tool_gateway_sessions_issue_idx", @@ -46391,9 +48424,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_gateway_sessions_gateway_idx": { "name": "tool_gateway_sessions_gateway_idx", @@ -46412,102 +48445,102 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_gateway_sessions_company_id_companies_id_fk": { "name": "tool_gateway_sessions_company_id_companies_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_gateway_sessions_agent_id_agents_id_fk": { "name": "tool_gateway_sessions_agent_id_agents_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_gateway_sessions_run_id_heartbeat_runs_id_fk": { "name": "tool_gateway_sessions_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_gateway_sessions_issue_id_issues_id_fk": { "name": "tool_gateway_sessions_issue_id_issues_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_gateway_sessions_project_id_projects_id_fk": { "name": "tool_gateway_sessions_project_id_projects_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], + "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_gateway_sessions", - "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], + "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -46822,9 +48855,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_invocations_run_idx": { "name": "tool_invocations_run_idx", @@ -46843,9 +48876,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_invocations_issue_idx": { "name": "tool_invocations_issue_idx", @@ -46864,9 +48897,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_invocations_gateway_idx": { "name": "tool_invocations_gateway_idx", @@ -46885,9 +48918,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_invocations_company_idempotency_uq": { "name": "tool_invocations_company_idempotency_uq", @@ -46906,128 +48939,128 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_invocations_company_id_companies_id_fk": { "name": "tool_invocations_company_id_companies_id_fk", "tableFrom": "tool_invocations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_invocations_agent_id_agents_id_fk": { "name": "tool_invocations_agent_id_agents_id_fk", "tableFrom": "tool_invocations", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_issue_id_issues_id_fk": { "name": "tool_invocations_issue_id_issues_id_fk", "tableFrom": "tool_invocations", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_run_id_heartbeat_runs_id_fk": { "name": "tool_invocations_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_invocations", - "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_invocations_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_invocations", - "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], + "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_invocations", - "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], + "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_application_id_tool_applications_id_fk": { "name": "tool_invocations_application_id_tool_applications_id_fk", "tableFrom": "tool_invocations", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_connection_id_tool_connections_id_fk": { "name": "tool_invocations_connection_id_tool_connections_id_fk", "tableFrom": "tool_invocations", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_invocations", - "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], + "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -47193,9 +49226,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateway_tokens_expiry_idx": { "name": "tool_mcp_gateway_tokens_expiry_idx", @@ -47214,9 +49247,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", @@ -47235,9 +49268,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateway_tokens_subject_idx": { "name": "tool_mcp_gateway_tokens_subject_idx", @@ -47262,9 +49295,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateway_tokens_company_expires_idx": { "name": "tool_mcp_gateway_tokens_company_expires_idx", @@ -47283,63 +49316,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_mcp_gateway_tokens_company_id_companies_id_fk": { "name": "tool_mcp_gateway_tokens_company_id_companies_id_fk", "tableFrom": "tool_mcp_gateway_tokens", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_mcp_gateway_tokens", - "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], + "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateway_tokens", - "tableTo": "agents", "columnsFrom": [ "expiry_override_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateway_tokens", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -47534,9 +49567,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateways_company_status_idx": { "name": "tool_mcp_gateways_company_status_idx", @@ -47555,9 +49588,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateways_profile_idx": { "name": "tool_mcp_gateways_profile_idx", @@ -47576,9 +49609,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateways_public_id_uq": { "name": "tool_mcp_gateways_public_id_uq", @@ -47591,9 +49624,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateways_company_slug_uq": { "name": "tool_mcp_gateways_company_slug_uq", @@ -47612,9 +49645,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_mcp_gateways_company_name_uq": { "name": "tool_mcp_gateways_company_name_uq", @@ -47633,102 +49666,102 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_mcp_gateways_company_id_companies_id_fk": { "name": "tool_mcp_gateways_company_id_companies_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_mcp_gateways_profile_id_tool_profiles_id_fk": { "name": "tool_mcp_gateways_profile_id_tool_profiles_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], + "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onDelete": "restrict", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "restrict" }, "tool_mcp_gateways_agent_id_agents_id_fk": { "name": "tool_mcp_gateways_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "agents", "columnsFrom": [ "agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_mcp_gateways_project_id_projects_id_fk": { "name": "tool_mcp_gateways_project_id_projects_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_mcp_gateways_issue_id_issues_id_fk": { "name": "tool_mcp_gateways_issue_id_issues_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_mcp_gateways_approval_issue_id_issues_id_fk": { "name": "tool_mcp_gateways_approval_issue_id_issues_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "issues", "columnsFrom": [ "approval_issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_mcp_gateways_created_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateways_created_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateways", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -47845,9 +49878,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_oauth_states_connection_idx": { "name": "tool_oauth_states_connection_idx", @@ -47860,9 +49893,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_oauth_states_actor_idx": { "name": "tool_oauth_states_actor_idx", @@ -47881,9 +49914,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_oauth_states_subject_agent_idx": { "name": "tool_oauth_states_subject_agent_idx", @@ -47902,9 +49935,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_oauth_states_expires_at_idx": { "name": "tool_oauth_states_expires_at_idx", @@ -47917,50 +49950,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_oauth_states_company_id_companies_id_fk": { "name": "tool_oauth_states_company_id_companies_id_fk", "tableFrom": "tool_oauth_states", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_oauth_states_connection_id_tool_connections_id_fk": { "name": "tool_oauth_states_connection_id_tool_connections_id_fk", "tableFrom": "tool_oauth_states", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_oauth_states_subject_agent_id_agents_id_fk": { "name": "tool_oauth_states_subject_agent_id_agents_id_fk", "tableFrom": "tool_oauth_states", - "tableTo": "agents", "columnsFrom": [ "subject_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -48082,9 +50115,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_policies_company_type_idx": { "name": "tool_policies_company_type_idx", @@ -48103,9 +50136,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_policies_company_name_uq": { "name": "tool_policies_company_name_uq", @@ -48124,37 +50157,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_policies_company_id_companies_id_fk": { "name": "tool_policies_company_id_companies_id_fk", "tableFrom": "tool_policies", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_policies_created_by_agent_id_agents_id_fk": { "name": "tool_policies_created_by_agent_id_agents_id_fk", "tableFrom": "tool_policies", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -48263,9 +50296,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profile_bindings_target_profile_uq": { "name": "tool_profile_bindings_target_profile_uq", @@ -48296,50 +50329,50 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_profile_bindings_company_id_companies_id_fk": { "name": "tool_profile_bindings_company_id_companies_id_fk", "tableFrom": "tool_profile_bindings", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_bindings_profile_id_tool_profiles_id_fk": { "name": "tool_profile_bindings_profile_id_tool_profiles_id_fk", "tableFrom": "tool_profile_bindings", - "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], + "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_bindings_created_by_agent_id_agents_id_fk": { "name": "tool_profile_bindings_created_by_agent_id_agents_id_fk", "tableFrom": "tool_profile_bindings", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -48453,9 +50486,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profile_entries_application_idx": { "name": "tool_profile_entries_application_idx", @@ -48474,9 +50507,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profile_entries_connection_idx": { "name": "tool_profile_entries_connection_idx", @@ -48495,9 +50528,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profile_entries_catalog_entry_idx": { "name": "tool_profile_entries_catalog_entry_idx", @@ -48516,76 +50549,76 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_profile_entries_company_id_companies_id_fk": { "name": "tool_profile_entries_company_id_companies_id_fk", "tableFrom": "tool_profile_entries", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_entries_profile_id_tool_profiles_id_fk": { "name": "tool_profile_entries_profile_id_tool_profiles_id_fk", "tableFrom": "tool_profile_entries", - "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], + "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_entries_application_id_tool_applications_id_fk": { "name": "tool_profile_entries_application_id_tool_applications_id_fk", "tableFrom": "tool_profile_entries", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_entries_connection_id_tool_connections_id_fk": { "name": "tool_profile_entries_connection_id_tool_connections_id_fk", "tableFrom": "tool_profile_entries", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_profile_entries", - "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], + "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -48689,9 +50722,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profiles_company_key_uq": { "name": "tool_profiles_company_key_uq", @@ -48710,9 +50743,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_profiles_company_name_uq": { "name": "tool_profiles_company_name_uq", @@ -48731,24 +50764,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_profiles_company_id_companies_id_fk": { "name": "tool_profiles_company_id_companies_id_fk", "tableFrom": "tool_profiles", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -48855,9 +50888,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_rate_limit_counters_window_uq": { "name": "tool_rate_limit_counters_window_uq", @@ -48894,37 +50927,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_rate_limit_counters_company_id_companies_id_fk": { "name": "tool_rate_limit_counters_company_id_companies_id_fk", "tableFrom": "tool_rate_limit_counters", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_rate_limit_counters_policy_id_tool_policies_id_fk": { "name": "tool_rate_limit_counters_policy_id_tool_policies_id_fk", "tableFrom": "tool_rate_limit_counters", - "tableTo": "tool_policies", "columnsFrom": [ "policy_id" ], + "tableTo": "tool_policies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -49008,9 +51041,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_runtime_metric_counters_bucket_uq": { "name": "tool_runtime_metric_counters_bucket_uq", @@ -49035,24 +51068,24 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_runtime_metric_counters_company_id_companies_id_fk": { "name": "tool_runtime_metric_counters_company_id_companies_id_fk", "tableFrom": "tool_runtime_metric_counters", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -49278,9 +51311,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_runtime_slots_connection_idx": { "name": "tool_runtime_slots_connection_idx", @@ -49293,9 +51326,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_runtime_slots_execution_workspace_idx": { "name": "tool_runtime_slots_execution_workspace_idx", @@ -49314,9 +51347,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_runtime_slots_slot_key_uq": { "name": "tool_runtime_slots_slot_key_uq", @@ -49335,89 +51368,89 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_runtime_slots_company_id_companies_id_fk": { "name": "tool_runtime_slots_company_id_companies_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_runtime_slots_application_id_tool_applications_id_fk": { "name": "tool_runtime_slots_application_id_tool_applications_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], + "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_runtime_slots_connection_id_tool_connections_id_fk": { "name": "tool_runtime_slots_connection_id_tool_connections_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], + "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk": { "name": "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], + "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk": { "name": "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "tool_runtime_slots_issue_id_issues_id_fk": { "name": "tool_runtime_slots_issue_id_issues_id_fk", "tableFrom": "tool_runtime_slots", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -49540,9 +51573,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_stdio_command_templates_company_status_idx": { "name": "tool_stdio_command_templates_company_status_idx", @@ -49561,9 +51594,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "tool_stdio_command_templates_company_key_uq": { "name": "tool_stdio_command_templates_company_key_uq", @@ -49582,37 +51615,37 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "tool_stdio_command_templates_company_id_companies_id_fk": { "name": "tool_stdio_command_templates_company_id_companies_id_fk", "tableFrom": "tool_stdio_command_templates", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "tool_stdio_command_templates_created_by_agent_id_agents_id_fk": { "name": "tool_stdio_command_templates_created_by_agent_id_agents_id_fk", "tableFrom": "tool_stdio_command_templates", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -49691,9 +51724,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_inbox_agent_policies_allowed_agent_ids_idx": { "name": "user_inbox_agent_policies_allowed_agent_ids_idx", @@ -49706,24 +51739,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "gin", - "with": {} + "concurrently": false } }, "foreignKeys": { "user_inbox_agent_policies_company_id_companies_id_fk": { "name": "user_inbox_agent_policies_company_id_companies_id_fk", "tableFrom": "user_inbox_agent_policies", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -49838,9 +51871,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_declarations_definition_idx": { "name": "user_secret_declarations_definition_idx", @@ -49853,9 +51886,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_declarations_target_idx": { "name": "user_secret_declarations_target_idx", @@ -49880,9 +51913,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_declarations_company_required_idx": { "name": "user_secret_declarations_company_required_idx", @@ -49901,9 +51934,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_declarations_target_path_uq": { "name": "user_secret_declarations_target_path_uq", @@ -49934,9 +51967,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_declarations_required_override_idx": { "name": "user_secret_declarations_required_override_idx", @@ -49955,38 +51988,38 @@ } ], "isUnique": false, - "where": "\"user_secret_declarations\".\"allow_missing_override\" = true", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"user_secret_declarations\".\"allow_missing_override\" = true", + "concurrently": false } }, "foreignKeys": { "user_secret_declarations_company_id_companies_id_fk": { "name": "user_secret_declarations_company_id_companies_id_fk", "tableFrom": "user_secret_declarations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "user_secret_declarations", - "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], + "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -50132,9 +52165,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_definitions_company_provider_idx": { "name": "user_secret_definitions_company_provider_idx", @@ -50153,9 +52186,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_definitions_provider_config_idx": { "name": "user_secret_definitions_provider_config_idx", @@ -50168,9 +52201,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "user_secret_definitions_company_key_uq": { "name": "user_secret_definitions_company_key_uq", @@ -50189,64 +52222,64 @@ } ], "isUnique": true, - "where": "\"user_secret_definitions\".\"deleted_at\" is null", - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "where": "\"user_secret_definitions\".\"deleted_at\" is null", + "concurrently": false } }, "foreignKeys": { "user_secret_definitions_company_id_companies_id_fk": { "name": "user_secret_definitions_company_id_companies_id_fk", "tableFrom": "user_secret_definitions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk": { "name": "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk", "tableFrom": "user_secret_definitions", - "tableTo": "company_secret_provider_configs", "columnsFrom": [ "provider_config_id" ], + "tableTo": "company_secret_provider_configs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "user_secret_definitions_created_by_agent_id_agents_id_fk": { "name": "user_secret_definitions_created_by_agent_id_agents_id_fk", "tableFrom": "user_secret_definitions", - "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "user_secret_definitions_updated_by_agent_id_agents_id_fk": { "name": "user_secret_definitions_updated_by_agent_id_agents_id_fk", "tableFrom": "user_secret_definitions", - "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -50306,9 +52339,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -50463,137 +52496,137 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "work_assessments_company_id_companies_id_fk": { "name": "work_assessments_company_id_companies_id_fk", "tableFrom": "work_assessments", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_trigger_actor_company_id_companies_id_fk": { "name": "work_assessments_trigger_actor_company_id_companies_id_fk", "tableFrom": "work_assessments", - "tableTo": "companies", "columnsFrom": [ "trigger_actor_company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_issue_company_fk": { "name": "work_assessments_issue_company_fk", "tableFrom": "work_assessments", - "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], + "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_run_owner_fk": { "name": "work_assessments_run_owner_fk", "tableFrom": "work_assessments", - "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_contract_owner_fk": { "name": "work_assessments_contract_owner_fk", "tableFrom": "work_assessments", - "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "contract_id" ], + "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_result_owner_fk": { "name": "work_assessments_result_owner_fk", "tableFrom": "work_assessments", - "tableTo": "native_run_results", "columnsFrom": [ "company_id", "issue_id", "run_id", "result_id" ], + "tableTo": "native_run_results", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "work_assessments_supersedes_owner_fk": { "name": "work_assessments_supersedes_owner_fk", "tableFrom": "work_assessments", - "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "supersedes_assessment_id" ], + "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "work_assessments_company_issue_run_id_uq": { "name": "work_assessments_company_issue_run_id_uq", - "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "id" - ] + ], + "nullsNotDistinct": false } }, "policies": {}, @@ -50772,9 +52805,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_operations_company_workspace_started_idx": { "name": "workspace_operations_company_workspace_started_idx", @@ -50799,9 +52832,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_operations_company_workspace_issue_started_idx": { "name": "workspace_operations_company_workspace_issue_started_idx", @@ -50832,63 +52865,63 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "workspace_operations_company_id_companies_id_fk": { "name": "workspace_operations_company_id_companies_id_fk", "tableFrom": "workspace_operations", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "workspace_operations", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "workspace_operations", - "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_operations_issue_id_issues_id_fk": { "name": "workspace_operations_issue_id_issues_id_fk", "tableFrom": "workspace_operations", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -51111,9 +53144,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_runtime_services_company_execution_workspace_status_idx": { "name": "workspace_runtime_services_company_execution_workspace_status_idx", @@ -51138,9 +53171,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_runtime_services_company_project_status_idx": { "name": "workspace_runtime_services_company_project_status_idx", @@ -51165,9 +53198,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_runtime_services_run_idx": { "name": "workspace_runtime_services_run_idx", @@ -51180,9 +53213,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "workspace_runtime_services_company_updated_idx": { "name": "workspace_runtime_services_company_updated_idx", @@ -51201,102 +53234,283 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "workspace_runtime_services_company_id_companies_id_fk": { "name": "workspace_runtime_services_company_id_companies_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "no action", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "no action" }, "workspace_runtime_services_project_id_projects_id_fk": { "name": "workspace_runtime_services_project_id_projects_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "projects", "columnsFrom": [ "project_id" ], + "tableTo": "projects", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], + "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], + "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_runtime_services_issue_id_issues_id_fk": { "name": "workspace_runtime_services_issue_id_issues_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "issues", "columnsFrom": [ "issue_id" ], + "tableTo": "issues", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_runtime_services_owner_agent_id_agents_id_fk": { "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], + "tableTo": "agents", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", "tableFrom": "workspace_runtime_services", - "tableTo": "heartbeat_runs", "columnsFrom": [ "started_by_run_id" ], + "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_attachment_uploads": { + "name": "mcp_attachment_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_attachment_upload_request_uq": { + "name": "mcp_attachment_upload_request_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_attachment_upload_expiry_idx": { + "name": "mcp_attachment_upload_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_attachment_uploads_company_id_companies_id_fk": { + "name": "mcp_attachment_uploads_company_id_companies_id_fk", + "tableFrom": "mcp_attachment_uploads", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_attachment_uploads_user_id_user_id_fk": { + "name": "mcp_attachment_uploads_user_id_user_id_fk", + "tableFrom": "mcp_attachment_uploads", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -51372,9 +53586,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_event_admissions_grant_idx": { "name": "mcp_event_admissions_grant_idx", @@ -51387,9 +53601,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_event_admissions_company_idx": { "name": "mcp_event_admissions_company_idx", @@ -51402,37 +53616,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_event_admissions_company_id_companies_id_fk": { "name": "mcp_event_admissions_company_id_companies_id_fk", "tableFrom": "mcp_event_admissions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_event_admissions", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -51515,9 +53729,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_event_deliveries_due_idx": { "name": "mcp_event_deliveries_due_idx", @@ -51530,24 +53744,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_event_deliveries_subscription_id_mcp_event_subscriptions_id_fk": { "name": "mcp_event_deliveries_subscription_id_mcp_event_subscriptions_id_fk", "tableFrom": "mcp_event_deliveries", - "tableTo": "mcp_event_subscriptions", "columnsFrom": [ "subscription_id" ], + "tableTo": "mcp_event_subscriptions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -51647,9 +53861,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_event_subscriptions_company_idx": { "name": "mcp_event_subscriptions_company_idx", @@ -51662,37 +53876,164 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_event_subscriptions_company_id_companies_id_fk": { "name": "mcp_event_subscriptions_company_id_companies_id_fk", "tableFrom": "mcp_event_subscriptions", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_event_subscriptions_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_event_subscriptions_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_event_subscriptions", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_file_tickets": { + "name": "mcp_file_tickets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_file_ticket_expiry_idx": { + "name": "mcp_file_ticket_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_file_ticket_grant_idx": { + "name": "mcp_file_ticket_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_file_tickets_company_id_companies_id_fk": { + "name": "mcp_file_tickets_company_id_companies_id_fk", + "tableFrom": "mcp_file_tickets", + "columnsFrom": [ + "company_id" + ], + "tableTo": "companies", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_file_tickets", + "columnsFrom": [ + "grant_id" + ], + "tableTo": "mcp_oauth_grants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk": { + "name": "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk", + "tableFrom": "mcp_file_tickets", + "columnsFrom": [ + "upload_id" + ], + "tableTo": "mcp_attachment_uploads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -51799,9 +54140,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_mutation_receipts_company_idx": { "name": "mcp_mutation_receipts_company_idx", @@ -51814,50 +54155,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_mutation_receipts_company_id_companies_id_fk": { "name": "mcp_mutation_receipts_company_id_companies_id_fk", "tableFrom": "mcp_mutation_receipts", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_mutation_receipts_user_id_user_id_fk": { "name": "mcp_mutation_receipts_user_id_user_id_fk", "tableFrom": "mcp_mutation_receipts", - "tableTo": "user", "columnsFrom": [ "user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_mutation_receipts", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -52023,9 +54364,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_oauth_user_code_uq": { "name": "mcp_oauth_user_code_uq", @@ -52038,9 +54379,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_oauth_device_expiry_idx": { "name": "mcp_oauth_device_expiry_idx", @@ -52053,37 +54394,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_device_requests", - "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], + "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_device_requests", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -52165,50 +54506,50 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_oauth_grants_company_id_companies_id_fk": { "name": "mcp_oauth_grants_company_id_companies_id_fk", "tableFrom": "mcp_oauth_grants", - "tableTo": "companies", "columnsFrom": [ "company_id" ], + "tableTo": "companies", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_oauth_grants_user_id_user_id_fk": { "name": "mcp_oauth_grants_user_id_user_id_fk", "tableFrom": "mcp_oauth_grants", - "tableTo": "user", "columnsFrom": [ "user_id" ], + "tableTo": "user", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_grants", - "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], + "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -52253,9 +54594,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": {}, @@ -52367,9 +54708,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_oauth_requests_expiry_idx": { "name": "mcp_oauth_requests_expiry_idx", @@ -52382,37 +54723,37 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_requests", - "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], + "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_requests", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -52482,9 +54823,9 @@ } ], "isUnique": true, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_oauth_tokens_grant_idx": { "name": "mcp_oauth_tokens_grant_idx", @@ -52497,9 +54838,9 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false }, "mcp_oauth_tokens_expiry_idx": { "name": "mcp_oauth_tokens_expiry_idx", @@ -52512,24 +54853,24 @@ } ], "isUnique": false, - "concurrently": false, + "with": {}, "method": "btree", - "with": {} + "concurrently": false } }, "foreignKeys": { "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_tokens", - "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], + "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -52541,21 +54882,21 @@ }, "enums": {}, "schemas": {}, + "views": {}, "sequences": { "public.chat_telegram_draft_ids": { "name": "chat_telegram_draft_ids", - "schema": "public", "increment": "1", - "startWith": "1", "minValue": "1", "maxValue": "2147483647", + "startWith": "1", "cache": "1", - "cycle": false + "cycle": false, + "schema": "public" } }, "roles": {}, "policies": {}, - "views": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 563829013fc..4319e115911 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2185,6 +2185,13 @@ "when": 1791371746435, "tag": "0313_private_task_access", "breakpoints": true + }, + { + "idx": 314, + "version": "7", + "when": 1791371819805, + "tag": "0314_private_task_draft_assets", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/private-task-draft-assets-migration.test.ts b/packages/db/src/private-task-draft-assets-migration.test.ts new file mode 100644 index 00000000000..54007d4c788 --- /dev/null +++ b/packages/db/src/private-task-draft-assets-migration.test.ts @@ -0,0 +1,35 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import postgres from "postgres"; +import { expect, it } from "vitest"; +import { applyPendingMigrations } from "./client.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; + +itEmbeddedPostgres("backfills owned draft images without attaching another uploader's images", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-draft-privacy-"); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + try { + const migration = await fs.readFile(new URL("./migrations/0314_private_task_draft_assets.sql", import.meta.url), "utf8"); + const hash = createHash("sha256").update(migration).digest("hex"); + const company = randomUUID(), task = randomUUID(), owned = randomUUID(), foreign = randomUUID(); + await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Draft privacy', 'DRAFT')`; + for (const [id, owner] of [[owned, 'owner'], [foreign, 'someone-else']]) { + await sql`INSERT INTO assets (id, company_id, provider, object_key, content_type, byte_size, sha256, created_by_user_id) + VALUES (${id}, ${company}, 'local_disk', ${`${company}/assets/issues/drafts/${id}`}, 'image/png', 1, 'test', ${owner})`; + } + await sql`INSERT INTO issues (id, company_id, title, description, visibility, created_by_user_id) + VALUES (${task}, ${company}, 'Private task', ${`![owned](/api/assets/${owned}/content) ![foreign](/api/assets/${foreign}/content)`}, 'private', 'owner')`; + for (let run = 0; run < 2; run += 1) { + await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`; + await applyPendingMigrations(database.connectionString); + expect(await sql`SELECT issue_id, asset_id FROM issue_attachments WHERE company_id = ${company}`) + .toEqual([{ issue_id: task, asset_id: owned }]); + } + } finally { + await sql.end(); + await database.cleanup(); + } +}, 60_000); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d0842b442ef..2d2f71d4d23 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1105,6 +1105,7 @@ export type { CompactIssue, Issue, IssueVisibility, + IssuePrivacyConstraints, IssueAccessGrant, IssueAccessGrantAgentVisibility, IssueAccessGrantSubjectType, @@ -1936,10 +1937,12 @@ export { projectDiscoverySchema, createProjectSchema, updateProjectSchema, + addProjectAccessMemberSchema, createProjectWorkspaceSchema, updateProjectWorkspaceSchema, type CreateProject, type UpdateProject, + type AddProjectAccessMember, type CreateProjectWorkspace, type UpdateProjectWorkspace, projectExecutionWorkspacePolicySchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e05d9ee417a..f149b2a8425 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -680,6 +680,7 @@ export type { IssueSubtreeDiagnosticEdge, IssueSubtreeDiagnosticsResponse, IssueVisibility, + IssuePrivacyConstraints, IssueAccessGrant, IssueAccessGrantAgentVisibility, IssueAccessGrantSubjectType, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 6d585ffcc12..566269782da 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -47,6 +47,11 @@ import type { export type { IssueWorkMode }; export type IssueVisibility = "open" | "private"; +/** Management hints for this task; never includes protected project/parent identity. */ +export interface IssuePrivacyConstraints { + publicBlockedBy: "parent" | "project" | null; + leavesPersonalProject: boolean; +} export type IssueAccessGrantSubjectType = "user" | "agent"; export type IssueAccessGrantSource = "explicit" | "assignment" | "project" | "owner"; export type IssueAccessGrantAgentVisibility = "discoverable" | "private"; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b45a1946b39..0634cf27391 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -390,12 +390,14 @@ export { projectDiscoverySchema, createProjectSchema, updateProjectSchema, + addProjectAccessMemberSchema, createProjectWorkspaceSchema, updateProjectWorkspaceSchema, projectExecutionWorkspacePolicySchema, projectWorkspaceRuntimeConfigSchema, type CreateProject, type UpdateProject, + type AddProjectAccessMember, type CreateProjectWorkspace, type UpdateProjectWorkspace, type ProjectExecutionWorkspacePolicy, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 77ef460c9fa..20ec32b9631 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -15,6 +15,14 @@ import { import { createAgentSchema } from "./agent.js"; describe("issue validators", () => { + it("accepts private visibility without adding defaults to updates", () => { + expect(createIssueSchema.parse({ title: "Private", visibility: "private" }).visibility).toBe("private"); + expect(createIssueSchema.parse({ title: "Open" }).visibility).toBe("open"); + expect(updateIssueSchema.parse({}).visibility).toBeUndefined(); + expect(updateIssueSchema.parse({ visibility: "private" }).visibility).toBe("private"); + expect(updateIssueSchema.safeParse({ visibility: "secret" }).success).toBe(false); + }); + it("validates the typed recovery display snapshot while retaining older metadata", () => { const metadata = { version: 1, sections: [{ rows: [{ type: "text", text: "Details" }] }] }; const recovery = { kind: "disposition_repair_escalated", actionId: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted", assigneeAgentId: null }; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index d39f1845091..8804e733abd 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -689,6 +689,7 @@ const createIssueBaseSchema = z.object({ projectId: z.string().guid().optional().nullable(), projectWorkspaceId: z.string().guid().optional().nullable(), goalId: z.string().guid().optional().nullable(), + visibility: z.enum(["open", "private"]).optional().default("open"), parentId: z.string().guid().optional().nullable(), blockedByIssueIds: z.array(z.string().guid()).optional(), unblockDescriptor: z diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index d207952f0ef..11b8eb7d830 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -111,6 +111,7 @@ const projectFields = { goalIds: z.array(z.string().guid()).optional(), name: z.string().min(1), description: z.string().optional().nullable(), + visibility: z.enum(["open", "private"]).optional().default("open"), status: z.enum(PROJECT_STATUSES).optional().default("backlog"), leadAgentId: z.string().guid().optional().nullable(), targetDate: z.string().optional().nullable(), @@ -137,4 +138,11 @@ export const updateProjectSchema = objectWithoutDefaults( export type UpdateProject = z.infer; +export const addProjectAccessMemberSchema = z.object({ + subjectType: z.enum(["user", "agent"]), + subjectId: z.string().min(1), +}); + +export type AddProjectAccessMember = z.infer; + export type ProjectExecutionWorkspacePolicy = z.infer; diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 0bb574486c6..796815389c3 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -42,6 +42,14 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); +vi.mock("../services/authorization.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + issueReadSqlCondition: vi.fn(async () => ({}) as any), + }; +}); + vi.mock("../services/agent-action-audit.js", () => ({ agentActionAuditService: () => mockAgentActionAuditService, })); @@ -219,6 +227,7 @@ describe("activity routes", () => { entityType: undefined, entityId: undefined, limit: 100, + readCondition: expect.anything(), }); }); @@ -237,6 +246,7 @@ describe("activity routes", () => { entityType: "issue", entityId: undefined, limit: 500, + readCondition: expect.anything(), }); }); diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index 6f1a9601634..f3c107910ec 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -19,6 +19,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { activityService } from "../services/activity.ts"; +import { issueReadSqlCondition } from "../services/authorization.ts"; import { issueService } from "../services/issues.ts"; import { documentService } from "../services/documents.ts"; @@ -150,6 +151,54 @@ describeEmbeddedPostgres("activity service", () => { expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]); }); + it("filters company activity rows whose entity is a private issue", async () => { + const companyId = randomUUID(); + const privateIssueId = randomUUID(); + const openIssueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Private activity", + issuePrefix: `A${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(issues).values([ + { + id: privateIssueId, + companyId, + title: "Confidential activity", + status: "todo", + priority: "medium", + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: "private-owner", + }, + { + id: openIssueId, + companyId, + title: "Open activity", + status: "todo", + priority: "medium", + }, + ]); + await db.insert(activityLog).values([ + { companyId, actorType: "system", actorId: "system", action: "private.changed", entityType: "issue", entityId: privateIssueId }, + { companyId, actorType: "system", actorId: "system", action: "open.changed", entityType: "issue", entityId: openIssueId }, + ]); + const actor = { + type: "board" as const, + userId: "non-member", + companyIds: [companyId], + source: "session" as const, + isInstanceAdmin: false, + }; + + const rows = await activityService(db).list({ + companyId, + readCondition: await issueReadSqlCondition(db, actor), + }); + expect(rows.map((row) => row.action)).toContain("open.changed"); + expect(rows.map((row) => row.action)).not.toContain("private.changed"); + }); + it.each([null, "native_provider_model_rejected", "adapter_failed"])("returns compact issue runs with bounded model rejection details: %s", async (errorCode) => { const companyId = randomUUID(); const agentId = randomUUID(); @@ -175,10 +224,21 @@ describeEmbeddedPostgres("activity service", () => { permissions: {}, }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Summarize a completed run", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: errorCode ? "failed" : "succeeded", errorCode, @@ -295,6 +355,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:00:00.000Z"), @@ -389,6 +451,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:00:00.000Z"), @@ -404,6 +468,8 @@ describeEmbeddedPostgres("activity service", () => { id: otherRunId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:05:00.000Z"), @@ -511,6 +577,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:10:00.000Z"), diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index d5d1805a694..6210ac99470 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -354,6 +354,8 @@ describe("agent live run routes", () => { mockHeartbeatService.getRunLogAccess.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", + scopeKind: "company", + issueId: null, logStore: "local_file", logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", }); @@ -598,6 +600,8 @@ describe("agent live run routes", () => { { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", + scopeKind: "company", + issueId: null, logStore: "local_file", logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", }, @@ -687,7 +691,8 @@ describe("agent live run routes", () => { lastOutputStream: null, lastOutputBytes: 0, processStartedAt: null, - issueId: "issue-1", + scopeKind: "company", + issueId: null, })); const { db, limit } = createLiveRunsDbStub(rows); @@ -731,7 +736,8 @@ describe("agent live run routes", () => { lastOutputStream: null, lastOutputBytes: 0, processStartedAt: null, - issueId: "issue-1", + scopeKind: "company", + issueId: null, })); const { db, limit } = createLiveRunsDbStub(rows); @@ -774,7 +780,8 @@ describe("agent live run routes", () => { lastOutputStream: null, lastOutputBytes: 0, processStartedAt: null, - issueId: "issue-1", + scopeKind: "company", + issueId: null, })); const selectCalls: Array> = []; @@ -834,7 +841,8 @@ describe("agent live run routes", () => { lastOutputStream: null, lastOutputBytes: 0, processStartedAt: null, - issueId: "issue-1", + scopeKind: "company", + issueId: null, })); const recentRows = Array.from({ length: 4 }, (_, index) => ({ id: `run-recent-${index}`, @@ -861,7 +869,8 @@ describe("agent live run routes", () => { lastOutputStream: null, lastOutputBytes: 0, processStartedAt: null, - issueId: "issue-1", + scopeKind: "company", + issueId: null, })); let selectCallCount = 0; diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 3937b0499a6..bf38fd88a23 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -34,6 +34,10 @@ const mockAccessService = vi.hoisted(() => ({ })); function registerModuleMocks() { + vi.doMock("../services/authorization.js", async (importActual) => ({ + ...await importActual(), + canActorReadApproval: async () => true, + })); vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, approvalService: () => mockApprovalService, diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index fbc6976498c..5825e47e1b1 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -103,7 +103,7 @@ async function createApp(storage: ReturnType) { }; next(); }); - app.use("/api", assetRoutes({} as any, storage)); + app.use("/api", assetRoutes({ select: () => ({ from: () => ({ leftJoin: () => ({ where: async () => [] }), where: async () => [] }) }) } as any, storage)); return app; } diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 889b9a9ace6..d9d43e4c191 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -172,6 +172,9 @@ describeEmbeddedPostgres("attention service", () => { createdAt?: Date; unblockDescriptor?: { owner: { userId: string } | "board"; action: string } | null; blockedTransitionAt?: Date | null; + visibility?: "open" | "private"; + privacyRootIssueId?: string | null; + responsibleUserId?: string | null; harnessKind?: string | null; reviewPolicy?: "anyone" | "not_creator" | "human_only" | null; }) { @@ -195,6 +198,9 @@ describeEmbeddedPostgres("attention service", () => { executionState: input.executionState ?? null, unblockDescriptor: input.unblockDescriptor ?? null, blockedTransitionAt: input.blockedTransitionAt ?? null, + visibility: input.visibility ?? "open", + privacyRootIssueId: input.privacyRootIssueId ?? null, + responsibleUserId: input.responsibleUserId ?? null, harnessKind: input.harnessKind ?? null, createdAt: input.createdAt, updatedAt: input.updatedAt, @@ -2040,6 +2046,35 @@ describeEmbeddedPostgres("attention service", () => { await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403); }); + it("omits private issue review items for a non-member board user", async () => { + const { companyId } = await seedCompany("ATP"); + const privateIssueId = randomUUID(); + await insertIssue({ + id: privateIssueId, + companyId, + identifier: "ATP-1", + title: "Confidential review", + status: "in_review", + assigneeUserId: "private-owner", + executionState: pendingUserExecutionState("private-owner"), + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: "private-owner", + }); + + const actor = { + type: "board" as const, + source: "session" as const, + userId: "non-member", + companyIds: [companyId], + isInstanceAdmin: false, + }; + const feed = await attentionService(db).list(companyId, { userId: actor.userId, actor }); + + expect(feed.items.map((item) => item.subject.title)).not.toContain("Confidential review"); + expect(JSON.stringify(feed)).not.toContain(privateIssueId); + }); + it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => { const { companyId, workerId } = await seedCompany("AGE"); const now = Date.parse("2026-08-02T12:00:00.000Z"); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 02c1b562cd9..4e1f3f63c2e 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, @@ -8,9 +8,11 @@ import { companyMemberships, createDb, instanceUserRoles, + issueAccessGrants, issueComments, issues, principalPermissionGrants, + projectAccessMembers, projects, userInboxAgentPolicies, } from "@paperclipai/db"; @@ -19,7 +21,13 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { authorizationService } from "../services/authorization.js"; +import { + authorizationService, + canActorReadIssuePrivacy, + issuePrivacyMode, + type AuthorizationActor, +} from "../services/authorization.js"; +import { logger } from "../middleware/logger.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,12 +64,18 @@ async function createAgent( .then((rows) => rows[0]!); } -async function createProject(db: ReturnType, companyId: string, label: string) { +async function createProject( + db: ReturnType, + companyId: string, + label: string, + visibility: "open" | "private" = "open", +) { return db .insert(projects) .values({ companyId, name: `Project ${label} ${randomUUID()}`, + visibility, }) .returning() .then((rows) => rows[0]!); @@ -76,6 +90,12 @@ async function createIssue( projectId?: string | null; parentId?: string | null; assigneeAgentId?: string | null; + assigneeUserId?: string | null; + createdByUserId?: string | null; + responsibleUserId?: string | null; + visibility?: "open" | "private"; + privacyRootIssueId?: string | null; + privacyParentIssueId?: string | null; originKind?: string | null; originId?: string | null; } = {}, @@ -91,6 +111,12 @@ async function createIssue( projectId: input.projectId ?? null, parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: input.assigneeUserId ?? null, + createdByUserId: input.createdByUserId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + visibility: input.visibility ?? "open", + privacyRootIssueId: input.privacyRootIssueId ?? null, + privacyParentIssueId: input.privacyParentIssueId ?? null, originKind: input.originKind ?? "manual", originId: input.originId ?? null, }) @@ -167,6 +193,21 @@ describeEmbeddedPostgres("authorization service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + it("defaults issue privacy to enforce while retaining explicit rollout overrides", () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + try { + delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + expect(issuePrivacyMode()).toBe("enforce"); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow"; + expect(issuePrivacyMode()).toBe("shadow"); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off"; + expect(issuePrivacyMode()).toBe("off"); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + } + }); + beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-"); db = createDb(tempDb.connectionString); @@ -178,6 +219,7 @@ describeEmbeddedPostgres("authorization service", () => { await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(instanceUserRoles); + await db.delete(issueAccessGrants); await db.delete(issues); await db.delete(agents); await db.delete(projects); @@ -2744,6 +2786,199 @@ describeEmbeddedPostgres("authorization service", () => { })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); }); + it("enforces private issue implicit principals, grants, revocation, and subtree inheritance", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + const previousTtl = process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = "5000"; + try { + const company = await createCompany(db, "IssuePrivacy"); + const ownerId = await createUser(db); + const memberId = await createUser(db); + const granteeId = await createUser(db); + await db.insert(companyMemberships).values([ownerId, memberId, granteeId].map((principalId) => ({ + companyId: company.id, + principalType: "user", + principalId, + status: "active", + membershipRole: "operator", + }))); + const assignedAgent = await createAgent(db, company.id); + const otherAgent = await createAgent(db, company.id); + const authz = authorizationService(db); + const userActor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session" }); + const agentActor = (agentId: string): AuthorizationActor => ({ + type: "agent", + agentId, + companyId: company.id, + source: "agent_key", + }); + const decide = (actor: AuthorizationActor, issue: Awaited>) => authz.decide({ + actor, + action: "issue:read", + resource: { type: "issue", companyId: company.id, issueId: issue.id }, + }); + + const openIssue = await createIssue(db, company.id); + await expect(decide(userActor(memberId), openIssue)).resolves.toMatchObject({ allowed: true }); + + const privateRootId = randomUUID(); + const privateRoot = await createIssue(db, company.id, { + id: privateRootId, + visibility: "private", + privacyRootIssueId: privateRootId, + responsibleUserId: ownerId, + assigneeAgentId: assignedAgent.id, + }); + await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + await expect(decide(agentActor(otherAgent.id), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + await expect(decide(userActor(ownerId), privateRoot)).resolves.toMatchObject({ allowed: true }); + await expect(decide(agentActor(assignedAgent.id), privateRoot)).resolves.toMatchObject({ allowed: true }); + + const grant = await db.insert(issueAccessGrants).values({ + issueId: privateRoot.id, + subjectType: "user", + subjectId: granteeId, + source: "explicit", + grantedByUserId: ownerId, + }).returning().then((rows) => rows[0]!); + await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ allowed: true }); + + const child = await createIssue(db, company.id, { + parentId: privateRoot.id, + privacyParentIssueId: privateRoot.id, + visibility: "private", + privacyRootIssueId: privateRoot.id, + }); + await expect(decide(userActor(granteeId), child)).resolves.toMatchObject({ allowed: true }); + + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow"; + const warn = vi.spyOn(logger, "warn").mockImplementation(() => logger); + await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ allowed: true }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + authzMode: "shadow", + issueId: privateRoot.id, + reason: "deny_issue_private", + }), + "issue privacy would deny read", + ); + warn.mockRestore(); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + if (previousTtl === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS; + else process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = previousTtl; + } + }); + + it("uses private-project membership as the same issue-read predicate while preserving issue grants", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + try { + const company = await createCompany(db, "ProjectPrivacy"); + const memberId = await createUser(db); + const granteeId = await createUser(db); + const outsiderId = await createUser(db); + await db.insert(companyMemberships).values([memberId, granteeId, outsiderId].map((principalId) => ({ + companyId: company.id, + principalType: "user" as const, + principalId, + status: "active", + membershipRole: "operator", + }))); + const project = await createProject(db, company.id, "Private", "private"); + const memberAgent = await createAgent(db, company.id); + const outsiderAgent = await createAgent(db, company.id); + await db.insert(companyMemberships).values([memberAgent.id, outsiderAgent.id].map((principalId) => ({ + companyId: company.id, + principalType: "agent" as const, + principalId, + status: "active", + membershipRole: "member", + }))); + await db.insert(projectAccessMembers).values({ + companyId: company.id, + projectId: project.id, + subjectType: "user", + subjectId: memberId, + }); + await db.insert(projectAccessMembers).values({ + companyId: company.id, + projectId: project.id, + subjectType: "agent", + subjectId: memberAgent.id, + }); + const issue = await createIssue(db, company.id, { projectId: project.id }); + await db.insert(issueAccessGrants).values({ + issueId: issue.id, + subjectType: "user", + subjectId: granteeId, + source: "explicit", + grantedByUserId: memberId, + }); + const authz = authorizationService(db); + const decideProject = (userId: string) => authz.decide({ + actor: { type: "board", userId, source: "session" }, + action: "project:read", + resource: { type: "project", companyId: company.id, projectId: project.id }, + }); + const decideIssue = (userId: string) => authz.decide({ + actor: { type: "board", userId, source: "session" }, + action: "issue:read", + resource: { type: "issue", companyId: company.id, issueId: issue.id }, + }); + const decideProjectAsAgent = (agentId: string) => authz.decide({ + actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" }, + action: "project:read", + resource: { type: "project", companyId: company.id, projectId: project.id }, + }); + const decideIssueAsAgent = (agentId: string) => authz.decide({ + actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" }, + action: "issue:read", + resource: { type: "issue", companyId: company.id, issueId: issue.id }, + }); + + await expect(decideProject(memberId)).resolves.toMatchObject({ allowed: true }); + await expect(decideProject(outsiderId)).resolves.toMatchObject({ + allowed: false, + reason: "deny_project_private", + }); + await expect(decideIssue(memberId)).resolves.toMatchObject({ allowed: true }); + await expect(decideIssue(granteeId)).resolves.toMatchObject({ allowed: true }); + await expect(decideIssue(outsiderId)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + await expect(decideProjectAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true }); + await expect(decideIssueAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true }); + await expect(decideProjectAsAgent(outsiderAgent.id)).resolves.toMatchObject({ + allowed: false, + reason: "deny_project_private", + }); + await expect(decideIssueAsAgent(outsiderAgent.id)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + } + }); + + it("denies secrets proposals for scoped tokens regardless of request source", async () => { const company = await createCompany(db, "SecretProposalScopes"); const actorAgent = await createAgent(db, company.id); diff --git a/server/src/__tests__/chat-project-tools.test.ts b/server/src/__tests__/chat-project-tools.test.ts index 81739407d39..b75f5ed6609 100644 --- a/server/src/__tests__/chat-project-tools.test.ts +++ b/server/src/__tests__/chat-project-tools.test.ts @@ -190,8 +190,9 @@ const support = await getEmbeddedPostgresTestSupport(); { projectId: f.projectId, assigneeAgentId: null, assigneeUserId: "board-user" }, ]) { const result = await create(path, fields); - expect(result.status, JSON.stringify(result.body)).toBe(403); - expect(result.body.error).toMatch(/outside.*boundary|cannot assign work to board users|different company/i); + const hiddenProject = fields.projectId !== f.projectId && path.startsWith("/api/companies/"); + expect(result.status, JSON.stringify(result.body)).toBe(hiddenProject ? 404 : 403); + expect(result.body.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary|cannot assign work to board users|different company/i); } } const after = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId)); @@ -232,7 +233,10 @@ const support = await getEmbeddedPostgresTestSupport(); body: JSON.stringify({ title: "Root cannot escape into another project", parentId: f.issueId, projectId: outside.id, assigneeAgentId: f.agentId, status: "backlog" }), }); - expect(response.status, JSON.stringify(await response.json())).toBe(403); + const denied = await response.json(); + const hiddenProject = path.startsWith("/api/companies/"); + expect(response.status, JSON.stringify(denied)).toBe(hiddenProject ? 404 : 403); + expect(denied.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary/i); } await expect(call(f, "create_task", { title: "Native root cannot escape", projectId: outside.id, idempotencyKey: "root-outside", status: "backlog" })).rejects.toThrow(/outside.*boundary/); diff --git a/server/src/__tests__/company-search-extract-routes.test.ts b/server/src/__tests__/company-search-extract-routes.test.ts index 375247858b6..1e939d7d05f 100644 --- a/server/src/__tests__/company-search-extract-routes.test.ts +++ b/server/src/__tests__/company-search-extract-routes.test.ts @@ -67,13 +67,17 @@ describe("company extract-search route", () => { }) .expect(200); - expect(extract).toHaveBeenCalledWith("company-1", expect.objectContaining({ + expect(extract).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ contains: "github.com/example/repo/pull", kind: "url", scope: "comments", limit: 200, matchesPerIssue: 200, - })); + }), + expect.objectContaining({ issueReadCondition: expect.anything() }), + ); expect(response.body).toMatchObject({ kind: "url", scope: "comments", truncated: false }); }); diff --git a/server/src/__tests__/company-search-extract-service.test.ts b/server/src/__tests__/company-search-extract-service.test.ts index d50c0e149af..46b37c3c39a 100644 --- a/server/src/__tests__/company-search-extract-service.test.ts +++ b/server/src/__tests__/company-search-extract-service.test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { companies, + companyMemberships, createDb, documents, issueComments, @@ -18,6 +19,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { companySearchExtractService } from "../services/company-search-extract.js"; +import { issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -73,6 +75,7 @@ describeEmbeddedPostgres("companySearchExtractService", () => { await db.delete(documents); await db.delete(issueComments); await db.delete(issues); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -266,4 +269,44 @@ describeEmbeddedPostgres("companySearchExtractService", () => { expect(result.results).toEqual([]); }); + + it("does not extract content from a private issue for a non-member", async () => { + const companyId = await createCompany(); + const viewerId = randomUUID(); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: viewerId, + membershipRole: "operator", + status: "active", +}); + const visibleIssueId = await createIssue(companyId, { + identifier: "EXT-PUBLIC", + description: "privacy-leak-needle", + }); + const privateIssueId = randomUUID(); + await createIssue(companyId, { + id: privateIssueId, + identifier: "EXT-PRIVATE", + description: "privacy-leak-needle private payload", + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: randomUUID(), + }); + const actor: AuthorizationActor = { + type: "board", + userId: viewerId, + source: "session", + }; + + const result = await svc.extract( + companyId, + companySearchExtractQuerySchema.parse({ contains: "privacy-leak-needle" }), + { issueReadCondition: await issueReadSqlCondition(db, actor) }, + ); + + expect(result.results.map((row) => row.issueId)).toEqual([visibleIssueId]); + expect(JSON.stringify(result)).not.toContain(privateIssueId); + expect(JSON.stringify(result)).not.toContain("private payload"); + }); }); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index a8665665abb..5d9e53260fb 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -8,9 +8,11 @@ import { documents, issueComments, issueDocuments, + issueAccessGrants, issueLabels, issues, labels, + projectAccessMembers, projects, } from "@paperclipai/db"; import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared"; @@ -23,6 +25,8 @@ import { companySearchBranchFetchLimit, companySearchService, } from "../services/company-search.js"; +import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js"; +import { issueService } from "../services/issues.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -87,6 +91,7 @@ describeEmbeddedPostgres("companySearchService", () => { await db.delete(documents); await db.delete(issueComments); await db.delete(issueLabels); + await db.delete(issueAccessGrants); await db.delete(issues); await db.delete(labels); await db.delete(projects); @@ -814,4 +819,99 @@ describeEmbeddedPostgres("companySearchService", () => { expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected); } }); + + it("pushes private issue filtering into list, count, and company search queries", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + try { + const companyId = await createCompany(); + const ownerId = `owner-${randomUUID()}`; + const otherId = `other-${randomUUID()}`; + const granteeId = `grantee-${randomUUID()}`; + const openId = await createIssue(companyId, { title: "privacy needle open" }); + const privateId = randomUUID(); + await createIssue(companyId, { + id: privateId, + title: "privacy needle private", + visibility: "private", + privacyRootIssueId: privateId, + responsibleUserId: ownerId, +}); + await db.insert(issueAccessGrants).values({ + issueId: privateId, + subjectType: "user", + subjectId: granteeId, + source: "explicit", + grantedByUserId: ownerId, + }); + + const issuesSvc = issueService(db); + const conditionFor = (userId: string) => issueReadSqlCondition(db, { + type: "board", + userId, + source: "session", + }); + const otherCondition = await conditionFor(otherId); + const ownerCondition = await conditionFor(ownerId); + const granteeCondition = await conditionFor(granteeId); + + await expect(issuesSvc.list(companyId, { readCondition: otherCondition })).resolves.toMatchObject([ + { id: openId }, + ]); + await expect(issuesSvc.count(companyId, { readCondition: otherCondition })).resolves.toBe(1); + await expect(issuesSvc.count(companyId, { readCondition: ownerCondition })).resolves.toBe(2); + await expect(issuesSvc.count(companyId, { readCondition: granteeCondition })).resolves.toBe(2); + + const result = await svc.search( + companyId, + companySearchQuerySchema.parse({ q: "privacy needle", scope: "issues" }), + { issueReadCondition: otherCondition }, + ); + expect(result.results.map((row) => row.id)).toEqual([openId]); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + } + }); + + it("pushes private project filtering into project search and counts", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + try { + const companyId = await createCompany(); + const memberId = `member-${randomUUID()}`; + const outsiderId = `outsider-${randomUUID()}`; + const openId = await createProject(companyId, { name: "privacy project open" }); + const privateId = await createProject(companyId, { + name: "privacy project private", + visibility: "private", + }); + await db.insert(projectAccessMembers).values({ + companyId, + projectId: privateId, + subjectType: "user", + subjectId: memberId, + }); + const conditionFor = (userId: string) => projectReadSqlCondition(db, { + type: "board", + userId, + source: "session", + }); + const query = companySearchQuerySchema.parse({ q: "privacy project", scope: "projects" }); + const outsiderResult = await svc.search(companyId, query, { + projectReadCondition: await conditionFor(outsiderId), + }); + expect(outsiderResult.results.map((row) => row.id)).toEqual([openId]); + expect(outsiderResult.countsByType.project).toBe(1); + + const memberResult = await svc.search(companyId, query, { + projectReadCondition: await conditionFor(memberId), + }); + expect(memberResult.results.map((row) => row.id)).toEqual(expect.arrayContaining([openId, privateId])); + expect(memberResult.countsByType.project).toBe(2); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + } + }); }); diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index c0de02f88c7..c6c318c372d 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -3,6 +3,7 @@ import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, + authUsers, aiProviderDefaults, agentWakeupRequests, issueComments, @@ -110,6 +111,7 @@ describeEmbeddedPostgres("connectionIntentService", () => { issuePrefix: "CONN", requireBoardApprovalForNewAgents: false, }); + await db.insert(authUsers).values({ id: "responsible-user", name: "Responsible user", email: "responsible@example.test", createdAt: new Date(), updatedAt: new Date() }); await db.insert(companyMemberships).values({ companyId, principalType: "user", diff --git a/server/src/__tests__/decisions-service.test.ts b/server/src/__tests__/decisions-service.test.ts index 15dcd96a519..4b418e355f1 100644 --- a/server/src/__tests__/decisions-service.test.ts +++ b/server/src/__tests__/decisions-service.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { activityLog, @@ -87,7 +87,6 @@ describePg("decisionService", () => { options: [{ id: "yes", label: "Yes", effects: [{ type: "comment_on_issue", targetIssueId, staleness, bodyMarkdown: "hello" }] }], ...extra, }); - // Make an existing decision TTL-expired for the next sweep. Creating a // decision that is already expired is impossible (create rejects a past // expiresAt), and creating one that expires a few milliseconds later races diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index ff0b60ee1f9..2df46e77cc5 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -62,6 +62,12 @@ vi.mock("../services/index.js", () => ({ workspaceOperationService: () => ({}), accessService: () => ({ canUser: vi.fn(), + decide: vi.fn(async (input: { action: string }) => ({ + allowed: ["project:read", "issue:read"].includes(input.action), + action: input.action, + reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant", + explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.", + })), hasPermission: vi.fn(), }), agentService: () => ({ diff --git a/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts index 07976472721..7df50f492ac 100644 --- a/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts +++ b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; @@ -7,6 +8,14 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +// This unit suite isolates runtime lifecycle behavior; privacy is covered with a +// real database by privacy-production-review.test.ts. +vi.mock("../services/authorization.js", async () => ({ + ...(await vi.importActual("../services/authorization.js")), + executionWorkspaceReadSqlCondition: async () => sql`true`, + canActorReadExecutionWorkspace: async () => true, +})); + const mockExecutionWorkspaceService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), @@ -157,8 +166,8 @@ function createRegisteredRepairFixture( prefix: string, options: { targetInstanceId?: string; withCli?: boolean } = {}, ) { - const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const baseCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`)); + const workspaceCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + const baseCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`))); const configDir = path.join(workspaceCwd, ".paperclip"); const sourceConfigPath = path.join(baseCwd, ".paperclip", "config.json"); const targetInstanceId = options.targetInstanceId ?? "repair-target"; @@ -300,7 +309,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/stop`) .send({}); - expect(res.status).toBe(409); + expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.details ?? res.body).toMatchObject({ code: "workspace_runtime_control_in_progress", activeAction: "start", @@ -322,7 +331,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`) .send({}); - expect(res.status).toBe(403); + expect(res.status, JSON.stringify(res.body)).toBe(403); expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled(); }); @@ -367,7 +376,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(422); + expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body).toMatchObject({ code: "workspace_repair_precondition_failed", reason: "seed_manifest_malformed", @@ -415,7 +424,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(422); + expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body).toMatchObject({ code: "workspace_repair_precondition_failed", reason: "source_registration_invalid", @@ -450,7 +459,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(422); + expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body).toMatchObject({ code: "workspace_repair_precondition_failed", reason: "source_registration_invalid", @@ -471,7 +480,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(422); + expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body).toMatchObject({ code: "workspace_repair_precondition_failed", reason: "source_registration_invalid", @@ -493,7 +502,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(422); + expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body).toMatchObject({ code: "workspace_repair_precondition_failed", reason: "seed_manifest_instance_mismatch", @@ -573,7 +582,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith( expect.objectContaining({ executionWorkspaceId, workspaceCwd: fixture.workspaceCwd }), ); @@ -604,7 +613,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1); expect(mockStartRuntimeServices).not.toHaveBeenCalled(); expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith( @@ -656,7 +665,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) .send({}); - expect(res.status).toBe(500); + expect(res.status, JSON.stringify(res.body)).toBe(500); expect(res.body).toEqual({ error: "Internal server error" }); expect(progress).toEqual(expect.arrayContaining([ expect.objectContaining({ metadata: { seedFailurePhase: "restore" } }), diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index 8f5e1c193b3..650b3351a31 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -1,9 +1,16 @@ +import { sql } from "drizzle-orm"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js"; +vi.mock("../services/authorization.js", async (importActual) => ({ + ...await importActual(), + executionWorkspaceReadSqlCondition: async () => sql`true`, + canActorReadExecutionWorkspace: async () => true, +})); + const mockExecutionWorkspaceService = vi.hoisted(() => ({ list: vi.fn(), listOverview: vi.fn(), @@ -133,6 +140,7 @@ describe("execution workspace routes", () => { }, ]); expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + readCondition: expect.anything(), projectId: undefined, projectWorkspaceId: undefined, issueId: undefined, @@ -159,7 +167,7 @@ describe("execution workspace routes", () => { status: ["active", "idle"], limit: 25, offset: 10, - }); + }, expect.anything()); }); it("rejects invalid workspace overview pagination", async () => { diff --git a/server/src/__tests__/external-object-routes.test.ts b/server/src/__tests__/external-object-routes.test.ts index ec69974f6a5..6c36c608aa1 100644 --- a/server/src/__tests__/external-object-routes.test.ts +++ b/server/src/__tests__/external-object-routes.test.ts @@ -224,11 +224,13 @@ describe("external object routes", () => { const app = await createApp(ownerActor()); const summary = await request(app).get(`/api/issues/${issueId}/external-object-summary`); - expect(summary.status).toBe(403); + expect(summary.status).toBe(404); + expect(summary.body.error).toBe("Issue not found"); expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled(); const list = await request(app).get(`/api/issues/${issueId}/external-objects`); - expect(list.status).toBe(403); + expect(list.status).toBe(404); + expect(list.body.error).toBe("Issue not found"); expect(mockExternalObjectsService.listForIssue).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index b60ea88fd9e..2748e28d22c 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -208,6 +208,20 @@ describeEmbeddedPostgres("workspace file resources", () => { await tempDb?.cleanup(); }); + it("withholds private task files and private cross-project targets", async () => { + const { projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot }); + await fs.writeFile(path.join(projectRoot, "secret.txt"), "PRIVATE_FILE", "utf8"); + await fs.writeFile(path.join(targetProjectRoot, "secret.txt"), "PRIVATE_TARGET", "utf8"); + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + await db.update(issues).set({ visibility: "private", responsibleUserId: "owner" }).where(eq(issues.id, graph.issueId)); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(404); + await db.update(issues).set({ visibility: "open" }).where(eq(issues.id, graph.issueId)); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(200); + await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId)); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "secret.txt" }).expect(404); + }); + it("resolves and reads a project file without exposing absolute paths", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts new file mode 100644 index 00000000000..94dbd7e0929 --- /dev/null +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -0,0 +1,395 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + agentRuntimeState, + activityLog, + authUsers, + companies, + companyMemberships, + createDb, + heartbeatRunEvents, + heartbeatRuns, + issueAccessGrants, + issues, + workspaceOperations, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { activityRoutes } from "../routes/activity.js"; +import { agentRoutes } from "../routes/agents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-privacy-routes-"); + db = createDb(tempDb.connectionString); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + }, 120_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(workspaceOperations); + await db.delete(heartbeatRunEvents); + await db.delete(issueAccessGrants); + await db.delete(agentRuntimeState); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(companies); + await db.delete(authUsers); + }); + + afterAll(async () => { + if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode; + await tempDb?.cleanup(); + }); + + async function seedFixture() { + const companyId = randomUUID(); + const ownerAgentId = randomUUID(); + const grantedAgentId = randomUUID(); + const otherAgentId = randomUUID(); + const ownerUserId = `owner-${randomUUID()}`; + const otherUserId = `other-${randomUUID()}`; + const issueId = randomUUID(); + const privateRunId = randomUUID(); + const maintenanceRunId = randomUUID(); + const operationId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Run Privacy", + issuePrefix: "RPR", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values([ownerUserId, otherUserId].map((id) => ({ + id, + name: id === ownerUserId ? "Owner user" : "Other user", + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }))); + await db.insert(companyMemberships).values([ownerUserId, otherUserId].map((principalId) => ({ + companyId, + principalType: "user", + principalId, + status: "active", + membershipRole: "operator", + }))); + await db.insert(agents).values([ + { + id: ownerAgentId, + companyId, + name: "Owner", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: grantedAgentId, + companyId, + name: "Granted reviewer", + role: "security", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "Other agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "RPR-1", + title: "Confidential email triage", + description: "private@example.test", + visibility: "private", + privacyRootIssueId: issueId, + status: "in_progress", + responsibleUserId: ownerUserId, + assigneeAgentId: ownerAgentId, + }); + await db.insert(issueAccessGrants).values({ + issueId, + subjectType: "agent", + subjectId: grantedAgentId, + source: "explicit", + grantedByAgentId: ownerAgentId, + }); + + const startedAt = new Date("2026-07-31T12:00:00.000Z"); + const finishedAt = new Date("2026-07-31T12:01:00.000Z"); + await db.insert(heartbeatRuns).values([ + { + id: privateRunId, + companyId, + agentId: ownerAgentId, + scopeKind: "issue", + issueId, + invocationSource: "assignment", + status: "running", + startedAt, + finishedAt, + usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 }, + resultJson: { summary: "Read the confidential email", costUsd: 0.42 }, + contextSnapshot: { issueId, title: "Confidential email triage" }, + logStore: "local_file", + logRef: "missing-private-run-log.ndjson", + }, + { + id: maintenanceRunId, + companyId, + agentId: ownerAgentId, + scopeKind: "company", + issueId: null, + invocationSource: "timer", + status: "succeeded", + startedAt, + finishedAt, + resultJson: { summary: "Maintenance complete", costUsd: 0.01 }, + contextSnapshot: { wakeReason: "heartbeat_timer" }, + }, + ]); + await db.insert(heartbeatRunEvents).values({ + companyId, + runId: privateRunId, + agentId: ownerAgentId, + seq: 1, + eventType: "output", + stream: "stdout", + message: "private@example.test", + }); + await db.insert(workspaceOperations).values({ + id: operationId, + companyId, + heartbeatRunId: privateRunId, + issueId: null, + phase: "provision", + status: "succeeded", + command: "read private email", + stdoutExcerpt: "private@example.test", + logStore: "local_file", + logRef: "missing-private-operation-log.ndjson", + }); + + return { + companyId, + ownerAgentId, + grantedAgentId, + otherAgentId, + ownerUserId, + otherUserId, + issueId, + privateRunId, + maintenanceRunId, + operationId, + }; + } + + function createApp(companyId: string, agentId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId, + companyId, + source: "agent_jwt", + }; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use("/api", activityRoutes(db)); + app.use(errorHandler); + return app; + } + + function createBoardApp(companyId: string, userId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId, + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + return app; + } + + it("returns 404 for direct private-run content and metadata-only list stubs to a non-member", async () => { + const fixture = await seedFixture(); + const app = createApp(fixture.companyId, fixture.otherAgentId); + + for (const path of [ + `/api/heartbeat-runs/${fixture.privateRunId}`, + `/api/heartbeat-runs/${fixture.privateRunId}/events`, + `/api/heartbeat-runs/${fixture.privateRunId}/log`, + `/api/heartbeat-runs/${fixture.privateRunId}/workspace-operations`, + `/api/workspace-operations/${fixture.operationId}/log`, + ]) { + const response = await request(app).get(path); + expect(response.status, path).toBe(404); + expect(JSON.stringify(response.body)).not.toContain("private@example.test"); + } + + const history = await request(app).get(`/api/companies/${fixture.companyId}/heartbeat-runs`); + expect(history.status).toBe(200); + const privateStub = history.body.find((run: { id: string }) => run.id === fixture.privateRunId); + expect(privateStub).toMatchObject({ + redacted: true, + durationMs: 60_000, + costUsd: 0.42, + usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 }, + }); + expect(privateStub).not.toHaveProperty("issueId"); + expect(privateStub).not.toHaveProperty("contextSnapshot"); + expect(privateStub).not.toHaveProperty("resultJson"); + expect(JSON.stringify(privateStub)).not.toContain("Confidential email triage"); + expect(JSON.stringify(privateStub)).not.toContain("summary"); + + const summarizedHistory = await request(app) + .get(`/api/companies/${fixture.companyId}/heartbeat-runs?summary=true`); + const summarizedStub = summarizedHistory.body.find((run: { id: string }) => run.id === fixture.privateRunId); + expect(summarizedStub).toMatchObject({ redacted: true, durationMs: 60_000, costUsd: 0.42 }); + expect(summarizedStub.usageJson).toMatchObject({ inputTokens: 11, outputTokens: 7 }); + + const live = await request(app).get(`/api/companies/${fixture.companyId}/live-runs`); + expect(live.status).toBe(200); + expect(live.body[0]).toMatchObject({ id: fixture.privateRunId, redacted: true, costUsd: 0.42 }); + expect(live.body[0]).not.toHaveProperty("issueId"); + + const maintenance = await request(app).get(`/api/heartbeat-runs/${fixture.maintenanceRunId}`); + expect(maintenance.status).toBe(200); + expect(maintenance.body.resultJson.summary).toBe("Maintenance complete"); + }); + + it("returns full private-run content to the owner and an explicitly granted agent", async () => { + const fixture = await seedFixture(); + + for (const agentId of [fixture.ownerAgentId, fixture.grantedAgentId]) { + const app = createApp(fixture.companyId, agentId); + const detail = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}`); + expect(detail.status).toBe(200); + expect(detail.body).toMatchObject({ issueId: fixture.issueId }); + expect(detail.body.resultJson.summary).toBe("Read the confidential email"); + + const events = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/events`); + expect(events.status).toBe(200); + expect(events.body[0].message).toBe("private@example.test"); + + const linkedIssues = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`); + expect(linkedIssues.status).toBe(200); + expect(linkedIssues.body[0]).toMatchObject({ issueId: fixture.issueId, title: "Confidential email triage" }); + } + }); + + it("returns no linked issue metadata to a non-member", async () => { + const fixture = await seedFixture(); + const response = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`); + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it("conceals private runs from unauthorized cancellation and watchdog mutations", async () => { + const fixture = await seedFixture(); + + const deniedCancel = await request(createBoardApp(fixture.companyId, fixture.otherUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`); + expect(deniedCancel.status).toBe(404); + + const deniedWatchdog = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`) + .send({ decision: "continue" }); + expect(deniedWatchdog.status).toBe(404); + + const allowedWatchdog = await request(createBoardApp(fixture.companyId, fixture.ownerUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`) + .send({ decision: "continue" }); + expect(allowedWatchdog.status).toBe(200); + + await db.update(heartbeatRuns) + .set({ status: "succeeded" }) + .where(eq(heartbeatRuns.id, fixture.privateRunId)); + const allowedCancel = await request(createBoardApp(fixture.companyId, fixture.ownerUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`); + expect(allowedCancel.status).toBe(200); + expect(allowedCancel.body.id).toBe(fixture.privateRunId); + }); + + it("keeps deleted-issue runs and operation history bound to a fail-closed tombstone", async () => { + const fixture = await seedFixture(); + + await db.delete(issues).where(eq(issues.id, fixture.issueId)); + + const missingDetail = await request(createApp(fixture.companyId, fixture.ownerAgentId)) + .get(`/api/heartbeat-runs/${fixture.privateRunId}`); + expect(missingDetail.status).toBe(404); + + const [tombstonedRun] = await db.select({ + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, fixture.privateRunId)); + expect(tombstonedRun).toEqual({ scopeKind: "issue", issueId: null }); + + const [operation] = await db.select({ + heartbeatRunId: workspaceOperations.heartbeatRunId, + issueId: workspaceOperations.issueId, + }) + .from(workspaceOperations) + .where(eq(workspaceOperations.id, fixture.operationId)); + expect(operation).toEqual({ heartbeatRunId: fixture.privateRunId, issueId: null }); + + const missingOperationLog = await request(createApp(fixture.companyId, fixture.ownerAgentId)) + .get(`/api/workspace-operations/${fixture.operationId}/log`); + expect(missingOperationLog.status).toBe(404); + }); + + it("logs would-deny decisions without enforcing them in shadow mode", async () => { + const fixture = await seedFixture(); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow"; + try { + const response = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .get(`/api/heartbeat-runs/${fixture.privateRunId}`); + expect(response.status).toBe(200); + expect(response.body.resultJson.summary).toBe("Read the confidential email"); + } finally { + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + } + }); +}); diff --git a/server/src/__tests__/issue-access-grants-routes.test.ts b/server/src/__tests__/issue-access-grants-routes.test.ts new file mode 100644 index 00000000000..f89dc4f0af6 --- /dev/null +++ b/server/src/__tests__/issue-access-grants-routes.test.ts @@ -0,0 +1,462 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + agentWakeupRequests, + authUsers, + companies, + companyMemberships, + createDb, + heartbeatRuns, + issueAccessGrants, + issueComments, + issueReferenceMentions, + issueRelations, + issues, + projectAccessMembers, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +vi.mock("../services/issue-assignment-wakeup.js", () => ({ + queueIssueAssignmentWakeup: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +type Db = ReturnType; + +function boardActor(companyId: string, userId: string): Express.Request["actor"] { + return { + type: "board", + userId, + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }; +} + +function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt" }; +} + +async function createApp(db: Db, actor: Express.Request["actor"]) { + const { issueRoutes } = await import("../routes/issues.js"); + const { projectRoutes } = await import("../routes/projects.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use("/api", projectRoutes(db)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); + }); + return app; +} + +describeEmbeddedPostgres("issue access grant and locked edge routes", { concurrent: false }, () => { + let db!: Db; + let tempDb: Awaited> | null = null; + const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-access-grants-"); + db = createDb(tempDb.connectionString); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + // Load the current route dependency graph during setup, outside assertion timeouts. + await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]); + }, 120_000); + + afterEach(async () => { + await db.delete(issueReferenceMentions); + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(activityLog); + await db.delete(issueAccessGrants); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(projectAccessMembers); + await db.delete(projects); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(authUsers); + await db.delete(companies); + }); + + afterAll(async () => { + if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode; + await tempDb?.cleanup(); + }); + + async function seedFixture() { + const companyId = randomUUID(); + const ownerId = `owner-${randomUUID()}`; + const adminId = `admin-${randomUUID()}`; + const memberId = `member-${randomUUID()}`; + const sharedUserId = `shared-${randomUUID()}`; + const now = new Date(); + await db.insert(companies).values({ + id: companyId, + name: "Private issue grants", + issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 5).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values([ + { id: ownerId, name: "Olivia Owner", email: `${ownerId}@example.test`, createdAt: now, updatedAt: now }, + { id: adminId, name: "Ada Admin", email: `${adminId}@example.test`, createdAt: now, updatedAt: now }, + { id: memberId, name: "Nora Nonreader", email: `${memberId}@example.test`, createdAt: now, updatedAt: now }, + { + id: sharedUserId, + name: "Uma User", + email: `${sharedUserId}@example.test`, + image: "https://example.test/uma.png", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(companyMemberships).values([ownerId, adminId, memberId, sharedUserId].map((principalId) => ({ + companyId, + principalType: "user", + principalId, + status: "active", + membershipRole: principalId === ownerId ? "owner" : principalId === adminId ? "admin" : "operator", + }))); + const [assignedAgent, sharedAgent] = await db.insert(agents).values([ + { + companyId, + name: "Assigned Agent", + role: "engineer", + status: "active", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + companyId, + name: "Private Helper", + role: "engineer", + status: "active", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: { + authorizationPolicy: { + agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, + }, + }, + }, + ]).returning(); + const privateProject = await db.insert(projects).values({ + companyId, + name: "Secret launch project", + visibility: "private", + status: "planned", + }).returning().then((rows) => rows[0]!); + const privateIssueId = randomUUID(); + await db.insert(issues).values({ + id: privateIssueId, + companyId, + identifier: "PRV-1", + title: "Confidential root", + status: "backlog", + priority: "medium", + projectId: privateProject.id, + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: ownerId, + createdByUserId: ownerId, + }); + const privateChild = await db.insert(issues).values({ + companyId, + parentId: privateIssueId, + identifier: "PRV-1A", + title: "Confidential child", + status: "backlog", + priority: "medium", + projectId: privateProject.id, + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: ownerId, + createdByUserId: ownerId, + }).returning().then((rows) => rows[0]!); + const openIssue = await db.insert(issues).values({ + companyId, + identifier: "PRV-2", + title: "Open issue", + status: "todo", + priority: "medium", + createdByUserId: ownerId, + }).returning().then((rows) => rows[0]!); + return { + companyId, + ownerId, + adminId, + memberId, + sharedUserId, + assignedAgent: assignedAgent!, + sharedAgent: sharedAgent!, + privateProject, + privateIssueId, + privateChild, + openIssue, + }; + } + + it("requires explicit owner/admin break-glass and records both audit evidence and an owner-visible notice", async () => { + const fixture = await seedFixture(); + const adminApp = await createApp(db, boardActor(fixture.companyId, fixture.adminId)); + const memberApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId)); + + await request(adminApp).get(`/api/issues/${fixture.privateIssueId}`).expect(404); + await request(memberApp).get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`).expect(404); + + const accessed = await request(adminApp) + .get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`); + expect(accessed.status, JSON.stringify(accessed.body)).toBe(200); + expect(accessed.body.id).toBe(fixture.privateIssueId); + + const auditRows = await db.select().from(activityLog); + expect(auditRows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + action: "issue.break_glass_read", + actorType: "user", + actorId: fixture.adminId, + entityType: "issue", + entityId: fixture.privateIssueId, + }), + ])); + const notices = await db.select().from(issueComments); + expect(notices).toEqual([ + expect.objectContaining({ + issueId: fixture.privateIssueId, + authorType: "system", + presentation: expect.objectContaining({ + kind: "system_notice", + title: "Break-glass access used", + tone: "warning", + }), + }), + ]); + }, 20_000); + + it("auto-grants child-subtree access on assignment and keeps it sticky until revoke", async () => { + const fixture = await seedFixture(); + const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId)); + + const assigned = await request(ownerApp) + .patch(`/api/issues/${fixture.privateChild.id}`) + .send({ assigneeAgentId: fixture.assignedAgent.id }); + expect(assigned.status, JSON.stringify(assigned.body)).toBe(200); + + await request(ownerApp) + .patch(`/api/issues/${fixture.privateChild.id}`) + .send({ assigneeAgentId: fixture.assignedAgent.id }) + .expect(200); + await request(ownerApp) + .patch(`/api/issues/${fixture.privateChild.id}`) + .send({ assigneeAgentId: null }) + .expect(200); + const unassignedIssue = await db.select().from(issues).then((rows) => rows.find((row) => row.id === fixture.privateChild.id)); + expect(unassignedIssue?.assigneeAgentId).toBeNull(); + + const grants = await db.select().from(issueAccessGrants); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + issueId: fixture.privateChild.id, + subjectType: "agent", + subjectId: fixture.assignedAgent.id, + source: "assignment", + grantedByUserId: fixture.ownerId, + revokedAt: null, + }); + + const readableBeforeRevoke = await request(await createApp( + db, + agentActor(fixture.companyId, fixture.assignedAgent.id), + )).get(`/api/issues/${fixture.privateChild.id}`); + expect(readableBeforeRevoke.status).toBe(200); + + await request(ownerApp) + .post(`/api/issues/${fixture.privateChild.id}/access-grants/${grants[0]!.id}/revoke`) + .send({}) + .expect(200); + const deniedAfterRevoke = await request(await createApp( + db, + agentActor(fixture.companyId, fixture.assignedAgent.id), + )).get(`/api/issues/${fixture.privateChild.id}`); + expect(deniedAfterRevoke.status).toBe(404); + expect(deniedAfterRevoke.body.error).toBe("Issue not found"); + }, 20_000); + + it("creates and enriches explicit grants idempotently while rejecting unauthorized and open shares", async () => { + const fixture = await seedFixture(); + const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId)); + const nonreaderApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId)); + + await request(nonreaderApp) + .post(`/api/issues/${fixture.privateIssueId}/access-grants`) + .send({ subjectType: "agent", subjectId: fixture.sharedAgent.id }) + .expect(404); + await request(nonreaderApp) + .patch(`/api/issues/${fixture.privateIssueId}`) + .send({ assigneeAgentId: fixture.assignedAgent.id }) + .expect(404); + const openShare = await request(ownerApp) + .post(`/api/issues/${fixture.openIssue.id}/access-grants`) + .send({ subjectType: "agent", subjectId: fixture.sharedAgent.id }); + expect(openShare.status).toBe(409); + expect(openShare.body.error).toContain("Open issues"); + + const first = await request(ownerApp) + .post(`/api/issues/${fixture.privateIssueId}/access-grants`) + .send({ subjectType: "agent", subjectId: fixture.sharedAgent.id }); + expect(first.status, JSON.stringify(first.body)).toBe(201); + expect(first.body).toMatchObject({ + issueId: fixture.privateIssueId, + source: "explicit", + subjectDisplayName: "Private Helper", + subjectInitials: "PH", + subjectAvatarUrl: null, + agentVisibility: "private", + }); + const duplicate = await request(ownerApp) + .post(`/api/issues/${fixture.privateIssueId}/access-grants`) + .send({ subjectType: "agent", subjectId: fixture.sharedAgent.id }); + expect(duplicate.status).toBe(200); + expect(duplicate.body.id).toBe(first.body.id); + + await request(ownerApp) + .post(`/api/issues/${fixture.privateIssueId}/access-grants`) + .send({ subjectType: "user", subjectId: fixture.sharedUserId }) + .expect(201); + const listed = await request(ownerApp).get(`/api/issues/${fixture.privateIssueId}/access-grants`); + expect(listed.status, JSON.stringify(listed.body)).toBe(200); + expect(listed.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + subjectId: fixture.sharedUserId, + subjectDisplayName: "Uma User", + subjectAvatarUrl: "https://example.test/uma.png", + subjectInitials: "UU", + agentVisibility: null, + }), + ])); + expect(await db.select().from(issueAccessGrants)).toHaveLength(2); + const createdActivities = await db.select().from(activityLog); + expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2); + }, 20_000); + + it("keeps private project metadata hidden from user and agent issue grantees", async () => { + const fixture = await seedFixture(); + await db.insert(issueAccessGrants).values([ + { + issueId: fixture.privateIssueId, + subjectType: "user", + subjectId: fixture.sharedUserId, + source: "explicit", + grantedByUserId: fixture.ownerId, + }, + { + issueId: fixture.privateIssueId, + subjectType: "agent", + subjectId: fixture.sharedAgent.id, + source: "explicit", + grantedByUserId: fixture.ownerId, + }, + ]); + + const granteeApps = [ + await createApp(db, boardActor(fixture.companyId, fixture.sharedUserId)), + await createApp(db, agentActor(fixture.companyId, fixture.sharedAgent.id)), + ]; + + for (const app of granteeApps) { + const detail = await request(app).get(`/api/issues/${fixture.privateIssueId}`); + expect(detail.status, JSON.stringify(detail.body)).toBe(200); + expect(detail.body.projectId).toBe(fixture.privateProject.id); + expect(detail.body.project).toBeNull(); + + const heartbeatContext = await request(app) + .get(`/api/issues/${fixture.privateIssueId}/heartbeat-context`); + expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200); + expect(heartbeatContext.body.issue.projectId).toBe(fixture.privateProject.id); + expect(heartbeatContext.body.project).toBeNull(); + + await request(app).get(`/api/projects/${fixture.privateProject.id}`).expect(404); + + const projectList = await request(app) + .get(`/api/companies/${fixture.companyId}/projects`); + expect(projectList.status, JSON.stringify(projectList.body)).toBe(200); + expect(projectList.body.map((project: { id: string }) => project.id)) + .not.toContain(fixture.privateProject.id); + + const projectSearch = await request(app) + .get(`/api/companies/${fixture.companyId}/search?q=Secret%20launch&scope=projects`); + expect(projectSearch.status, JSON.stringify(projectSearch.body)).toBe(200); + expect(projectSearch.body.results.map((project: { id: string }) => project.id)) + .not.toContain(fixture.privateProject.id); + } + }, 20_000); + + it("returns exact locked stubs only through visible blocker and mention edges", async () => { + const fixture = await seedFixture(); + const visibleIssue = await db.insert(issues).values({ + companyId: fixture.companyId, + identifier: "PRV-3", + title: "Visible coordination issue", + description: "Depends on PRV-1", + status: "blocked", + priority: "medium", + createdByUserId: fixture.memberId, + }).returning().then((rows) => rows[0]!); + await db.insert(issueRelations).values({ + companyId: fixture.companyId, + issueId: fixture.privateIssueId, + relatedIssueId: visibleIssue.id, + type: "blocks", + }); + await db.insert(issueReferenceMentions).values({ + companyId: fixture.companyId, + sourceIssueId: visibleIssue.id, + targetIssueId: fixture.privateIssueId, + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + matchedText: "PRV-1", + }); + + const app = await createApp(db, boardActor(fixture.companyId, fixture.memberId)); + const detail = await request(app).get(`/api/issues/${visibleIssue.id}`); + expect(detail.status, JSON.stringify(detail.body)).toBe(200); + const expectedStub = { id: fixture.privateIssueId, identifier: "PRV-1", locked: true }; + expect(detail.body.blockedBy).toEqual([expectedStub]); + expect(detail.body.relatedWork.outbound[0].issue).toEqual(expectedStub); + expect(JSON.stringify(detail.body)).not.toContain("Confidential root"); + + const direct = await request(app).get(`/api/issues/${fixture.privateIssueId}`); + expect(direct.status).toBe(404); + + const list = await request(app) + .get(`/api/companies/${fixture.companyId}/issues?includeBlockedBy=true`); + expect(list.status, JSON.stringify(list.body)).toBe(200); + expect(list.body.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId); + const visibleRow = list.body.find((row: { id: string }) => row.id === visibleIssue.id); + expect(visibleRow.blockedBy).toEqual([expectedStub]); + + const search = await request(app) + .get(`/api/companies/${fixture.companyId}/search?q=Confidential%20root&scope=issues`); + expect(search.status).toBe(200); + expect(search.body.results.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId); + }, 20_000); +}); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 76594b855da..38062852173 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -19,6 +19,9 @@ const mockIssueService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(async ({ action }: { action: string }) => ({ + action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable", + })), canUser: vi.fn(async () => false), hasPermission: vi.fn(async () => false), })); @@ -136,7 +139,17 @@ function registerModuleMocks() { })); } -async function createApp(db: unknown = {}) { +function createEmptyRelationReadDb() { + const query = { + from: vi.fn().mockReturnThis(), + where: vi.fn(async () => []), + }; + return { + select: vi.fn().mockReturnValue(query), + }; +} + +async function createApp(db: unknown = createEmptyRelationReadDb()) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 004ed36b8c9..4452045d774 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -471,14 +471,16 @@ describe("agent issue mutation checkout ownership", () => { input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read", + input.action === "company_scope:read" || + input.action === "project:read", action: input.action, reason: input.action === "tasks:assign" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read" + input.action === "company_scope:read" || + input.action === "project:read" ? "allow_explicit_grant" : "deny_missing_grant", explanation: @@ -486,7 +488,8 @@ describe("agent issue mutation checkout ownership", () => { input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read" + input.action === "company_scope:read" || + input.action === "project:read" ? "Allowed by test default." : "Missing permission.", })); @@ -880,7 +883,7 @@ describe("agent issue mutation checkout ownership", () => { it("allows mentioned peer agents to post comments without ownership of an active checkout", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ - allowed: input.action === "issue:comment", + allowed: input.action === "issue:comment" || input.action === "issue:read", action: input.action, reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : "deny_missing_grant", explanation: @@ -963,8 +966,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); }); @@ -979,8 +982,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/interactions`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled(); }); @@ -1025,8 +1028,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments/comment-1`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueService.getComment).not.toHaveBeenCalled(); }); @@ -1034,7 +1037,7 @@ describe("agent issue mutation checkout ownership", () => { it("allows visible issue field updates for peer agents", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId })); mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ - allowed: input.action === "issue:comment" || input.action === "issue:mutate", + allowed: input.action === "issue:comment" || input.action === "issue:mutate" || input.action === "issue:read", action: input.action, reason: input.action === "issue:comment" @@ -1333,6 +1336,9 @@ describe("agent issue mutation checkout ownership", () => { }); it("defaults agent-created root follow-up issues to inherit the current run workspace", async () => { + mockProjectService.getById.mockResolvedValue({ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", companyId, visibility: "open", + } as never); const app = await createApp( ownerActor(), createRunContextDb({ @@ -1633,7 +1639,7 @@ describe("agent issue mutation checkout ownership", () => { it("allows agents with the active-checkout management grant to mutate active checkouts", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ - allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts", + allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" || input.action === "issue:read", action: input.action, reason: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" @@ -2008,7 +2014,9 @@ describe("agent issue mutation checkout ownership", () => { }); it.each(["agent", "viewer"])("rejects %s export-only retries before admission", async kind => { - mockAccessService.decide.mockResolvedValue({ allowed: false, explanation: "No runtime access" }); + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action === "issue:read", explanation: "The viewer can read but has no runtime access", + })); const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor())) .post(`/api/issues/${issueId}/recovery-actions/retry-workspace-export`) .send({ actionId: recoveryActionId, runId: ownerRunId, repairNote: "Restored provider connectivity and preserved all saved files." }); @@ -2575,11 +2583,14 @@ describe("agent issue mutation checkout ownership", () => { explanation: "Target agent requires approval before task assignment.", })); decide.mockImplementation(async (input: { action: string }) => ({ - allowed: input.action === "issue:mutate", + allowed: input.action === "issue:read" || input.action === "issue:mutate", action: input.action, - reason: input.action === "issue:mutate" ? "allow_self" : "deny_policy_restricted", + reason: + input.action === "issue:read" || input.action === "issue:mutate" + ? "allow_self" + : "deny_policy_restricted", explanation: - input.action === "issue:mutate" + input.action === "issue:read" || input.action === "issue:mutate" ? "Allowed because the actor owns the assigned issue." : "Target agent requires approval before task assignment.", })); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index d3bb4380016..1415722b2fb 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -724,7 +724,8 @@ describe("issue attachment routes", () => { const app = await createApp(storage); const res = await request(app).get("/api/attachments/attachment-1/content"); - expect(res.status).toBe(403); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(storage.getObject).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 4d405a71b13..22dfa841b5d 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -29,6 +29,12 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(async (input: { action: string }) => ({ + allowed: input.action === "issue:read" || input.action === "project:read", + action: input.action, + reason: "allow_test_read", + explanation: "The fixture actor can read the task.", + })), hasPermission: vi.fn(), })); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index e4ef7fb9746..03dc648fc1e 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -12,6 +12,9 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(async ({ action }: { action: string }) => ({ + action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable", + })), canUser: vi.fn(), hasPermission: vi.fn(), })); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index d0cc06b0f2d..0de9d782c8c 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -821,7 +821,7 @@ describe("issue comment reopen routes", () => { }); mockAccessService.decide.mockImplementation( async (input: { action?: string }) => { - const allowed = input.action === "issue:comment"; + const allowed = input.action === "issue:read" || input.action === "issue:comment"; return { allowed, action: input.action, @@ -869,7 +869,7 @@ describe("issue comment reopen routes", () => { }); mockAccessService.decide.mockImplementation( async (input: { action?: string }) => { - const allowed = input.action === "issue:comment"; + const allowed = input.action === "issue:read" || input.action === "issue:comment"; return { allowed, action: input.action, diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index be388307da3..769de2b1a57 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -39,6 +39,12 @@ vi.mock("../services/index.js", () => ({ }), accessService: () => ({ canUser: vi.fn(), + decide: vi.fn(async (input: { action: string }) => ({ + allowed: input.action === "issue:read" || input.action === "project:read", + action: input.action, + reason: "allow_test_read", + explanation: "The fixture actor can read the task.", + })), hasPermission: vi.fn(), }), agentService: () => ({ diff --git a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts index bfb9c8c6092..887b4822663 100644 --- a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts +++ b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts @@ -4,6 +4,8 @@ import request from "supertest"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + authUsers, + companyMemberships, activityLog, agents, companies, @@ -47,7 +49,9 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => { await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(companies); + await db.delete(authUsers); }); afterAll(async () => { @@ -78,6 +82,8 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => { issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); + await db.insert(authUsers).values({ id: "board-user", name: "Board", email: "board@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing(); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "admin" }); await db.insert(agents).values({ id: agentId, companyId, diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts index 503e872a596..7931bb768ec 100644 --- a/server/src/__tests__/issue-tree-control-routes.test.ts +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -28,10 +28,12 @@ const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(), wakeup: vi.fn(), })); +const mockAccessService = vi.hoisted(() => ({ decide: vi.fn() })); const mockHeartbeatFactory = vi.hoisted(() => vi.fn()); vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, heartbeatService: mockHeartbeatFactory, issueService: () => mockIssueService, issueTreeControlService: () => mockTreeControlService, @@ -87,6 +89,25 @@ describe("issue tree control routes", () => { }); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockHeartbeatService.wakeup.mockResolvedValue(null); + mockAccessService.decide.mockResolvedValue({ allowed: true }); + }); + + it("conceals private roots from non-members before exposing tree state", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_issue_private" }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview") + .send({ mode: "pause" }); + + expect(res.status).toBe(404); + expect(mockTreeControlService.preview).not.toHaveBeenCalled(); }); it.each([false, true])( diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0c152f5cb1d..a71cc5b3b2d 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -18,6 +18,7 @@ import { issueComments, issueInboxArchives, issueDocuments, + issueAccessGrants, issuePlanDecompositions, issueReadStates, issueRelations, @@ -25,6 +26,7 @@ import { issueWorkProducts, issues, projectWorkspaces, + projectAccessMembers, projects, workspaceOperations, toolApplications, @@ -3280,6 +3282,121 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(child.responsibleUserId).toBe(responsibleUserId); }); + it("creates children under a private issue in the same private subtree", async () => { + const companyId = randomUUID(); + const parentIssueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Private subtree company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(issues).values({ + id: parentIssueId, + companyId, + title: "Private root", + visibility: "private", + privacyRootIssueId: parentIssueId, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + title: "Inherited private child", + visibility: "open", + }); + + expect(child.visibility).toBe("private"); + expect(child.privacyRootIssueId).toBe(parentIssueId); + }); + + it("creates one personal private project and parks private tasks there", async () => { + const companyId = randomUUID(); + const responsibleUserId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Personal private tasks company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + + const [first, second] = await Promise.all([ + svc.create(companyId, { + title: "First personal private task", + visibility: "private", + createdByUserId: responsibleUserId, + }), + svc.create(companyId, { + title: "Second personal private task", + visibility: "private", + createdByUserId: responsibleUserId, + }), + ]); + + const personalProjects = await db.select().from(projects) + .where(eq(projects.personalOwnerUserId, responsibleUserId)); + expect(personalProjects).toHaveLength(1); + expect(personalProjects[0]).toMatchObject({ + companyId, + name: "My private tasks", + visibility: "private", + }); + expect(first.projectId).toBe(personalProjects[0]!.id); + expect(second.projectId).toBe(personalProjects[0]!.id); + expect(first.privacyRootIssueId).toBe(first.id); + expect(second.privacyRootIssueId).toBe(second.id); + await expect(db.select().from(projectAccessMembers) + .where(eq(projectAccessMembers.projectId, personalProjects[0]!.id))) + .resolves.toEqual([ + expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }), + ]); + }); + + it("keeps a responsible human and grants an agent access to its explicitly private task", async () => { + const companyId = randomUUID(); + const responsibleUserId = randomUUID(); + const creatorAgentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Agent private task company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(agents).values({ + id: creatorAgentId, + companyId, + name: "Private task creator", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const issue = await svc.create(companyId, { + title: "Agent email scratch task", + visibility: "private", + createdByAgentId: creatorAgentId, + actorResponsibleUserId: responsibleUserId, + }); + + expect(issue.responsibleUserId).toBe(responsibleUserId); + const personalProject = await db.select().from(projects) + .where(eq(projects.id, issue.projectId!)) + .then((rows) => rows[0]!); + expect(personalProject.personalOwnerUserId).toBe(responsibleUserId); + await expect(db.select().from(projectAccessMembers) + .where(eq(projectAccessMembers.projectId, personalProject.id))) + .resolves.toEqual([ + expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }), + ]); + await expect(db.select().from(issueAccessGrants) + .where(eq(issueAccessGrants.issueId, issue.id))) + .resolves.toEqual([ + expect.objectContaining({ + subjectType: "agent", + subjectId: creatorAgentId, + source: "explicit", + }), + ]); + }); + it("only honors explicit responsibleUserId for trusted issue create callers", async () => { const companyId = randomUUID(); const creatorUserId = randomUUID(); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 3b13d32de8d..bafcdfce5ac 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -1368,10 +1368,8 @@ describeEmbeddedPostgres( expect( unmentionedComment.status, JSON.stringify(unmentionedComment.body), - ).toBe(403); - expect(unmentionedComment.body.details.code).toBe( - "issue_write_actor_class_excluded", - ); + ).toBe(404); + expect(unmentionedComment.body).toEqual({ error: "Issue not found" }); }); it("propagates denied low-trust policy conflicts on control-plane guards", async () => { @@ -1738,7 +1736,7 @@ describeEmbeddedPostgres( const before = await snapshot(db); const res = await attempt.req(); expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe( - 403, + ["LT approvals", "LT-15/16", "LT-19", "LT-26 child", "LT-26 child with unauthorized assignee", "LT-26 company issue", "LT-26 interaction", "LT-06 resume", "LT-06 blocker mutation"].includes(attempt.id) ? 403 : 404, ); expectNoCanary(res.body, ...forbiddenMarkers); const after = await snapshot(db); @@ -1881,7 +1879,7 @@ describeEmbeddedPostgres( for (const attempt of attempts) { const res = await attempt.req(); expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe( - 403, + 404, ); expectNoCanary(res.body, ...forbiddenMarkers); } diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index ac83f76c558..e20f4895274 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -244,6 +244,19 @@ function loadSpecRoutes() { } describe("openapi routes", () => { + it("documents manager-only task privacy hints without protected scope identity", () => { + const spec = buildOpenApiSpec() as any; + const operation = spec.paths["/api/issues/{id}/privacy-constraints"].get; + const schema = operation.responses["200"].content["application/json"].schema; + expect(schema.properties).toEqual({ + publicBlockedBy: { type: "string", enum: ["parent", "project"], nullable: true }, + leavesPersonalProject: { type: "boolean" }, + }); + expect(schema.required).toEqual(["publicBlockedBy", "leavesPersonalProject"]); + expect(operation.responses["403"]).toBeDefined(); + expect(operation.responses["404"]).toBeDefined(); + }); + it("documents strict run-attributed feedback without a read endpoint", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/companies/{companyId}/agent-commentary"]; diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index b54fe6e31da..2554586da91 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { and, eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { activityLog, agentWakeupRequests, @@ -14,11 +14,13 @@ import { companyMemberships, costEvents, createDb, + documents, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issueAttachments, issueComments, + issueDocuments, issueRelations, issueThreadInteractions, issues, @@ -215,14 +217,6 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { const { companyId, agentId } = await seedCompanyAndAgent(); const blockerIssueId = randomUUID(); const originRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: originRunId, - companyId, - agentId, - status: "running", - invocationSource: "assignment", - contextSnapshot: { issueId: blockerIssueId }, - }); await db.insert(issues).values({ id: blockerIssueId, companyId, @@ -231,6 +225,15 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { priority: "medium", identifier: `${issuePrefix(companyId)}-blocker`, }); + await db.insert(heartbeatRuns).values({ + id: originRunId, + companyId, + agentId, + status: "running", + invocationSource: "assignment", + contextSnapshot: { issueId: blockerIssueId }, + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); const issue = await services.issues.create({ @@ -1284,4 +1287,215 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }), ).rejects.toThrow("over the"); }); + + // PAP-16091 / PAP-16050: a plugin is a non-member principal. Every issue-read + // path the host exposes (which a Slack notifier/digest plugin would fan out + // to shared channels) must apply the canonical visibility predicate so private + // issue content and relationship metadata never reach non-members. + describe("non-member plugin issue-read privacy (PAP-16091)", () => { + let previousMode: string | undefined; + beforeEach(() => { + previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; +}); + afterEach(() => { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + }); + + async function seedOpenAndPrivate() { + const { companyId, agentId } = await seedCompanyAndAgent(); + const openIssueId = randomUUID(); + const privateIssueId = randomUUID(); + await db.insert(issues).values([ + { id: openIssueId, companyId, title: "Public status", status: "todo", priority: "medium", visibility: "open" }, + { id: privateIssueId, companyId, title: "Confidential HR matter", status: "todo", priority: "medium", visibility: "private" }, + ]); + return { companyId, agentId, openIssueId, privateIssueId }; + } + + it("omits private issues from issues.list", async () => { + const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const listed = await services.issues.list({ companyId }); + const ids = listed.map((issue) => issue.id); + expect(ids).toContain(openIssueId); + expect(ids).not.toContain(privateIssueId); + }); + + it("returns null from issues.get for a private issue", async () => { + const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toBeNull(); + // Sanity: the same path still returns readable (open) issues. + await expect(services.issues.get({ issueId: openIssueId, companyId })).resolves.toMatchObject({ id: openIssueId }); + }); + + it("returns no comments from issues.listComments for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + await db.insert(issueComments).values({ + id: randomUUID(), companyId, issueId: privateIssueId, body: "secret decision rationale", + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.listComments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); + }); + + it("returns no attachments from issues.listAttachments for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const assetId = randomUUID(); + await db.insert(assets).values({ + id: assetId, companyId, provider: "local_disk", objectKey: "secret", contentType: "image/png", byteSize: 10, sha256: "z", + }); + await db.insert(issueAttachments).values({ id: randomUUID(), companyId, issueId: privateIssueId, assetId }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.listAttachments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); + }); + + it("returns null from issues.getAttachmentContent for a private issue's attachment", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const assetId = randomUUID(); + await db.insert(assets).values({ + id: assetId, companyId, provider: "local_disk", objectKey: "secret2", contentType: "text/plain", byteSize: 4, sha256: "y", + }); + const attachmentId = randomUUID(); + await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: privateIssueId, assetId }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }), + ).resolves.toBeNull(); + }); + + it("excludes a private descendant and redacts private blocker edges in getOrchestrationSummary", async () => { + const { companyId } = await seedCompanyAndAgent(); + const rootId = randomUUID(); + const publicChildId = randomUUID(); + const privateChildId = randomUUID(); + const privateBlockerId = randomUUID(); + await db.insert(issues).values([ + { id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" }, + { id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" }, + { id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" }, + { id: privateBlockerId, companyId, title: "Private blocker", status: "todo", priority: "medium", visibility: "private" }, + ]); + // privateBlocker blocks the public root -> root.blockedBy includes it pre-redaction. + await db.insert(issueRelations).values({ + companyId, issueId: privateBlockerId, relatedIssueId: rootId, type: "blocks", + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const summary = await services.issues.getOrchestrationSummary({ companyId, issueId: rootId, includeSubtree: true }); + + // Private descendant never enters the subtree. + expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootId, publicChildId])); + // No relation edge anywhere references the private blocker or private child. + const edgeIds = new Set(); + for (const relation of Object.values(summary.relations)) { + for (const edge of [...relation.blockedBy, ...relation.blocks]) edgeIds.add(edge.id); + } + expect(edgeIds.has(privateBlockerId)).toBe(false); + expect(edgeIds.has(privateChildId)).toBe(false); + }); + + it("reports a private root issue as not-found from getOrchestrationSummary", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getOrchestrationSummary({ companyId, issueId: privateIssueId, includeSubtree: true }), + ).rejects.toThrow("Issue not found"); + }); + + async function seedIssueDocument(companyId: string, issueId: string, key: string, body: string) { + const documentId = randomUUID(); + await db.insert(documents).values({ id: documentId, companyId, latestBody: body, format: "markdown" }); + await db.insert(issueDocuments).values({ id: randomUUID(), companyId, issueId, documentId, key }); + return documentId; + } + + it("throws not-found from issues.getRelations for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getRelations({ issueId: privateIssueId, companyId }), + ).rejects.toThrow("Issue not found"); + }); + + it("redacts a private blocker edge from issues.getRelations on a public issue", async () => { + const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); + // privateIssue blocks the open issue -> open.blockedBy would name it pre-redaction. + await db.insert(issueRelations).values({ + companyId, issueId: privateIssueId, relatedIssueId: openIssueId, type: "blocks", + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const relations = await services.issues.getRelations({ issueId: openIssueId, companyId }); + const edgeIds = [...relations.blockedBy, ...relations.blocks].map((edge) => edge.id); + expect(edgeIds).not.toContain(privateIssueId); + }); + + it("reports a private root issue as not-found from issues.getSubtree", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getSubtree({ issueId: privateIssueId, companyId }), + ).rejects.toThrow("Issue not found"); + }); + + it("excludes a private child and its documents from issues.getSubtree on an open root", async () => { + const { companyId } = await seedCompanyAndAgent(); + const rootId = randomUUID(); + const publicChildId = randomUUID(); + const privateChildId = randomUUID(); + await db.insert(issues).values([ + { id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" }, + { id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" }, + { id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" }, + ]); + // A document on the private child would surface in the subtree summary pre-fix. + await seedIssueDocument(companyId, privateChildId, "plan", "confidential subtree plan"); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const subtree = await services.issues.getSubtree({ + issueId: rootId, companyId, includeDocuments: true, includeRelations: true, + }); + expect(new Set(subtree.issueIds)).toEqual(new Set([rootId, publicChildId])); + expect(subtree.issues.map((issue) => issue.id)).not.toContain(privateChildId); + expect(Object.keys(subtree.documents ?? {})).not.toContain(privateChildId); + }); + + it("returns no interactions from issues.listInteractions for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), companyId, issueId: privateIssueId, kind: "request_confirmation", + payload: { version: 1, prompt: "Confirm confidential action?" } as any, + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.listInteractions({ issueId: privateIssueId, companyId })).resolves.toEqual([]); + }); + + it("throws not-found from issueDocuments.list for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body"); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issueDocuments.list({ issueId: privateIssueId, companyId }), + ).rejects.toThrow("Issue not found"); + }); + + it("throws not-found from issueDocuments.get for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body"); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issueDocuments.get({ issueId: privateIssueId, companyId, key: "plan" }), + ).rejects.toThrow("Issue not found"); + }); + + it("is pass-through when privacy mode is off (enforce flip is the single switch)", async () => { + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off"; + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const ids = (await services.issues.list({ companyId })).map((issue) => issue.id); + expect(ids).toContain(privateIssueId); + await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toMatchObject({ id: privateIssueId }); + }); + }); }); diff --git a/server/src/__tests__/privacy-production-review.test.ts b/server/src/__tests__/privacy-production-review.test.ts new file mode 100644 index 00000000000..1e0b37396ca --- /dev/null +++ b/server/src/__tests__/privacy-production-review.test.ts @@ -0,0 +1,487 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { Readable } from "node:stream"; +import { once } from "node:events"; +import express from "express"; +import request from "supertest"; +import WebSocket from "ws"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { workspaceOperations, agents, approvals, assets, chatEndpoints, chatExternalPrincipals, chatConversations, chatIdentityLinks, chatDeliveries, toolApplications, toolConnections, executionWorkspaces, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, projectAccessMembers, projects, startEmbeddedPostgresTestDatabase } from "@paperclipai/db"; +import { authorizationService, canPublishIssueToChatAudience, canActorReadIssuePrivacy, issueReadSqlCondition, canActorReadProjectPrivacy, projectReadSqlCondition, canActorReadApproval, approvalReadSqlCondition, canActorReadExecutionWorkspace, type AuthorizationActor } from "../services/authorization.js"; + +import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js"; + +// Production regressions and the approved downward-only sharing contract. +describe("private task production review", () => { + let tempDb: Awaited>; + let db: ReturnType; + beforeAll(async () => { + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-review-"); + db = createDb(tempDb.connectionString); + // Load the current route dependency graph during setup, outside assertion timeouts. + await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]); + }, 120_000); + afterAll(async () => { await tempDb?.cleanup(); }); + + async function fixture() { + const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${randomUUID().slice(0, 5)}` }).returning(); + const owner = randomUUID(), outsider = randomUUID(); + await db.insert(authUsers).values([owner, outsider].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() }))); + await db.insert(companyMemberships).values([owner, outsider].map(principalId => ({ companyId: company.id, principalType: "user", principalId, status: "active", membershipRole: "operator" }))); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Shared agent", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: {} }).returning(); + const actor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session", companyIds: [company.id] }); + return { company, owner, outsider, agent, actor }; + } + + it("lets only the project privacy owner or an admin change its audience", async () => { + const f = await fixture(); + const { projectRoutes } = await import("../routes/projects.js"); + const { errorHandler } = await import("../middleware/index.js"); + let actor = f.actor(f.owner); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", projectRoutes(db)); + app.use(errorHandler); + const created = await request(app).post(`/api/companies/${f.company.id}/projects`).send({ name: "Private project", visibility: "private", privacyOwnerUserId: f.outsider }); + expect(created.status).toBe(201); + expect(created.body.privacyOwnerUserId).toBe(f.owner); + const projectId = created.body.id; + const member = await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "user", subjectId: f.outsider }); + expect(member.status).toBe(201); + actor = f.actor(f.outsider); + expect((await request(app).get(`/api/projects/${projectId}`)).status).toBe(200); + expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(403); + expect((await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "agent", subjectId: f.agent.id })).status).toBe(403); + expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(403); + actor = f.actor(f.owner); + expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(200); + expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(200); + await db.update(companyMemberships).set({ membershipRole: "admin" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.outsider))); + actor = f.actor(f.outsider); + expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "private" })).status).toBe(200); + }); + + it("publishes private chat content only to a currently authorized linked DM recipient", async () => { + const f = await fixture(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private chat", visibility: "private", responsibleUserId: f.owner }).returning(); + const [application] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Test chat", type: "native" }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: application.id, name: "Test chat", uid: "test-chat", connectionPurpose: "channel", transport: "chat_sdk" }).returning(); + const [endpoint] = await db.insert(chatEndpoints).values({ companyId: f.company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: f.agent.id }).returning(); + const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: f.company.id, provider: "slack", providerAccountId: "test", externalId: "person", kind: "user" }).returning(); + const [conversation] = await db.insert(chatConversations).values({ companyId: f.company.id, endpointId: endpoint.id, issueId: task.id, externalConversationId: "dm", externalLabel: "Test DM", isDirectMessage: true }).returning(); + const publication = { companyId: f.company.id, issueId: task.id, endpointId: endpoint.id, conversationId: conversation.id }; + expect(await canPublishIssueToChatAudience(db, publication)).toBe(false); + await db.insert(chatDeliveries).values({ companyId: f.company.id, endpointId: endpoint.id, conversationId: conversation.id, principalId: principal.id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "message", normalizedEvent: {} as any }); + const [link] = await db.insert(chatIdentityLinks).values({ companyId: f.company.id, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: f.outsider, status: "linked" }).returning(); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(false); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning(); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(true); + await db.update(chatConversations).set({ isDirectMessage: false }).where(eq(chatConversations.id, conversation.id)); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(false); + await db.update(chatConversations).set({ isDirectMessage: true }).where(eq(chatConversations.id, conversation.id)); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(false); + await db.update(chatIdentityLinks).set({ paperclipUserId: f.owner }).where(eq(chatIdentityLinks.id, link.id)); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(true); + await db.update(chatIdentityLinks).set({ revokedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id)); + expect(await canPublishIssueToChatAudience(db, publication)).toBe(false); + }); + + it("a shared agent cannot read an owner's private task on behalf of an unauthorized coworker", async () => { + const f = await fixture(); + const id = randomUUID(); + const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, assigneeAgentId: f.agent.id }).returning(); + const authz = authorizationService(db); + const resource = { type: "issue" as const, companyId: f.company.id, issueId: id }; + expect((await authz.decide({ actor: f.actor(f.outsider), action: "issue:read", resource })).allowed).toBe(false); + expect((await authz.decide({ actor: { type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_jwt", onBehalfOfUserId: f.outsider }, action: "issue:read", resource })).allowed).toBe(false); + }); + + it("list SQL and direct checks agree after a project becomes open", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Previously private", visibility: "open" }).returning(); + await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider }); + const id = randomUUID(); + const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Still individually private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning(); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), issue)).toBe(false); + const found = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, id), await issueReadSqlCondition(db, f.actor(f.outsider)))); + expect(found).toEqual([]); + }); + + it("making a parent private also protects existing descendants", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner }); + const child = await svc.create(f.company.id, { title: "Existing child", parentId: parent.id, createdByUserId: f.owner }); + await svc.update(parent.id, { visibility: "private" }); + const updated = await svc.getById(child.id); + expect(updated?.visibility).toBe("private"); + }); + + it("restricting a project protects descendants outside it and preserves privacy when reopened", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const { projectService } = await import("../services/projects.js"); + const svc = issueService(db); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Project", visibility: "open" }).returning(); + const root = await svc.create(f.company.id, { title: "Root", projectId: project.id, createdByUserId: f.owner }); + const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner }); + const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner }); + await projectService(db).update(project.id, { visibility: "private" }); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false); + await projectService(db).update(project.id, { visibility: "open" }); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false); + expect((await svc.getById(child.id))?.visibility).toBe("private"); + }); + + it("publishing a personal task leaves its private project while keeping its children private", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const root = await svc.create(f.company.id, { title: "Personal", visibility: "private", createdByUserId: f.owner }); + const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner }); + const published = await svc.update(root.id, { visibility: "open" }); + expect(published?.projectId).toBeNull(); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), published!)).toBe(true); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false); + }); + + it("reparenting an open issue under a private task inherits privacy", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner, visibility: "private" }); + const child = await svc.create(f.company.id, { title: "Moved child", createdByUserId: f.owner }); + const updated = await svc.update(child.id, { parentId: parent.id }); + expect(updated?.visibility).toBe("private"); + }); + + it("allows task management hints in an unreadable personal project without disclosing its identity", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Protected personal context", visibility: "private", personalOwnerUserId: f.outsider, privacyOwnerUserId: f.outsider }).returning(); + const id = randomUUID(); + const [task] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Owned task", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning(); + const { issueRoutes } = await import("../routes/issues.js"); + const { projectRoutes } = await import("../routes/projects.js"); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); }); + app.use("/api", issueRoutes(db, {} as any)); app.use("/api", projectRoutes(db)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); }); + await request(app).get(`/api/projects/${project.id}`).expect(404); + const response = await request(app).get(`/api/issues/${task.id}/privacy-constraints`).expect(200); + expect(response.body).toEqual({ publicBlockedBy: null, leavesPersonalProject: true }); + const { issueService } = await import("../services/issues.js"); + expect(await issueService(db).update(task.id, { visibility: "open" })).toMatchObject({ visibility: "open", projectId: null }); + }); + + it("reports an inaccessible private parent as a move-first constraint without leaking it", async () => { + const f = await fixture(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values({ id: parentId, companyId: f.company.id, title: "Hidden parent content", visibility: "private", privacyRootIssueId: parentId, responsibleUserId: f.outsider }); + await db.insert(issues).values({ id: childId, companyId: f.company.id, title: "Owned child", visibility: "private", privacyRootIssueId: parentId, privacyParentIssueId: parentId, responsibleUserId: f.owner }); + const { issueRoutes } = await import("../routes/issues.js"); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); }); + app.use("/api", issueRoutes(db, {} as any)); + await request(app).get(`/api/issues/${parentId}`).expect(404); + const response = await request(app).get(`/api/issues/${childId}/privacy-constraints`).expect(200); + expect(response.body).toEqual({ publicBlockedBy: "parent", leavesPersonalProject: false }); + }); + + it("does not expose task management hints to a shared reader or unrelated company", async () => { + const f = await fixture(); const id = randomUUID(); + await db.insert(issues).values({ id, companyId: f.company.id, title: "Private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner }); + const { issueRoutes } = await import("../routes/issues.js"); + let actor = f.actor(f.outsider); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor as Express.Request["actor"]; next(); }); + app.use("/api", issueRoutes(db, {} as any)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); }); + await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404); + await db.insert(issueAccessGrants).values({ issueId: id, subjectType: "user", subjectId: f.outsider, source: "explicit" }); + await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(403); + actor = { ...f.actor(f.owner), companyIds: [] }; + await db.update(companyMemberships).set({ status: "archived" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.owner))); + const denied = await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404); + expect(denied.body.publicBlockedBy).toBeUndefined(); + }); + + it("an unauthorized board user cannot fetch a private issue through PATCH", async () => { + const f = await fixture(); + const id = randomUUID(); + await db.insert(issues).values({ id, companyId: f.company.id, title: "Confidential acquisition", description: "Private email content", status: "backlog", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, createdByUserId: f.owner }); + const { issueRoutes } = await import("../routes/issues.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = f.actor(f.outsider) as Express.Request["actor"]; next(); }); + app.use("/api", issueRoutes(db, {} as any)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); }); + await request(app).get(`/api/issues/${id}`).expect(404); + const response = await request(app).patch(`/api/issues/${id}`).send({ priority: "low" }); + expect({ status: response.status, title: response.body.title }).toEqual({ status: 404, title: undefined }); + }); + + it("sharing a child grants its descendants, never its parent or sibling, and revokes immediately", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner }); + const child = await svc.create(f.company.id, { title: "Shared child", parentId: root.id, createdByUserId: f.owner }); + const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner }); + const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.owner }); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: child.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning(); + for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) { + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), task)).toBe(expected); + const rows = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, task.id), await issueReadSqlCondition(db, f.actor(f.outsider)))); + expect(rows.length > 0).toBe(expected); + } + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false); + }); + + it("binds child-manager grant writes to the child and rejects ancestor or sibling grants", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const { issueRoutes } = await import("../routes/issues.js"); + const { errorHandler } = await import("../middleware/index.js"); + const svc = issueService(db); + const root = await svc.create(f.company.id, { title: "Ancestor", visibility: "private", createdByUserId: f.outsider }); + const child = await svc.create(f.company.id, { title: "Managed child", parentId: root.id, createdByUserId: f.owner }); + const grandchild = await svc.create(f.company.id, { title: "Descendant", parentId: child.id, createdByUserId: f.outsider }); + const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.outsider }); + const [rootGrant] = await db.insert(issueAccessGrants).values({ issueId: root.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning(); + const [siblingGrant] = await db.insert(issueAccessGrants).values({ issueId: sibling.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning(); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = f.actor(f.owner); next(); }); + app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler); + const audience = await request(app).get(`/api/issues/${child.id}/access-grants`).expect(200); + expect(audience.body).toContainEqual(expect.objectContaining({ id: rootGrant.id, inherited: true })); + for (const grant of [rootGrant, siblingGrant]) { + await request(app).post(`/api/issues/${child.id}/access-grants/${grant.id}/revoke`).send({}).expect(404); + const [unchanged] = await db.select().from(issueAccessGrants).where(eq(issueAccessGrants.id, grant.id)); + expect(unchanged.revokedAt).toBeNull(); + } + for (const task of [root, sibling]) { + await request(app).post(`/api/issues/${task.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(403); + } + const added = await request(app).post(`/api/issues/${child.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(201); + expect(added.body.issueId).toBe(child.id); + const agent: AuthorizationActor = { type: "agent", companyId: f.company.id, agentId: f.agent.id }; + for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) { + expect(await canActorReadIssuePrivacy(db, agent, task)).toBe(expected); + } + await request(app).post(`/api/issues/${child.id}/access-grants/${added.body.id}/revoke`).send({}).expect(200); + expect(await canActorReadIssuePrivacy(db, agent, child)).toBe(false); + expect(await canActorReadIssuePrivacy(db, agent, grandchild)).toBe(false); + }); + + it("retains a child assignment grant after unassignment without granting the root", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner }); + const child = await svc.create(f.company.id, { title: "Assigned child", parentId: root.id, createdByUserId: f.owner, assigneeAgentId: f.agent.id }); + await svc.update(child.id, { assigneeAgentId: null }); + const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id }; + expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(true); + expect(await canActorReadIssuePrivacy(db, actor, root)).toBe(false); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, child.id)); + expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(false); + }); + + it("protects standalone tasks created from a private run", async () => { + const f = await fixture(); + const { issueService } = await import("../services/issues.js"); + const svc = issueService(db); + const source = await svc.create(f.company.id, { title: "Private conversation", visibility: "private", createdByUserId: f.owner }); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: source.id }).returning(); + const handoff = await svc.create(f.company.id, { title: "Standalone handoff", createdByUserId: f.owner, originRunId: run.id }); + expect(handoff).toMatchObject({ parentId: null, privacyParentIssueId: source.id, visibility: "private" }); + expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), handoff)).toBe(false); + }); + + it("intersects agent and responsible-user access to private projects in direct and list reads", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Private project", visibility: "private" }).returning(); + await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "agent", subjectId: f.agent.id }); + const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id, onBehalfOfUserId: f.outsider }; + expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(false); + expect(await db.select().from(projects).where(and(eq(projects.id, project.id), await projectReadSqlCondition(db, actor)))).toEqual([]); + await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider }); + expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(true); + }); + + it("binds native runs and keeps missing-task history fail closed", async () => { + const f = await fixture(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning(); + expect(run).toMatchObject({ scopeKind: "issue", issueId: task.id }); + await db.update(heartbeatRuns).set({ contextSnapshot: {}, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id)); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id))).toMatchObject([{ scopeKind: "issue", issueId: task.id }]); + const [orphan] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: randomUUID() } }).returning(); + expect(orphan).toMatchObject({ scopeKind: "issue", issueId: null }); + }); + + it("filters private and orphaned operation excerpts from a shared workspace listing", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Shared" }).returning(); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Shared workspace" }).returning(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, issueId: task.id, phase: "prepare", stdoutExcerpt: "PRIVATE_OPERATION" }); + const [publicOperation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, phase: "prepare", stdoutExcerpt: "PUBLIC_OPERATION" }).returning(); + const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); + const { errorHandler } = await import("../middleware/index.js"); + let actor = f.actor(f.outsider); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", executionWorkspaceRoutes(db)); app.use(errorHandler); + const path = `/api/execution-workspaces/${workspace.id}/workspace-operations`; + const outside = await request(app).get(path); + expect(outside.status).toBe(200); + expect(outside.body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]); + actor = f.actor(f.owner); + expect((await request(app).get(path)).body).toHaveLength(2); + await db.delete(issues).where(eq(issues.id, task.id)); + expect((await request(app).get(path)).body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]); + }); + + it("keeps operation history private even with a company run and after task or run deletion", async () => { + const f = await fixture(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded" }).returning(); + expect(run.scopeKind).toBe("company"); + const [operation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, issueId: task.id, heartbeatRunId: run.id, phase: "prepare", stdoutExcerpt: "PRIVATE_LOG" }).returning(); + const access = authorizationService(db); + expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), operation)).toBe(true); + expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.outsider), operation)).toBe(false); + await db.delete(issues).where(eq(issues.id, task.id)); + const [orphan] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, operation.id)); + expect(orphan).toMatchObject({ issueId: null, stdoutExcerpt: "PRIVATE_LOG", metadata: { _issuePrivacySources: { [task.id]: true } } }); + expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), orphan)).toBe(false); + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)); + const [history] = await db.update(workspaceOperations).set({ metadata: {} }).where(eq(workspaceOperations.id, operation.id)).returning(); + expect(history).toMatchObject({ heartbeatRunId: null, metadata: { _runPrivacySources: { [run.id]: true }, _issuePrivacySources: { [task.id]: true } } }); + expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), history)).toBe(false); + }); + + it("delivers authorized live output, then stops delivery after revocation on the same socket", async () => { + const f = await fixture(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning(); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning(); + const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js"); + const { publishLiveEvent } = await import("../services/live-events.js"); + const server = createServer(); + const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/${f.company.id}/events/ws`); + const messages: string[] = []; + socket.on("message", data => messages.push(data.toString())); + try { + await once(socket, "open"); + const publish = (chunk: string) => publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: task.id, runId: run.id, agentId: f.agent.id, chunk } }); + publish("AUTHORIZED_CANARY"); + await expect.poll(() => messages.some(message => message.includes("AUTHORIZED_CANARY"))).toBe(true); + publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "authorized_activity", entityType: "issue", entityId: task.id } }); + await expect.poll(() => messages.some(message => message.includes("authorized_activity"))).toBe(true); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + publish("REVOKED_CANARY"); + publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "revoked_activity", entityType: "issue", entityId: task.id } }); + publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "queue_drained", entityType: "company", entityId: f.company.id } }); + await expect.poll(() => messages.some(message => message.includes("queue_drained"))).toBe(true); + expect(messages.join(" ")).not.toContain("REVOKED_CANARY"); + expect(messages.join(" ")).not.toContain("revoked_activity"); + } finally { + socket.terminate(); + await new Promise(resolve => (wss as any).close(resolve)); + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it("protects linked approval payloads and execution workspace details", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Public project" }).returning(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, projectId: project.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + const [approval] = await db.insert(approvals).values({ companyId: f.company.id, type: "hire_agent", payload: { sourceIssueId: task.id, instructions: "PRIVATE_PAYLOAD" } }).returning(); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, sourceIssueId: task.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Private workspace" }).returning(); + expect(await canActorReadApproval(db, f.actor(f.owner), approval.id)).toBe(true); + expect(await canActorReadApproval(db, f.actor(f.outsider), approval.id)).toBe(false); + expect(await db.select().from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor(f.outsider))))).toEqual([]); + expect(await canActorReadExecutionWorkspace(db, f.actor(f.owner), workspace.id)).toBe(true); + expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false); + await db.update(executionWorkspaces).set({ sourceIssueId: null, metadata: {} }).where(eq(executionWorkspaces.id, workspace.id)); + expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false); + await db.delete(issues).where(eq(issues.id, task.id)); + expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false); + }); + + it("keeps inline draft images owner-only until publication then follows the task grant", async () => { + const f = await fixture(); + const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/assets/issues/drafts/image.png`, contentType: "image/png", byteSize: 6, sha256: "test", createdByUserId: f.owner }).returning(); + const { assetRoutes } = await import("../routes/assets.js"); + const { issueService } = await import("../services/issues.js"); + function appFor(userId: string) { + const app = express(); + app.use((req, _res, next) => { req.actor = f.actor(userId) as any; next(); }); + app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any)); + return app; + } + const url = `/api/assets/${asset.id}/content`; + await request(appFor(f.owner)).get(url).expect(200); + await request(appFor(f.outsider)).get(url).expect(404); + const task = await issueService(db).create(f.company.id, { title: "Private draft", visibility: "private", createdByUserId: f.owner, description: `![image](${url})` }); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning(); + await request(appFor(f.outsider)).get(url).expect(200); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + await request(appFor(f.outsider)).get(url).expect(404); + await db.delete(issues).where(eq(issues.id, task.id)); + await request(appFor(f.outsider)).get(url).expect(404); + }); + + it("protects attachment assets even after their attachment or task is deleted", async () => { + const f = await fixture(); + const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning(); + const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/issues/${task.id}/file.txt`, contentType: "text/plain", byteSize: 6, sha256: "test" }).returning(); + const { assetRoutes } = await import("../routes/assets.js"); + const appFor = (userId: string) => { + const app = express(); + app.use((req, _res, next) => { req.actor = f.actor(userId) as Express.Request["actor"]; next(); }); + app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any)); + return app; + }; + await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(200, "SECRET"); + await request(appFor(f.outsider)).get(`/api/assets/${asset.id}/content`).expect(404); + await db.delete(issues).where(eq(issues.id, task.id)); + await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(404); + }); + + it("live WebSocket delivery does not disclose a private run to an unauthorized member", async () => { + const f = await fixture(); + const id = randomUUID(); + await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner }); + expect((await authorizationService(db).decide({ actor: f.actor(f.outsider), action: "issue:read", resource: { type: "issue", companyId: f.company.id, issueId: id } })).allowed).toBe(false); + const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js"); + const { publishLiveEvent } = await import("../services/live-events.js"); + const server = createServer(); + const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/companies/${f.company.id}/events/ws`); + try { + await once(socket, "open"); + const message = once(socket, "message"); + publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: id, runId: randomUUID(), agentId: f.agent.id, chunk: "PRIVATE_EMAIL_CANARY" } }); + const result = await Promise.race([message.then(([data]) => data.toString()), new Promise(resolve => setTimeout(() => resolve(""), 150))]); + expect(result).not.toContain("PRIVATE_EMAIL_CANARY"); + } finally { + socket.terminate(); + await new Promise(resolve => (wss as any).close(resolve)); + await new Promise(resolve => server.close(() => resolve())); + } + }); +}); diff --git a/server/src/__tests__/projects-list-archived-routes.test.ts b/server/src/__tests__/projects-list-archived-routes.test.ts index b8201552f79..e36f8ca6146 100644 --- a/server/src/__tests__/projects-list-archived-routes.test.ts +++ b/server/src/__tests__/projects-list-archived-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { companies, createDb, projects } from "@paperclipai/db"; +import { companies, companyMemberships, createDb, projectAccessMembers, projects } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -51,9 +51,10 @@ describeEmbeddedPostgres("project list archived route defaults", () => { beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-projects-list-archived-"); db = createDb(tempDb.connectionString); - }, 20_000); + }, 120_000); afterEach(async () => { + await db.delete(companyMemberships); await db.delete(projects); await db.delete(companies); }); @@ -108,3 +109,74 @@ describeEmbeddedPostgres("project list archived route defaults", () => { expect(res.body.map((project: { id: string }) => project.id)).toEqual([activeProjectId, archivedProjectId]); }); }); + +describeEmbeddedPostgres("private project route visibility", { concurrent: false }, () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-project-routes-"); + db = createDb(tempDb.connectionString); + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + }, 20_000); + + afterEach(async () => { + await db.delete(companyMemberships); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode; + await tempDb?.cleanup(); + }); + + it("omits private projects from non-member lists and returns 404 on direct fetch", async () => { + const companyId = randomUUID(); + const privateProjectId = randomUUID(); + const memberUserId = "project-member"; + await db.insert(companies).values({ + id: companyId, + name: "Private projects", + issuePrefix: `P${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(projects).values([ + { companyId, name: "Open project", visibility: "open" }, + { id: privateProjectId, companyId, name: "Secret project", visibility: "private" }, + ]); + await db.insert(companyMemberships).values([memberUserId, "project-outsider"].map((principalId) => ({ + companyId, + principalType: "user" as const, + principalId, + status: "active", + membershipRole: "admin", + }))); + await db.insert(projectAccessMembers).values({ + companyId, + projectId: privateProjectId, + subjectType: "user", + subjectId: memberUserId, + }); + const actorFor = (userId: string): Express.Request["actor"] => ({ + type: "board", + userId, + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }); + + const outsiderApp = createApp(db, actorFor("project-outsider")); + const outsiderList = await request(outsiderApp).get(`/api/companies/${companyId}/projects`); + expect(outsiderList.status).toBe(200); + expect(outsiderList.body.map((project: { id: string }) => project.id)).not.toContain(privateProjectId); + await request(outsiderApp).get(`/api/projects/${privateProjectId}`).expect(404); + + const memberApp = createApp(db, actorFor(memberUserId)); + const memberList = await request(memberApp).get(`/api/companies/${companyId}/projects`); + expect(memberList.body.map((project: { id: string }) => project.id)).toContain(privateProjectId); + await request(memberApp).get(`/api/projects/${privateProjectId}`).expect(200); + }); +}); diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 5156f08d249..eb4d9d2bb7f 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -6,7 +6,7 @@ import { projectRoutes } from "../routes/projects.js"; import { agentRoutes } from "../routes/agents.js"; import { companySkillRoutes } from "../routes/company-skills.js"; import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; -import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartbeatRuns } from "@paperclipai/db"; +import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartbeatRuns, issueAccessGrants } from "@paperclipai/db"; import { createHmac, createHash, randomBytes, randomUUID } from "node:crypto"; import express, { type Request } from "express"; import request from "supertest"; @@ -657,6 +657,52 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => return { ...f, issue: issue!, call, dispatch, transfers, storage, cleanup: () => rm(storageDir, { recursive: true, force: true }) }; } + async function makePrivate(f: Awaited>) { + const owner = randomUUID(); + await db.insert(authUsers).values({ id: owner, name: "Private owner", email: owner + "@example.com", createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: owner, membershipRole: "member", status: "active" }); + await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issue.id, responsibleUserId: owner, createdByUserId: owner }).where(eq(issues.id, f.issue.id)); + return owner; + } + + it("hides private tasks from assistant search, reads, edits and file-link creation", async () => { + const f = await expandedFixture(); + try { + await makePrivate(f); + expect(await f.call("paperclip_search_tasks")).toMatchObject({ tasks: [] }); + for (const name of ["paperclip_read_task", "paperclip_list_deliverables", "paperclip_list_document_revisions"]) { + await expect(f.call(name, { taskId: f.issue.id, ...(name === "paperclip_list_document_revisions" ? { key: "plan" } : {}) })).rejects.toMatchObject({ status: 404 }); + } + expect(await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes: { title: "Unauthorized edit" } })).toMatchObject({ outcome: "rejected", status: 404 }); + await expect(f.call("paperclip_get_upload_url", { taskId: f.issue.id, requestId: randomUUID(), filename: "data.txt", contentType: "text/plain", byteSize: 2, sha256: createHash("sha256").update("hi").digest("hex") })).rejects.toThrow(); + expect((await db.select().from(issues).where(eq(issues.id, f.issue.id)))[0]?.title).toBe("Work"); + expect(await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.taskId, f.issue.id))).toHaveLength(0); + } finally { await f.cleanup(); } + }); + + it("revokes existing assistant upload and download links when private task sharing is removed", async () => { + const f = await expandedFixture(); + try { + const owner = await makePrivate(f); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.actor.userId!, source: "explicit", grantedByUserId: owner }).returning(); + const bytes = Buffer.from("Private attachment"); + const args = { taskId: f.issue.id, filename: "private.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }; + const app = express(); app.use(f.transfers.router); + const uploadedUrl = new URL((await f.call("paperclip_get_upload_url", { ...args, requestId: randomUUID() })).url as string); + const uploaded = await request(app).put(uploadedUrl.pathname + uploadedUrl.search).set("Content-Type", args.contentType).send(bytes); + expect(uploaded.status).toBe(200); + const download = new URL((await f.call("paperclip_get_download_url", { attachmentId: uploaded.body.attachment.id })).url as string); + const pending = await f.call("paperclip_get_upload_url", { ...args, requestId: randomUUID() }); + const pendingUrl = new URL(pending.url as string); + expect((await request(app).get(download.pathname + download.search)).status).toBe(200); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant!.id)); + expect((await request(app).get(download.pathname + download.search)).status).not.toBe(200); + expect((await request(app).put(pendingUrl.pathname + pendingUrl.search).set("Content-Type", args.contentType).send(bytes)).status).toBe(403); + expect((await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, pending.uploadId as string)))[0]?.attachmentId).toBeNull(); + await expect(f.call("paperclip_get_download_url", { attachmentId: uploaded.body.attachment.id })).rejects.toThrow(); + } finally { await f.cleanup(); } + }); + it("edits, blocks and completes tasks through real routes and rejects review overrides", async () => { const f = await expandedFixture(); try { diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index 285060b754e..f0aef9e6da8 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -7,6 +7,7 @@ import { activityLog, agents, companies, + companyMemberships, costEvents, createDb, documentRevisions, @@ -52,6 +53,7 @@ function unprivilegedBoardActor(companyId: string): Express.Request["actor"] { source: "session", sessionId: "session-1", companyIds: [companyId], + memberships: [{ companyId, membershipRole: "operator", status: "active" }], isInstanceAdmin: false, }; } @@ -93,6 +95,7 @@ describeEmbeddedPostgres("status card routes", () => { await db.delete(heartbeatRuns); await db.delete(instanceSettings); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -322,6 +325,62 @@ describeEmbeddedPostgres("status card routes", () => { expect(list.body[0]).not.toHaveProperty("watchedIssueCount"); }); + it("redacts hydrated summaries and watched issues when a card includes a private task", async () => { + const company = await seedCompany(); + await enableStatusCards(); + const privateIssueId = randomUUID(); + await db.insert(issues).values({ + id: privateIssueId, + companyId: company.id, + title: "Confidential acquisition", + status: "blocked", + priority: "high", + visibility: "private", + privacyRootIssueId: privateIssueId, + responsibleUserId: "private-owner", + }); + const document = await db.insert(documents).values({ + companyId: company.id, + title: "Private status summary", + format: "markdown", + latestBody: "Confidential acquisition is blocked.", + latestRevisionNumber: 1, + }).returning().then((rows) => rows[0]!); + const card = await db.insert(statusCards).values({ + companyId: company.id, + createdByUserId: "private-owner", + interestPrompt: "Acquisition status", + documentId: document.id, + queries: [{ scope: "issues", status: ["blocked"], updatedWithin: "7d", sort: "updated", limit: 20, offset: 0 }], + queryVersion: 1, + refreshPolicy: { mode: "manual" }, + state: "active", + fingerprint: {}, + }).returning().then((rows) => rows[0]!); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: "unprivileged-user", + membershipRole: "operator", + status: "active", + }); + + const app = createApp(db, unprivilegedBoardActor(company.id)); + const detail = await request(app).get(`/api/status-cards/${card.id}`); + expect(detail.status).toBe(200); + expect(detail.body).toMatchObject({ + watchedIssueCount: 0, + summaryBody: null, + privacyRedacted: true, + }); + expect(JSON.stringify(detail.body)).not.toContain("Confidential acquisition"); + + const dryRun = await request(app).get(`/api/status-cards/${card.id}/dry-run`); + expect(dryRun.status).toBe(200); + expect(JSON.stringify(dryRun.body)).not.toContain("Confidential acquisition"); + expect(JSON.stringify(dryRun.body)).not.toContain(privateIssueId); + }); + it("refreshes a card whose watched issues carry human comments", async () => { // Regression: the postgres-js driver returns `max(updated_at)` as a string, // so `latestHumanCommentAt.toISOString()` threw and refresh 500'd whenever a diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 98522fd333d..3e4e5466eb3 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -1,7 +1,16 @@ +import { sql } from "drizzle-orm"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +// Isolate runtime-management guards; real database privacy is covered by +// privacy-production-review.test.ts. +vi.mock("../services/authorization.js", async () => ({ + ...(await vi.importActual("../services/authorization.js")), + executionWorkspaceReadSqlCondition: async () => sql`true`, + canActorReadExecutionWorkspace: async () => true, +})); + const mockProjectService = vi.hoisted(() => ({ create: vi.fn(), createWorkspace: vi.fn(), diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 893de8f20c1..b9989cd2fa0 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -4,8 +4,8 @@ import { createRequire } from "node:module"; import type { Duplex } from "node:stream"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db"; -import type { DeploymentMode } from "@paperclipai/shared"; +import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns, issues, projects } from "@paperclipai/db"; +import type { DeploymentMode, LiveEvent } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "../middleware/logger.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; @@ -40,7 +40,12 @@ const { WebSocket, WebSocketServer } = require("ws") as { WebSocketServer: new (opts: { noServer: boolean }) => WsServer; }; +import { authorizationService, type AuthorizationActor } from "../services/authorization.js"; +import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; + interface UpgradeContext { + actor: AuthorizationActor; + apiKeyId?: string; companyId: string; actorType: "board" | "agent"; actorId: string; @@ -143,6 +148,7 @@ async function authorizeUpgrade( companyId, actorType: "board", actorId: "board", + actor: { type: "board", userId: "local-board", source: "local_implicit" }, }; } @@ -161,6 +167,7 @@ async function authorizeUpgrade( companyId, actorType: "board", actorId: cloudActor.userId, + actor: { type: "board", userId: cloudActor.userId, source: "cloud_tenant", companyIds: cloudActor.companyIds }, }; } } @@ -198,6 +205,7 @@ async function authorizeUpgrade( companyId, actorType: "board", actorId: userId, + actor: { type: "board", userId, source: "session", companyIds: memberships.map(m => m.companyId) }, }; } @@ -208,7 +216,7 @@ async function authorizeUpgrade( .where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt))) .then((rows) => rows[0] ?? null); - if (!key || key.companyId !== companyId) { + if (!key || key.companyId !== companyId || key.scopeConfig) { return null; } @@ -221,6 +229,8 @@ async function authorizeUpgrade( companyId, actorType: "agent", actorId: key.agentId, + apiKeyId: key.id, + actor: { type: "agent", agentId: key.agentId, companyId, source: "agent_key", onBehalfOfUserId: key.responsibleUserId }, }; } @@ -260,9 +270,67 @@ export function setupLiveEventsWebSocketServer( return; } + const access = authorizationService(db); + async function visibleEvent(event: LiveEvent): Promise { + if (context!.actor.source === "local_implicit") return event; + if (context!.apiKeyId) { + const active = await db.select({ id: agentApiKeys.id }).from(agentApiKeys) + .where(and(eq(agentApiKeys.id, context!.apiKeyId), isNull(agentApiKeys.revokedAt))).limit(1); + if (!active.length) return null; + } else { + const membership = await db.select({ id: companyMemberships.id }).from(companyMemberships) + .where(and(eq(companyMemberships.companyId, context!.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, context!.actorId), eq(companyMemberships.status, "active"))).limit(1); + const admin = context!.actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, context!.actorId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0); + if (!membership.length && !admin) return null; + } + const payload = event.payload; + const issueId = typeof payload.issueId === "string" ? payload.issueId + : payload.entityType === "issue" && typeof payload.entityId === "string" ? payload.entityId : null; + if (issueId) { + const [issue] = await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, event.companyId))).limit(1); + if (!issue || !(await access.decide({ actor: context!.actor, action: "issue:read", + resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null; + } + if (payload.entityType === "project" && typeof payload.entityId === "string") { + const [project] = await db.select({ id: projects.id }).from(projects) + .where(and(eq(projects.id, payload.entityId), eq(projects.companyId, event.companyId))).limit(1); + if (!project || !(await access.decide({ actor: context!.actor, action: "project:read", + resource: { type: "project", companyId: event.companyId, projectId: project.id } })).allowed) return null; + } + const runId = typeof payload.runId === "string" ? payload.runId + : payload.entityType === "heartbeat_run" && typeof payload.entityId === "string" ? payload.entityId : null; + if (runId) { + const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, event.companyId))); + if (!run || !(await canActorReadHeartbeatRun(db, access, context!.actor, run))) return null; + } + if (event.type.startsWith("heartbeat.run.")) return runId ? event : null; + if (event.type === "agent.session.goal.changed") return issueId ? event : null; + // Company-wide invalidations carry no task-derived content. Authorized + // clients obtain details through viewer-filtered HTTP reads. + if (event.type === "activity.logged") return { ...event, payload: { + action: payload.action, entityType: payload.entityType, entityId: payload.entityId, + } }; + if (event.type === "agent.status") return { ...event, payload: { agentId: payload.agentId, status: payload.status } }; + if (event.type === "external_object.updated") return { ...event, payload: { externalObjectId: payload.externalObjectId } }; + if (event.type.startsWith("plugin.")) return event; + return null; + } + let delivery = Promise.resolve(); + let pending = 0; const unsubscribe = subscribeCompanyLiveEvents(context.companyId, (event) => { if (socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify(event)); + if (++pending > 1000) { socket.close(1013, "event backlog"); return; } + delivery = delivery.then(async () => { + if (socket.readyState !== WebSocket.OPEN) return; + // Check at delivery time, including after a grant is revoked while the + // subscription remains open. Never cache successful privacy decisions. + const visible = await visibleEvent(event); + if (visible && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(visible)); + }).catch(err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed")) + .finally(() => { pending -= 1; }); }); cleanupByClient.set(socket, unsubscribe); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 47592cfe507..2dae3d31b9e 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -10,6 +10,8 @@ import { sanitizeRecord } from "../redaction.js"; import { badRequest, forbidden } from "../errors.js"; import { agentActionAuditService } from "../services/agent-action-audit.js"; import { logActivity } from "../services/activity-log.js"; +import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; +import { issueReadSqlCondition } from "../services/authorization.js"; /** Max rows a single CSV export will stream (guards against runaway exports). */ const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000; @@ -230,6 +232,7 @@ export function activityRoutes(db: Db) { entityType: req.query.entityType as string | undefined, entityId: req.query.entityId as string | undefined, limit: normalizeActivityLimit(Number(req.query.limit)), + readCondition: await issueReadSqlCondition(db, req.actor), }; const result = await svc.list(filters); res.json(result); @@ -369,6 +372,10 @@ export function activityRoutes(db: Db) { } assertCompanyAccess(req, run.companyId); if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return; + if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) { + res.json([]); + return; + } const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index eb63b74170c..96bba2754a8 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -261,6 +261,11 @@ import { changeConsentGateService, touchesAgentProfileChangeConsentFields, } from "../services/change-consent-gate.js"; +import { + canActorReadHeartbeatRun, + canActorReadWorkspaceOperation, + redactHeartbeatRunListRow, +} from "../services/heartbeat-run-privacy.js"; import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerProviderProfile, @@ -300,6 +305,12 @@ function mergeDesiredSkillEntries( const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; +type RunReadBinding = { + companyId: string; + scopeKind?: "company" | "issue" | null; + issueId?: string | null; +}; + function readRunLogLimitBytes(value: unknown) { const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; @@ -759,6 +770,27 @@ export function agentRoutes( const instanceSettings = instanceSettingsService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function actorCanReadRun(req: Request, run: RunReadBinding) { + return canActorReadHeartbeatRun(db, access, req.actor, run); + } + + async function assertRunReadAllowed( + req: Request, + res: Response, + run: RunReadBinding, + notFoundMessage = "Heartbeat run not found", + ) { + if (await actorCanReadRun(req, run)) return true; + res.status(404).json({ error: notFoundMessage }); + return false; + } + + async function serializeRunListRow< + T extends Record & RunReadBinding & { id: string }, + >(req: Request, run: T) { + return await actorCanReadRun(req, run) ? run : redactHeartbeatRunListRow(run); + } + // The company-scoped adapter login-session service. It runs the device-login // flow in a fresh trusted sandbox and holds the one-time prompt in memory. The // process owns one instance, so the in-memory prompt and the cancellation @@ -7030,7 +7062,7 @@ export function agentRoutes( const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined; const summary = req.query.summary === "true" || req.query.summary === "1"; const runs = await heartbeat.list(companyId, agentId, limit, { summary }); - res.json(await runRedactions.redactForRuns(companyId, runs)); + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(runs.map(run => serializeRunListRow(req, run))))); }); router.get("/companies/:companyId/provider-traces", async (req, res) => { @@ -7103,7 +7135,17 @@ export function agentRoutes( lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes, processStartedAt: heartbeatRuns.processStartedAt, - issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, + inputTokens: sql`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"), + cachedInputTokens: sql`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"), + outputTokens: sql`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"), + totalTokens: sql`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"), + costUsd: sql`coalesce( + (${heartbeatRuns.resultJson} ->> 'costUsd')::numeric, + (${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric, + (${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric + )`.as("costUsd"), }; const liveRunsQuery = db @@ -7156,7 +7198,7 @@ export function agentRoutes( } const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => !(await actorCanReadRun(req, run)) ? redactHeartbeatRunListRow(run) : ({ ...heartbeat.decorateActiveRunStatus(run), agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), @@ -7179,6 +7221,7 @@ export function agentRoutes( const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; + if (!(await assertRunReadAllowed(req, res, run))) return; const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId); const decoratedRun = heartbeat.decorateActiveRunStatus(run); res.json(await runRedactions.redactForRun( @@ -7196,6 +7239,7 @@ export function agentRoutes( const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; + if (!(await assertRunReadAllowed(req, res, existing))) return; const requestId = cancellationRequestId(req.body?.cancellationRequestId); // Stamp the cancellation as operator-initiated (this route is board-only). // Recovery reads this to stand down instead of classifying the cancelled @@ -7435,6 +7479,7 @@ export function agentRoutes( const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; + if (!(await assertRunReadAllowed(req, res, existing))) return; const decision = typeof req.body?.decision === "string" ? req.body.decision : ""; if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) { res.status(400).json({ error: "Unsupported watchdog decision" }); @@ -7655,6 +7700,7 @@ export function agentRoutes( const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; + if (!(await assertRunReadAllowed(req, res, run))) return; const afterSeq = Number(req.query.afterSeq ?? 0); const limit = Number(req.query.limit ?? 200); @@ -7674,6 +7720,7 @@ export function agentRoutes( const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; + if (!(await assertRunReadAllowed(req, res, run))) return; const offset = Number(req.query.offset ?? 0); const limitBytes = readRunLogLimitBytes(req.query.limitBytes); @@ -7691,11 +7738,16 @@ export function agentRoutes( const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; + if (!(await assertRunReadAllowed(req, res, run))) return; const context = asRecord(run.contextSnapshot); const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId); const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId); - res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions())); + const visibleOperations = []; + for (const operation of operations) { + if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation); + } + res.json(redactCurrentUserValue(visibleOperations, await getCurrentUserRedactionOptions())); }); router.get("/workspace-operations/:operationId/log", async (req, res) => { @@ -7703,6 +7755,10 @@ export function agentRoutes( const operation = await getAccessibleResource(req, res, workspaceOperations.getById(operationId), "Workspace operation not found"); if (!operation) return; if (!(await assertRunTelemetryReadAllowed(req, res, operation.companyId))) return; + if (!(await canActorReadWorkspaceOperation(db, access, req.actor, operation))) { + res.status(404).json({ error: "Workspace operation not found" }); + return; + } const offset = Number(req.query.offset ?? 0); const limitBytes = readRunLogLimitBytes(req.query.limitBytes); @@ -7761,7 +7817,7 @@ export function agentRoutes( and( eq(heartbeatRuns.companyId, issue.companyId), inArray(heartbeatRuns.status, ["queued", "running"]), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + eq(heartbeatRuns.issueId, issue.id), ), ) .orderBy(desc(heartbeatRuns.createdAt)); diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index feb3bbdfda3..d10a4bb0ac1 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -22,6 +22,7 @@ import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo, import { redactEventPayload } from "../redaction.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { issueService } from "../services/issues.js"; +import { approvalReadSqlCondition, canActorReadApproval } from "../services/authorization.js"; import { REVIEW_PATH_RECOVERY_INSTRUCTION } from "../services/recovery/review-path-recovery.js"; function redactApprovalPayload }>(approval: T): T { @@ -45,6 +46,15 @@ export function approvalRoutes( options: { pluginWorkerManager?: PluginWorkerManager } = {}, ) { const router = Router(); + router.param("id", async (req, res, next, id) => { + try { + if (!(await canActorReadApproval(db, req.actor, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } + next(); + } catch (error) { next(error); } + }); const svc = approvalService(db); const access = accessService(db); const heartbeat = heartbeatService(db, { @@ -207,7 +217,7 @@ export function approvalRoutes( assertCompanyAccess(req, companyId); if (!(await assertApprovalAccessAllowed(req, res, companyId))) return; const status = req.query.status as string | undefined; - const result = await svc.list(companyId, status); + const result = await svc.list(companyId, status, await approvalReadSqlCondition(db, req.actor)); res.json(result.map((approval) => redactApprovalPayload(approval))); }); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 5ac78e0cc2a..11fe0eb8e68 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -1,9 +1,13 @@ +import { and, eq } from "drizzle-orm"; +import { runnerApiResponseReservations, heartbeatRuns, issues, issueAttachments } from "@paperclipai/db"; +import { authorizationService } from "../services/authorization.js"; +import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; import type { Db } from "@paperclipai/db"; -import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared"; +import { ASSET_NAMESPACE_RULE, isUuidLike, createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { @@ -184,7 +188,7 @@ export function assetRoutes(db: Db, storage: StorageService) { sha256: stored.sha256, originalFilename: stored.originalFilename, createdByAgentId: actor.agentId, - createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByUserId: actor.actorType === "user" ? actor.actorId : req.actor.onBehalfOfUserId ?? null, }); await logActivity(db, { @@ -198,7 +202,7 @@ export function assetRoutes(db: Db, storage: StorageService) { entityType: "asset", entityId: asset.id, details: { - originalFilename: asset.originalFilename, + ...(namespaceSuffix.startsWith("issues/") ? {} : { originalFilename: asset.originalFilename }), contentType: asset.contentType, byteSize: asset.byteSize, }, @@ -327,6 +331,41 @@ export function assetRoutes(db: Db, storage: StorageService) { const assetId = req.params.assetId as string; const asset = await getAccessibleResource(req, res, svc.getById(assetId), "Asset not found"); if (!asset) return; + const access = authorizationService(db); + // Persisted storage namespaces retain provenance after attachment rows are deleted. + const objectParts = asset.objectKey.split("/"); + const isDraftImage = objectParts[1] === "assets" && objectParts[2] === "issues" && objectParts[3] === "drafts"; + const namespace = objectParts[1] === "assets" ? objectParts[2] : objectParts[1]; + const sourceIssueId = objectParts[1] === "assets" ? objectParts[3] : objectParts[2]; + const sourceExists = namespace !== "issues" || isDraftImage || (sourceIssueId && isUuidLike(sourceIssueId) && await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, sourceIssueId), eq(issues.companyId, asset.companyId))).limit(1).then(rows => rows.length > 0)); + if (namespace === "issues" && !isDraftImage && (!sourceExists || !(await access.decide({ actor: req.actor, action: "issue:read", + resource: { type: "issue", companyId: asset.companyId, issueId: sourceIssueId } })).allowed)) { + res.status(404).json({ error: "Asset not found" }); return; + } + const captures = await db.select({ run: heartbeatRuns }).from(runnerApiResponseReservations) + .leftJoin(heartbeatRuns, eq(heartbeatRuns.id, runnerApiResponseReservations.runId)) + .where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId))); + if (namespace === "runner-api" && !captures.length) { res.status(404).json({ error: "Asset not found" }); return; } + for (const { run } of captures) { + if (!run || !(await canActorReadHeartbeatRun(db, access, req.actor, run))) { + res.status(404).json({ error: "Asset not found" }); return; + } + } + const attachments = await db.select({ issueId: issueAttachments.issueId }).from(issueAttachments) + .where(and(eq(issueAttachments.assetId, asset.id), eq(issueAttachments.companyId, asset.companyId))); + if (isDraftImage && !attachments.length && !(req.actor.type === "board" + ? req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || req.actor.userId === asset.createdByUserId + : req.actor.type === "agent" && req.actor.agentId === asset.createdByAgentId + && (!asset.createdByUserId || req.actor.onBehalfOfUserId === asset.createdByUserId))) { + res.status(404).json({ error: "Asset not found" }); return; + } + for (const attachment of attachments) { + if (!(await access.decide({ actor: req.actor, action: "issue:read", resource: { + type: "issue", companyId: asset.companyId, issueId: attachment.issueId, + } })).allowed) { res.status(404).json({ error: "Asset not found" }); return; } + } + // Use the persisted size only after resource authorization. Single ranges // keep saved API text pages bounded all the way to disk or object storage. @@ -354,7 +393,7 @@ export function assetRoutes(db: Db, storage: StorageService) { res.status(206); res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${asset.byteSize}`); } - res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("Cache-Control", "private, no-store"); res.setHeader("X-Content-Type-Options", "nosniff"); if (!inlineSafe) { res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'"); diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts index 1a9458b5f62..0ed43fedc8f 100644 --- a/server/src/routes/attention.ts +++ b/server/src/routes/attention.ts @@ -41,6 +41,7 @@ export function attentionRoutes(db: Db) { const feed = await svc.list(companyId, { userId: req.actor.userId, includeDismissed, + actor: req.actor, archived, all, allowUnscopedAll: all, diff --git a/server/src/routes/decision-training.ts b/server/src/routes/decision-training.ts index 3191ccb936a..619a7a64ab1 100644 --- a/server/src/routes/decision-training.ts +++ b/server/src/routes/decision-training.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; +import { authorizationService, issueReadSqlCondition } from "../services/authorization.js"; import { validate } from "../middleware/validate.js"; import { decisionTrainingService, logActivity } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"; @@ -52,6 +53,14 @@ function requireExampleOwner(res: Response, userId: string, createdByUserId: str export function decisionTrainingRoutes(db: Db) { const router = Router(); const svc = decisionTrainingService(db); + async function canRead(req: Request, companyId: string, issueId: string) { + return (await authorizationService(db).decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId, issueId } })).allowed; + } + async function requireRead(req: Request, res: Response, companyId: string, issueId: string) { + if (await canRead(req, companyId, issueId)) return true; + res.status(404).json({ error: "Decision training source not found" }); + return false; + } router.post( "/companies/:companyId/decision-training", @@ -61,6 +70,7 @@ export function decisionTrainingRoutes(db: Db) { assertCompanyAccess(req, companyId); const userId = requireHumanUser(req, res); if (!userId) return; + if (!(await requireRead(req, res, companyId, req.body.issueId))) return; const example = await svc.create({ companyId, @@ -97,6 +107,7 @@ export function decisionTrainingRoutes(db: Db) { assertCompanyAccess(req, companyId); const userId = requireHumanUser(req, res); if (!userId) return; + if (!(await requireRead(req, res, companyId, req.body.issueId))) return; const preview = await svc.preview({ companyId, sourceKind: req.body.sourceKind, @@ -126,6 +137,7 @@ export function decisionTrainingRoutes(db: Db) { return; } res.json(await svc.list(companyId, { + readCondition: await issueReadSqlCondition(db, req.actor), projectId: parsed.data.project, kind: parsed.data.kind, author: parsed.data.author, @@ -137,7 +149,7 @@ export function decisionTrainingRoutes(db: Db) { const companyId = req.params.companyId as string; assertBoard(req); assertCompanyAccess(req, companyId); - const rows = await svc.list(companyId); + const rows = await svc.list(companyId, { readCondition: await issueReadSqlCondition(db, req.actor) }); const body = rows .map(({ example }) => JSON.stringify({ retentionPolicy: example.retentionPolicy, @@ -165,7 +177,7 @@ export function decisionTrainingRoutes(db: Db) { const exampleId = parseExampleId(req, res); if (!exampleId) return; const example = await svc.getById(exampleId); - if (!example || !hasCompanyAccess(req, example.companyId)) { + if (!example || !hasCompanyAccess(req, example.companyId) || !(await canRead(req, example.companyId, example.issueId))) { res.status(404).json({ error: "Decision training example not found" }); return; } diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index f3147df6324..280d9bc01ed 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,3 +1,5 @@ +import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js"; +import { canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition } from "../services/authorization.js"; import { spawn } from "node:child_process"; import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -96,6 +98,12 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P pluginWorkerManager: opts.pluginWorkerManager, }); + async function assertWorkspacePrivacy(req: Request, res: Response, id: string) { + if (await canActorReadExecutionWorkspace(db, req.actor, id)) return true; + res.status(404).json({ error: "Execution workspace not found" }); + return false; + } + async function assertExecutionWorkspaceReadAllowed(req: Request, res: Response, companyId: string) { const decision = await access.decide({ actor: req.actor, @@ -123,6 +131,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P assertCompanyAccess(req, companyId); if (!(await assertExecutionWorkspaceReadAllowed(req, res, companyId))) return; const filters = { + readCondition: await executionWorkspaceReadSqlCondition(db, req.actor), projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, @@ -149,7 +158,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P return; } - const overview = await svc.listOverview(companyId, parsed.data); + const overview = await svc.listOverview(companyId, parsed.data, await executionWorkspaceReadSqlCondition(db, req.actor)); res.json(overview); }); @@ -157,6 +166,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const id = req.params.id as string; const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!workspace) return; + if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return; if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return; res.json(workspace); }); @@ -165,6 +175,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const id = req.params.id as string; const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!workspace) return; + if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return; if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return; const readiness = await svc.getCloseReadiness(id); if (!readiness) { @@ -187,6 +198,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P assertBoard(req); const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!workspace) return; + if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return; // Opening a workspace board is a runtime-control-grade action: it hands the // caller an authenticated session inside the cloned instance. if (!(await assertRuntimeManageAllowed(req, res, workspace.companyId))) return; @@ -259,9 +271,14 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const id = req.params.id as string; const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!workspace) return; + if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return; if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return; const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id); - res.json(operations); + const visibleOperations = []; + for (const operation of operations) { + if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation); + } + res.json(visibleOperations); }); async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) { @@ -274,6 +291,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!existing) return; + if (!(await assertWorkspacePrivacy(req, res, existing.id))) return; if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return; const authorization = await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { @@ -1050,6 +1068,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!existing) return; + if (!(await assertWorkspacePrivacy(req, res, existing.id))) return; assertBoard(req); if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return; @@ -1139,6 +1158,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); if (!existing) return; + if (!(await assertWorkspacePrivacy(req, res, existing.id))) return; if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return; assertNoAgentHostWorkspaceCommandMutation( req, diff --git a/server/src/routes/file-resources.ts b/server/src/routes/file-resources.ts index 4f852756afb..c84d51fd9c2 100644 --- a/server/src/routes/file-resources.ts +++ b/server/src/routes/file-resources.ts @@ -355,7 +355,7 @@ export function fileResourceRoutes(db: Db, opts: { availabilityLimiter?: FileResourceLimiter; } = {}) { const router = Router(); - const svc = opts.service ?? workspaceFileResourceService(db); + const serviceFor = (req: Request) => opts.service ?? workspaceFileResourceService(db, req.actor); const limiter = opts.limiter ?? createFileResourceLimiter(); const listLimiter = opts.listLimiter ?? createFileResourceListLimiter(); const availabilityLimiter = opts.availabilityLimiter ?? createFileResourceAvailabilityLimiter(); @@ -468,7 +468,7 @@ export function fileResourceRoutes(db: Db, opts: { throw error; } - const issue = await svc.getIssue(req.params.issueId); + const issue = await serviceFor(req).getIssue(req.params.issueId); const actor = getActorInfo(req); if (!hasCompanyAccess(req, issue.companyId)) { const error = notFound("Issue not found"); @@ -501,7 +501,7 @@ export function fileResourceRoutes(db: Db, opts: { let release: (() => void) | null = null; try { release = availabilityLimiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); - const result = await svc.availability(req.params.issueId, body, { issue }); + const result = await serviceFor(req).availability(req.params.issueId, body, { issue }); await logAvailabilityAttempt({ companyId: issue.companyId, actor, @@ -544,7 +544,7 @@ export function fileResourceRoutes(db: Db, opts: { } throw error; } - const issue = await svc.getIssue(req.params.issueId); + const issue = await serviceFor(req).getIssue(req.params.issueId); const actor = getActorInfo(req); try { if (!hasCompanyAccess(req, issue.companyId)) { @@ -595,7 +595,7 @@ export function fileResourceRoutes(db: Db, opts: { const requestAbort = requestAbortController(req, res); try { - const result = await svc.list(req.params.issueId, query, { + const result = await serviceFor(req).list(req.params.issueId, query, { issue, scanContext: { signal: requestAbort.signal, @@ -677,7 +677,7 @@ export function fileResourceRoutes(db: Db, opts: { } throw error; } - const issue = await svc.getIssue(req.params.issueId); + const issue = await serviceFor(req).getIssue(req.params.issueId); const actor = getActorInfo(req); try { if (!hasCompanyAccess(req, issue.companyId)) { @@ -730,7 +730,7 @@ export function fileResourceRoutes(db: Db, opts: { throw error; } try { - const result = await svc.resolve(req.params.issueId, query, { issue }); + const result = await serviceFor(req).resolve(req.params.issueId, query, { issue }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -789,7 +789,7 @@ export function fileResourceRoutes(db: Db, opts: { } throw error; } - const issue = await svc.getIssue(req.params.issueId); + const issue = await serviceFor(req).getIssue(req.params.issueId); const actor = getActorInfo(req); try { if (!hasCompanyAccess(req, issue.companyId)) { @@ -842,7 +842,7 @@ export function fileResourceRoutes(db: Db, opts: { if (parseBooleanQuery(req.query.download)) { let result: Awaited> | null = null; try { - result = await svc.prepareDownload(req.params.issueId, query, { issue }); + result = await serviceFor(req).prepareDownload(req.params.issueId, query, { issue }); } catch (error) { await logDeniedAttempt({ companyId: issue.companyId, @@ -892,7 +892,7 @@ export function fileResourceRoutes(db: Db, opts: { let result: WorkspaceFileContent | null = null; try { - result = await svc.readContent(req.params.issueId, query, { issue }); + result = await serviceFor(req).readContent(req.params.issueId, query, { issue }); } catch (error) { await logDeniedAttempt({ companyId: issue.companyId, diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts index 2a75da4be23..c9a00491ba3 100644 --- a/server/src/routes/issue-tree-control.ts +++ b/server/src/routes/issue-tree-control.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import type { Request } from "express"; +import type { Request, Response } from "express"; import { issues as issueRows, type Db, @@ -15,6 +15,7 @@ import { } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { + accessService, heartbeatService, issueService, issueTreeControlService, @@ -55,6 +56,35 @@ export function issueTreeControlRoutes( const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); + const access = accessService(db); + + async function assertIssueReadAllowed(req: Request, res: Response, issue: { + id: string; + companyId: string; + projectId?: string | null; + parentId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string; + }) { + const decision = await access.decide({ + actor: req.actor, + action: "issue:read", + resource: { + type: "issue", + companyId: issue.companyId, + issueId: issue.id, + projectId: issue.projectId ?? null, + parentIssueId: issue.parentId ?? null, + assigneeAgentId: issue.assigneeAgentId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + status: issue.status ?? "backlog", + }, + }); + if (decision.allowed) return true; + res.status(404).json({ error: "Root issue not found" }); + return false; + } async function resolveRootIssue(req: Request) { const rootIssueId = req.params.id as string; @@ -66,6 +96,7 @@ export function issueTreeControlRoutes( assertBoard(req); const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found"); if (!root) return; + if (!(await assertIssueReadAllowed(req, res, root))) return; const preview = await treeControlSvc.preview(root.companyId, root.id, req.body); const actor = getActorInfo(req); @@ -93,6 +124,7 @@ export function issueTreeControlRoutes( assertBoard(req); const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found"); if (!root) return; + if (!(await assertIssueReadAllowed(req, res, root))) return; const actor = getActorInfo(req); const actorInput = { @@ -334,6 +366,7 @@ export function issueTreeControlRoutes( const issueId = req.params.id as string; const issue = await getAccessibleResource(req, res, issuesSvc.getById(issueId), "Issue not found"); if (!issue) return; + if (!(await assertIssueReadAllowed(req, res, issue))) return; const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); res.json({ activePauseHold }); }); @@ -342,6 +375,7 @@ export function issueTreeControlRoutes( assertBoard(req); const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found"); if (!root) return; + if (!(await assertIssueReadAllowed(req, res, root))) return; const statusParam = typeof req.query.status === "string" ? req.query.status : null; const modeParam = typeof req.query.mode === "string" ? req.query.mode : null; const includeMembers = req.query.includeMembers === "true"; @@ -360,6 +394,7 @@ export function issueTreeControlRoutes( assertBoard(req); const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found"); if (!root) return; + if (!(await assertIssueReadAllowed(req, res, root))) return; const holdId = req.params.holdId as string; if (!isUuidLike(holdId)) { @@ -387,6 +422,7 @@ export function issueTreeControlRoutes( "Root issue not found", ); if (!root) return; + if (!(await assertIssueReadAllowed(req, res, root))) return; const holdId = req.params.holdId as string; if (!isUuidLike(holdId)) { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7cb2245a440..dbd430dbc81 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,3 +1,5 @@ +import type { IssuePrivacyConstraints } from "@paperclipai/shared"; +import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js"; import { setIssueTitle } from "../services/issue-title.js"; import { setIssueTitleSchema } from "@paperclipai/shared"; @@ -25,16 +27,7 @@ import { createHash, randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; -import { - and, - asc, - desc, - eq, - inArray, - isNull, - notInArray, - sql, -} from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, notInArray, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import type { ChatChannelService } from "../services/chat-channels.js"; import { @@ -42,6 +35,7 @@ import { agentWakeupRequests, agents, approvals, + authUsers, chatConversations, chatEndpoints, chatPublications, @@ -50,6 +44,7 @@ import { executionWorkspaces, heartbeatRuns, issueApprovals, + issueAccessGrants, issueComments, issueDocuments, issueExecutionDecisions, @@ -58,11 +53,14 @@ import { issueThreadInteractions, issues as issueRows, issueWorkProducts, + instanceUserRoles, pipelineCaseIssueLinks, pipelineCases, pipelineStages, pipelines, projectWorkspaces, + projectAccessMembers, + projects, } from "@paperclipai/db"; import { addIssueCommentSchema, @@ -133,6 +131,7 @@ import { type IssueWakeDiagnosticWakeRequest, type IssueWakeDiagnosticsResponse, type IssueRelationIssueSummary, + type IssueLockedStub, type IssueReviewPolicy, type IssueThreadInteractionCanonicalResolverPolicy, type IssueComment, @@ -287,10 +286,15 @@ import { readAcceptedPlanConfirmationTarget, type IssuePostCommitAction, } from "../services/issues.js"; -import { authorizationDeniedDetails } from "../services/authorization.js"; +import { + authorizationDeniedDetails, + issueReadSqlCondition, + projectReadSqlCondition, +} from "../services/authorization.js"; import { stalledReviewDecisionService } from "../services/stalled-review-decisions.js"; import { environmentService } from "../services/environments.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; +import { ensurePersonalPrivateProject } from "../services/projects.js"; import { redactSensitiveText } from "../redaction.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; import { @@ -399,14 +403,15 @@ const refreshExternalObjectsSchema = z const inboxArchiveBodySchema = z .object({ userId: z.string().trim().min(1).optional(), - }) - .strict() - .default({}); -const externalObjectSummariesSchema = z - .object({ - issueIds: z.array(z.string().guid()).max(1000), - }) - .strict(); +}).strict().default({}); +const externalObjectSummariesSchema = z.object({ + issueIds: z.array(z.string().uuid()).max(1000), +}).strict(); +const revokeIssueAccessGrantSchema = z.object({}).strict().default({}); +const createIssueAccessGrantSchema = z.object({ + subjectType: z.enum(["user", "agent"]), + subjectId: z.string().trim().min(1), +}).strict(); const promoteLowTrustOutputSchema = z.object({ sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]), @@ -475,10 +480,12 @@ type CompanySearchService = { extract( companyId: string, query: CompanySearchExtractQuery, + options?: { issueReadCondition?: SQL }, ): Promise; search( companyId: string, query: CompanySearchQuery, + options?: { issueReadCondition?: SQL; projectReadCondition?: SQL }, ): Promise; }; type ActivityIssueRelationSummary = { @@ -2996,6 +3003,8 @@ function toCompactIssue(issue: any): CompactIssue { projectWorkspaceId: issue.projectWorkspaceId, goalId: issue.goalId, parentId: issue.parentId, + visibility: issue.visibility, + privacyRootIssueId: issue.privacyRootIssueId, title: issue.title, description: issue.description, status: issue.status, @@ -4989,6 +4998,86 @@ export function issueRoutes( }); } + async function canUseIssueBreakGlass(req: Request, issue: { companyId: string }) { + if (req.actor.type !== "board" || !req.actor.userId) return false; + return db + .select({ role: companyMemberships.membershipRole }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, issue.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, req.actor.userId), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.membershipRole, ["owner", "admin"]), + )) + .limit(1) + .then((rows) => rows.length > 0); + } + + async function recordIssueBreakGlassRead( + req: Request, + issue: Parameters[1] & { identifier?: string | null }, + ) { + const actor = getActorInfo(req); + const occurredAt = new Date().toISOString(); + await db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await issueService(txDb).addComment( + issue.id, + `A company administrator used audited break-glass access to read this private task at ${occurredAt}.`, + {}, + { + authorType: "system", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Break-glass access used", + detailsDefaultOpen: false, + density: "compact", + }, + }, + ); + await logActivity(txDb, { + companyId: issue.companyId, + actorType: "user", + actorId: actor.actorId, + agentId: null, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue.break_glass_read", + entityType: "issue", + entityId: issue.id, + issueId: issue.id, + details: { + issueId: issue.id, + occurredAt, + acknowledgement: "breakGlass=true", + }, + }); + }); + } + + async function assertIssueReadAllowed( + req: Request, + res: Response, + issue: Parameters[1] & { identifier?: string | null }, + options: { allowBreakGlass?: boolean } = {}, + ) { + const decision = await decideIssueAccess(req, issue, "issue:read"); + if (decision.allowed) return true; + if ( + options.allowBreakGlass === true + && req.query.breakGlass === "true" + && decision.reason === "deny_issue_private" + && await canUseIssueBreakGlass(req, issue) + ) { + await recordIssueBreakGlassRead(req, issue); + return true; + } + res.status(404).json({ error: "Issue not found" }); + return false; + } + /** * Map an authorization denial onto the issue-write copy contract (plan §6). * @@ -5068,21 +5157,310 @@ export function issueRoutes( return false as const; } - async function assertIssueReadAllowed( + async function canActorReadProject(req: Request, project: { id: string; companyId: string }) { + return access.decide({ + actor: req.actor, + action: "project:read", + resource: { type: "project", companyId: project.companyId, projectId: project.id }, + }).then((decision) => decision.allowed); + } + + async function visibleIssueProject( + req: Request, + project: T | null, + ): Promise { + if (!project) return null; + return (await canActorReadProject(req, project)) ? project : null; + } + + async function assertCanManageIssuePrivacy( req: Request, res: Response, - issue: Parameters[1], + issue: { companyId: string; responsibleUserId: string | null; createdByUserId: string | null; assigneeUserId: string | null }, ) { - const key = `${issue.id}:${issue.companyId}:${issue.projectId ?? ""}:${issue.parentId ?? ""}:${issue.assigneeAgentId ?? ""}:${issue.assigneeUserId ?? ""}:${issue.status}`; - const value = memoizeIssueReadDecision(req, key, () => - decideIssueAccess(req, issue, "issue:read"), - ); - const decision = await value; - if (decision.allowed) return true; - res - .status(403) - .json({ error: "Issue is outside this actor's authorization boundary" }); + if (req.actor.type !== "board" || !req.actor.userId) { + res.status(403).json({ error: "Only a responsible user, issue owner, or administrator can manage issue privacy" }); return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; + const instanceAdmin = req.actor.source !== "cloud_tenant" + ? await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and( + eq(instanceUserRoles.userId, req.actor.userId), + eq(instanceUserRoles.role, "instance_admin"), + )) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + if (instanceAdmin) return true; + if ( + issue.responsibleUserId === req.actor.userId + || issue.createdByUserId === req.actor.userId + || issue.assigneeUserId === req.actor.userId + ) return true; + const membership = await db + .select({ role: companyMemberships.membershipRole }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, issue.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, req.actor.userId), + eq(companyMemberships.status, "active"), + )) + .then((rows) => rows[0] ?? null); + if (membership && (membership.role === "owner" || membership.role === "admin")) return true; + res.status(403).json({ error: "Only a responsible user, issue owner, or administrator can manage issue privacy" }); + return false; + } + + async function resolveManagedIssueForPrivacy( + req: Request, + res: Response, + issue: Parameters[1] & { + privacyRootIssueId: string | null; + responsibleUserId: string | null; + createdByUserId: string | null; + }, + ) { + if (!(await assertIssueReadAllowed(req, res, issue))) return null; + if (!(await assertCanManageIssuePrivacy(req, res, issue))) return null; + // Management is scoped to this exact task; an ancestor privacy root is + // inheritance provenance, never authority to mutate its grants. + return issue; + } + + function subjectInitials(displayName: string | null) { + if (!displayName) return null; + const parts = displayName.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return null; + return parts.slice(0, 2).map((part) => part[0]!.toUpperCase()).join(""); + } + + function issueGrantAgentVisibility(permissions: unknown): "discoverable" | "private" | null { + if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null; + const authorizationPolicy = (permissions as Record).authorizationPolicy; + if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) return null; + const agentVisibility = (authorizationPolicy as Record).agentVisibility; + if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null; + const mode = (agentVisibility as Record).mode; + return mode === "private" || mode === "discoverable" ? mode : null; + } + + async function ensureActiveIssueAccessGrant( + dbOrTx: any, + input: { + issueId: string; + subjectType: "user" | "agent"; + subjectId: string; + source: "explicit" | "assignment"; + grantedByUserId: string | null; + grantedByAgentId: string | null; + }, + ) { + // No partial unique constraint is required for rollout compatibility. The + // transaction-scoped lock serializes the active-row check for this tuple, + // including the first insert where SELECT FOR UPDATE cannot lock a row. + const lockKey = `issue-access-grant:${input.issueId}:${input.subjectType}:${input.subjectId}`; + await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); + const active = await dbOrTx + .select() + .from(issueAccessGrants) + .where(and( + eq(issueAccessGrants.issueId, input.issueId), + eq(issueAccessGrants.subjectType, input.subjectType), + eq(issueAccessGrants.subjectId, input.subjectId), + isNull(issueAccessGrants.revokedAt), + )) + .orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id)) + .limit(1) + .then((rows: Array) => rows[0] ?? null); + if (active) return { grant: active, created: false as const }; + + const grant = await dbOrTx + .insert(issueAccessGrants) + .values(input) + .returning() + .then((rows: Array) => rows[0]!); + return { grant, created: true as const }; + } + + async function enrichIssueAccessGrants( + companyId: string, + grants: Array, + ) { + const agentIds = [...new Set(grants.filter((grant) => grant.subjectType === "agent").map((grant) => grant.subjectId))]; + const userIds = [...new Set(grants.filter((grant) => grant.subjectType === "user").map((grant) => grant.subjectId))]; + const [agentRows, userRows] = await Promise.all([ + agentIds.length === 0 + ? Promise.resolve([]) + : db.select({ + id: agents.id, + name: agents.name, + permissions: agents.permissions, + }).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))), + userIds.length === 0 + ? Promise.resolve([]) + : db.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)), + ]); + const agentsById = new Map(agentRows.map((agent) => [agent.id, agent])); + const usersById = new Map(userRows.map((user) => [user.id, user])); + + return grants.map((grant) => { + const agent = grant.subjectType === "agent" ? agentsById.get(grant.subjectId) : null; + const user = grant.subjectType === "user" ? usersById.get(grant.subjectId) : null; + const subjectDisplayName = agent?.name ?? user?.name ?? null; + return { + ...grant, + subjectDisplayName, + subjectAvatarUrl: user?.image ?? null, + subjectInitials: subjectInitials(subjectDisplayName), + agentVisibility: agent ? issueGrantAgentVisibility(agent.permissions) : null, + }; + }); + } + + async function assertIssueGrantSubjectExists( + companyId: string, + subjectType: "user" | "agent", + subjectId: string, + ) { + if (subjectType === "agent") { + const agent = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, subjectId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!agent) throw unprocessable("Grant subject must be an agent in the issue company"); + return; + } + const member = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, subjectId), + eq(companyMemberships.status, "active"), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!member) throw unprocessable("Grant subject must be an active user in the issue company"); + } + + type IssueEdgeReference = IssueRelationIssueSummary | IssueLockedStub; + + async function readableIssueEdgeIds(req: Request, companyId: string, ids: string[]) { + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length === 0) return new Set(); + const rows = await db + .select({ + id: issueRows.id, + companyId: issueRows.companyId, + projectId: issueRows.projectId, + parentId: issueRows.parentId, + assigneeAgentId: issueRows.assigneeAgentId, + assigneeUserId: issueRows.assigneeUserId, + status: issueRows.status, + }) + .from(issueRows) + .where(and(eq(issueRows.companyId, companyId), inArray(issueRows.id, uniqueIds))); + const decisions = await Promise.all(rows.map(async (row) => ({ + id: row.id, + allowed: (await decideIssueAccess(req, row, "issue:read")).allowed, + }))); + return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id)); + } + + function lockedIssueStub(issue: Pick): IssueLockedStub { + return { id: issue.id, identifier: issue.identifier, locked: true }; + } + + async function redactIssueRelationEdges( + req: Request, + companyId: string, + relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] }, + ): Promise<{ blockedBy: IssueEdgeReference[]; blocks: IssueEdgeReference[] }> { + const all = [...relations.blockedBy, ...relations.blocks]; + const edgeIds = new Set(); + const pending = [...all]; + while (pending.length > 0) { + const issue = pending.pop()!; + if (edgeIds.has(issue.id)) continue; + edgeIds.add(issue.id); + pending.push(...(issue.terminalBlockers ?? [])); + } + const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]); + const redact = (issue: IssueRelationIssueSummary, ancestors = new Set()): IssueEdgeReference => { + if (!readableIds.has(issue.id)) return lockedIssueStub(issue); + const { terminalBlockers, ...visibleFields } = issue; + if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary; + const nextAncestors = new Set(ancestors).add(issue.id); + return { + ...visibleFields, + ...(terminalBlockers + ? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] } + : {}), + }; + }; + return { + blockedBy: relations.blockedBy.map((issue) => redact(issue)), + blocks: relations.blocks.map((issue) => redact(issue)), + }; + } + + async function redactIssueReferenceEdges; + inbound: Array<{ issue: IssueRelationIssueSummary }>; + }>(req: Request, companyId: string, summary: T) { + const items = [...summary.outbound, ...summary.inbound]; + const readableIds = await readableIssueEdgeIds(req, companyId, items.map((item) => item.issue.id)); + const redactItems = (rows: I[]) => rows.map((item) => ({ + ...item, + issue: readableIds.has(item.issue.id) ? item.issue : lockedIssueStub(item.issue), + })); + return { + outbound: redactItems(summary.outbound), + inbound: redactItems(summary.inbound), + }; + } + + async function redactIssueRelationEdgesOnRows(req: Request, companyId: string, rows: T[]): Promise { + const related = rows.flatMap((row) => [...(row.blockedBy ?? []), ...(row.blocks ?? [])]); + const edgeIds = new Set(); + const pending = [...related]; + while (pending.length > 0) { + const issue = pending.pop()!; + if (edgeIds.has(issue.id)) continue; + edgeIds.add(issue.id); + pending.push(...(issue.terminalBlockers ?? [])); + } + const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]); + const redact = (issue: IssueRelationIssueSummary, ancestors = new Set()): IssueEdgeReference => { + if (!readableIds.has(issue.id)) return lockedIssueStub(issue); + const { terminalBlockers, ...visibleFields } = issue; + if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary; + const nextAncestors = new Set(ancestors).add(issue.id); + return { + ...visibleFields, + ...(terminalBlockers + ? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] } + : {}), + }; + }; + return rows.map((row) => ({ + ...row, + ...(row.blockedBy ? { blockedBy: row.blockedBy.map((issue) => redact(issue)) } : {}), + ...(row.blocks ? { blocks: row.blocks.map((issue) => redact(issue)) } : {}), + })) as T[]; } async function assertIssueWriteInfluenceAllowed( @@ -5121,6 +5499,7 @@ export function issueRoutes( identifier?: string | null; }, ) { + if (!(await assertIssueReadAllowed(req, res, issue))) return false; if (req.actor.type !== "agent") return true; const actorAgentId = req.actor.agentId; if (!actorAgentId) { @@ -5242,6 +5621,7 @@ export function issueRoutes( }, options: { allowVisibleIssueWrite?: boolean } = {}, ) { + if (!(await assertIssueReadAllowed(req, res, issue))) return false; if (req.actor.type !== "agent") return true; const actorAgentId = req.actor.agentId; if (!actorAgentId) { @@ -7635,6 +8015,8 @@ export function issueRoutes( goals: project.goals, name: project.name, description: project.description, + visibility: project.visibility, + personalOwnerUserId: project.personalOwnerUserId, status: project.status, leadAgentId: project.leadAgentId, targetDate: project.targetDate, @@ -7805,10 +8187,9 @@ export function issueRoutes( }); return; } - const result = await getSearchService().extract( - companyId, - parsedQuery.data, - ); + const result = await getSearchService().extract(companyId, parsedQuery.data, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + }); res.json(result); }); @@ -7856,7 +8237,10 @@ export function issueRoutes( }); return; } - const result = await getSearchService().search(companyId, query); + const result = await getSearchService().search(companyId, query, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + projectReadCondition: await projectReadSqlCondition(db, req.actor), + }); res.json(result); }); @@ -8047,6 +8431,7 @@ export function issueRoutes( const offset = parsedOffset ?? 0; const listFilters: IssueFilters = { + readCondition: await issueReadSqlCondition(db, req.actor), attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, assigneeAgentId, @@ -8092,11 +8477,15 @@ export function issueRoutes( sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined, updatedSince: rawUpdatedSince, }; + // readCondition is a Drizzle SQL object with cyclic internal references; + // actor identity already scopes the coordinator key, so only serializable + // request filters belong in its normalized query. + const { readCondition: _readCondition, ...requestKeyFilters } = listFilters; const requestKey = issueListRequestKey({ req, companyId, normalizedQuery: { - ...listFilters, + ...requestKeyFilters, view: compactView ? "compact" : undefined, }, }); @@ -8108,9 +8497,10 @@ export function issueRoutes( diagnostics: opts.issueListDiagnostics, compute: async () => { const rawResult = await svc.list(companyId, listFilters); - const result = (await actorCanReadCompanyScope(req, companyId)) + const visibleRows = await actorCanReadCompanyScope(req, companyId) ? rawResult : await filterIssuesForActor(req, rawResult); + const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows); const issueIds = result.map((issue) => issue.id); if (compactView) { const [handoffStates, recoveryActionByIssue] = await Promise.all([ @@ -8275,6 +8665,7 @@ export function issueRoutes( } const blockedCountFilters = { + readCondition: await issueReadSqlCondition(db, req.actor), attention: "blocked", status: req.query.status as string | string[] | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, @@ -8549,6 +8940,7 @@ export function issueRoutes( currentExecutionWorkspacePromise, recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), ]); + const visibleProject = await visibleIssueProject(req, project); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, issue.companyId, @@ -8558,8 +8950,8 @@ export function issueRoutes( relations, recoveryActionsByRelationIssue, ); - const revalidatedActiveRecoveryAction = - await revalidateActiveSourceRecoveryForRead({ + const visibleRelations = await redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions); + const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", actor: getActorInfo(req), @@ -8610,8 +9002,8 @@ export function issueRoutes( projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, parentId: issue.parentId, - blockedBy: relationsWithRecoveryActions.blockedBy, - blocks: relationsWithRecoveryActions.blocks, + blockedBy: visibleRelations.blockedBy, + blocks: visibleRelations.blocks, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, originKind: issue.originKind, @@ -8625,12 +9017,12 @@ export function issueRoutes( status: ancestor.status, priority: ancestor.priority, })), - project: project + project: visibleProject ? { - id: project.id, - name: project.name, - status: project.status, - targetDate: project.targetDate, + id: visibleProject.id, + name: visibleProject.name, + status: visibleProject.status, + targetDate: visibleProject.targetDate, } : null, goal: goal @@ -8838,7 +9230,7 @@ export function issueRoutes( "Issue not found", ); if (!issue) return; - if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue)))) return; + if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true })))) return; const inboxArchiveFieldsPromise = req.actor.type === "board" && req.actor.userId ? timing.time("inbox", () => svc.getActiveInboxArchiveFields(issue, req.actor.userId!)) @@ -8899,6 +9291,13 @@ export function issueRoutes( const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries( relations, recoveryActionsByRelationIssue, ); + const [visibleRelations, visibleReferenceSummary, visibleProject, mentionedProjectVisibility] = await Promise.all([ + redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions), + redactIssueReferenceEdges(req, issue.companyId, referenceSummary), + visibleIssueProject(req, project), + Promise.all(mentionedProjects.map((mentionedProject) => canActorReadProject(req, mentionedProject))), + ]); + const visibleMentionedProjects = mentionedProjects.filter((_, index) => mentionedProjectVisibility[index]); // Recovery revalidation may change the blocker; read it afterwards. const executionBlocker = await timing.time("execution_blocker", () => getExecutionBlocker(db, issue.companyId, issue.id)); res.setHeader("Server-Timing", timing.header()); @@ -8913,19 +9312,15 @@ export function issueRoutes( executionBlocker, scheduledRetry, activeRecoveryAction: revalidatedActiveRecoveryAction, - blockedBy: relationsWithRecoveryActions.blockedBy, - blocks: relationsWithRecoveryActions.blocks, - relatedWork: referenceSummary, - referencedIssueIdentifiers: referenceSummary.outbound.map( - (item) => item.issue.identifier ?? item.issue.id, - ), + blockedBy: visibleRelations.blockedBy, + blocks: visibleRelations.blocks, + relatedWork: visibleReferenceSummary, + referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), ...documentPayload, - project: compactIssueProject(project), + project: compactIssueProject(visibleProject), goal: goal ?? null, - mentionedProjects, - currentExecutionWorkspace: compactIssueExecutionWorkspace( - currentExecutionWorkspace, - ), + mentionedProjects: visibleMentionedProjects, + currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace), workProducts, linkedCases, externalChannelBinding, @@ -11793,6 +12188,37 @@ export function issueRoutes( } : {}), }; + if (createBody.parentId) { + const parent = await svc.getById(createBody.parentId); + if (!parent || parent.companyId !== companyId || !(await assertIssueReadAllowed(req, res, parent))) { + if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" }); + return; + } + } + if (createBody.originRunId) { + const [originRun] = await db.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, createBody.originRunId), eq(heartbeatRuns.companyId, companyId))); + if (!originRun || !(await canActorReadHeartbeatRun(db, access, req.actor, originRun))) { + res.status(404).json({ error: "Origin run not found" }); + return; + } + } + if (createBody.projectId) { + const selectedProject = await projectsSvc.getById(createBody.projectId); + if (!selectedProject || selectedProject.companyId !== companyId) { + res.status(404).json({ error: "Project not found" }); + return; + } + const projectDecision = await access.decide({ + actor: req.actor, + action: "project:read", + resource: { type: "project", companyId, projectId: selectedProject.id }, + }); + if (!projectDecision.allowed) { + res.status(404).json({ error: "Project not found" }); + return; + } + } const createAssignmentScope = { projectId: await resolveCreateAssignmentProjectId({ ...createBody, companyId }), parentIssueId: createBody.parentId ?? null, @@ -11880,16 +12306,14 @@ export function issueRoutes( issue = await svc.create(companyId, ordinaryCreateInput); } if (deduplicationReason) { - const referenceSummary = - await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary); res.status(200).json({ ...issue, deduplicated: true, deduplicationReason, - relatedWork: referenceSummary, - referencedIssueIdentifiers: referenceSummary.outbound.map( - (item) => item.issue.identifier ?? item.issue.id, - ), + relatedWork: visibleReferenceSummary, + referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), }); return; } @@ -12067,12 +12491,11 @@ export function issueRoutes( } await queueTaskWatchdogEvaluation(issue, actor.runId); + const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary); res.status(201).json({ ...issue, - relatedWork: referenceSummary, - referencedIssueIdentifiers: referenceSummary.outbound.map( - (item) => item.issue.identifier ?? item.issue.id, - ), + relatedWork: visibleReferenceSummary, + referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), }); }, ); @@ -12654,6 +13077,146 @@ export function issueRoutes( res.json(result); }); + router.get("/issues/:id/privacy-constraints", async (req, res) => { + const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); + if (!issue || !(await resolveManagedIssueForPrivacy(req, res, issue))) return; + // A task owner need not be able to read its surrounding private scope. + // Read policy internally and return only action hints, never scope identity. + const [project] = issue.projectId ? await db.select({ visibility: projects.visibility, personalOwnerUserId: projects.personalOwnerUserId }) + .from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId))) : []; + const [parent] = issue.privacyParentIssueId ? await db.select({ visibility: issueRows.visibility, projectId: issueRows.projectId }) + .from(issueRows).where(and(eq(issueRows.id, issue.privacyParentIssueId), eq(issueRows.companyId, issue.companyId))) : []; + const [parentProject] = parent?.projectId ? await db.select({ visibility: projects.visibility }) + .from(projects).where(and(eq(projects.id, parent.projectId), eq(projects.companyId, issue.companyId))) : []; + res.json({ + publicBlockedBy: project?.visibility === "private" && !project.personalOwnerUserId ? "project" + : parent?.visibility === "private" || parentProject?.visibility === "private" ? "parent" : null, + leavesPersonalProject: project?.visibility === "private" && Boolean(project.personalOwnerUserId), + } satisfies IssuePrivacyConstraints); + // Visibility writes still recheck these rules under the privacy-tree lock. + }); + + router.get("/issues/:id/access-grants", async (req, res) => { + const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); + if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return; + const managedIssue = await resolveManagedIssueForPrivacy(req, res, issue); + if (!managedIssue) return; + const ancestry = await db.select().from(issueRows).where(inArray(issueRows.id, sql`( + with recursive ancestry as ( + select id, company_id, privacy_parent_issue_id from issues where id = ${issue.id} and company_id = ${issue.companyId} + union + select p.id, p.company_id, p.privacy_parent_issue_id from issues p join ancestry c + on p.id = c.privacy_parent_issue_id and p.company_id = c.company_id + where p.visibility = 'private' or exists (select 1 from projects pp where pp.id = p.project_id and pp.visibility = 'private') + ) select id from ancestry)`)); + const grants = await db.select().from(issueAccessGrants) + .where(inArray(issueAccessGrants.issueId, ancestry.map(task => task.id))) + .orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id)); + const effective: Array = + grants.map(grant => ({ ...grant, inherited: grant.issueId !== issue.id })); + const privateProjectIds = ancestry.flatMap(task => task.projectId ? [task.projectId] : []); + const members = privateProjectIds.length ? await db.select({ member: projectAccessMembers }).from(projectAccessMembers) + .innerJoin(projects, and(eq(projects.id, projectAccessMembers.projectId), eq(projects.companyId, issue.companyId), eq(projects.visibility, "private"))) + .where(inArray(projectAccessMembers.projectId, privateProjectIds)) : []; + for (const { member } of members) effective.push({ + id: `project:${member.id}`, issueId: issue.id, subjectType: member.subjectType, subjectId: member.subjectId, + source: "project", grantedByUserId: null, grantedByAgentId: null, createdAt: member.createdAt, revokedAt: null, inherited: true, + }); + for (const task of ancestry) { + for (const subjectId of new Set([task.responsibleUserId, task.createdByUserId].filter((id): id is string => Boolean(id)))) { + effective.push({ id: `owner:${task.id}:${subjectId}`, issueId: task.id, subjectType: "user", subjectId, + source: "owner", grantedByUserId: null, grantedByAgentId: null, createdAt: task.createdAt, revokedAt: null, inherited: task.id !== issue.id }); + } + } + res.json(await enrichIssueAccessGrants(issue.companyId, effective)); + }); + + router.post( + "/issues/:id/access-grants", + validate(createIssueAccessGrantSchema), + async (req, res) => { + const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); + if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return; + const managedIssue = await resolveManagedIssueForPrivacy(req, res, issue); + if (!managedIssue) return; + if (managedIssue.privacyRootIssueId === null) { + res.status(409).json({ error: "Open issues do not require access grants" }); + return; + } + const subjectType = req.body.subjectType as "user" | "agent"; + const subjectId = req.body.subjectId as string; + await assertIssueGrantSubjectExists(issue.companyId, subjectType, subjectId); + const actor = getActorInfo(req); + const result = await db.transaction((tx) => ensureActiveIssueAccessGrant(tx, { + issueId: managedIssue.id, + subjectType, + subjectId, + source: "explicit", + grantedByUserId: actor.actorType === "user" ? actor.actorId : null, + grantedByAgentId: actor.agentId, + })); + if (result.created) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue_access_grant.created", + entityType: "issue_access_grant", + entityId: result.grant.id, + details: { + issueId: result.grant.issueId, + subjectType: result.grant.subjectType, + subjectId: result.grant.subjectId, + source: result.grant.source, + }, + }); + } + const [enriched] = await enrichIssueAccessGrants(issue.companyId, [result.grant]); + res.status(result.created ? 201 : 200).json(enriched); + }, + ); + + router.post( + "/issues/:id/access-grants/:grantId/revoke", + validate(revokeIssueAccessGrantSchema), + async (req, res) => { + const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); + if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return; + const managedIssue = await resolveManagedIssueForPrivacy(req, res, issue); + if (!managedIssue) return; + const [grant] = await db + .update(issueAccessGrants) + .set({ revokedAt: new Date() }) + .where(and( + eq(issueAccessGrants.id, req.params.grantId as string), + eq(issueAccessGrants.issueId, managedIssue.id), + isNull(issueAccessGrants.revokedAt), + )) + .returning(); + if (!grant) { + res.status(404).json({ error: "Issue access grant not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue_access_grant.revoked", + entityType: "issue_access_grant", + entityId: grant.id, + details: { issueId: grant.issueId, subjectType: grant.subjectType, subjectId: grant.subjectId }, + }); + res.json(grant); + }, + ); + router.post( "/issues/:id/stalled-review-decision", validate(stalledReviewDecisionSchema), @@ -12830,6 +13393,17 @@ export function issueRoutes( "Issue not found", ); if (!existing) return; + if (!(await assertIssueReadAllowed(req, res, existing))) return; + if (req.body.parentId) { + const parent = await svc.getById(req.body.parentId); + if (!parent || parent.companyId !== existing.companyId || !(await assertIssueReadAllowed(req, res, parent))) { + if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" }); + return; + } + } + // An authorized agent may restrict a task; widening access remains an owner/admin action. + if (req.body.visibility !== undefined && !(req.actor.type === "agent" && req.body.visibility === "private") + && !(await resolveManagedIssueForPrivacy(req, res, existing))) return; // MCP task changes cannot stop a run or implicitly decide a pending review. const mcpParticipationChange = req.actor.source === "mcp_oauth" && ["status", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "parentId", "projectId"].some(key => req.body[key] !== undefined); @@ -12839,6 +13413,7 @@ export function issueRoutes( req, collectIssueWorkspaceCommandPaths(req.body), ); + if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) { await auditAgentIssueCommentAttributionSpoof({ db, @@ -12899,6 +13474,34 @@ export function issueRoutes( onBehalfOfUserId: _requestedOnBehalfOfUserId, ...updateFields } = req.body; + if (req.body.visibility === "private" && !existing.projectId && updateFields.projectId === undefined) { + const responsibleUserId = existing.responsibleUserId ?? (req.actor.type === "board" + ? req.actor.userId ?? null + : req.actor.onBehalfOfUserId ?? null); + if (!responsibleUserId) { + res.status(422).json({ error: "Private tasks require a responsible user" }); + return; + } + const personalProject = await db.transaction((tx) => + ensurePersonalPrivateProject(tx, existing.companyId, responsibleUserId)); + updateFields.projectId = personalProject.id; + } + if (updateFields.projectId) { + const selectedProject = await projectsSvc.getById(updateFields.projectId as string); + if (!selectedProject || selectedProject.companyId !== existing.companyId) { + res.status(404).json({ error: "Project not found" }); + return; + } + const projectDecision = await access.decide({ + actor: req.actor, + action: "project:read", + resource: { type: "project", companyId: existing.companyId, projectId: selectedProject.id }, + }); + if (!projectDecision.allowed) { + res.status(404).json({ error: "Project not found" }); + return; + } + } if (existing.conversationAgentId && req.actor.type === "board" && commentBody) { throw unprocessable("Send conversation messages through the comments endpoint with a clientRequestId"); } @@ -13942,9 +14545,7 @@ export function issueRoutes( blockedBy?: unknown; blocks?: unknown; activeRecoveryAction?: unknown; - relatedWork?: Awaited< - ReturnType - >; + relatedWork?: unknown; referencedIssueIdentifiers?: string[]; } = issue; let updatedRelations: Awaited< @@ -13952,13 +14553,13 @@ export function issueRoutes( > | null = null; if (issue && Array.isArray(req.body.blockedByIssueIds)) { updatedRelations = await svc.getRelationSummaries(issue.id); + const visibleUpdatedRelations = await redactIssueRelationEdges(req, issue.companyId, updatedRelations); issueResponse = { ...issue, blockedByIssueIds: - issue.blockedByIssueIds ?? - [...new Set(req.body.blockedByIssueIds as string[])].sort(), - blockedBy: updatedRelations.blockedBy, - blocks: updatedRelations.blocks, + issue.blockedByIssueIds ?? [...new Set(req.body.blockedByIssueIds as string[])].sort(), + blockedBy: visibleUpdatedRelations.blockedBy, + blocks: visibleUpdatedRelations.blocks, }; } await routinesSvc.syncRunStatusForIssue(issue.id); @@ -14435,10 +15036,15 @@ export function issueRoutes( commentReferenceSummaryBefore, commentReferenceSummaryAfter, ); + const visibleCommentReferenceSummary = await redactIssueReferenceEdges( + req, + issue.companyId, + commentReferenceSummaryAfter, + ); issueResponse = { ...issueResponse, - relatedWork: commentReferenceSummaryAfter, - referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map( + relatedWork: visibleCommentReferenceSummary, + referencedIssueIdentifiers: visibleCommentReferenceSummary.outbound.map( (item) => item.issue.identifier ?? item.issue.id, ), }; @@ -14526,10 +15132,11 @@ export function issueRoutes( } } } else if (updateReferenceSummaryAfter) { + const visibleUpdateReferenceSummary = await redactIssueReferenceEdges(req, issue.companyId, updateReferenceSummaryAfter); issueResponse = { ...issueResponse, - relatedWork: updateReferenceSummaryAfter, - referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map( + relatedWork: visibleUpdateReferenceSummary, + referencedIssueIdentifiers: visibleUpdateReferenceSummary.outbound.map( (item) => item.issue.identifier ?? item.issue.id, ), }; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 90825cb1edc..5d7c8744b7c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -70,6 +70,7 @@ import { // Project createProjectSchema, updateProjectSchema, + addProjectAccessMemberSchema, createProjectWorkspaceSchema, updateProjectWorkspaceSchema, // Company @@ -1408,6 +1409,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}", "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", + "POST /api/projects/{id}/access-members", + "DELETE /api/projects/{id}/access-members/{memberId}", "GET /api/companies/{companyId}/secret-provider-configs", "POST /api/companies/{companyId}/secret-provider-configs", "GET /api/companies/{companyId}/secret-providers/health", @@ -1638,6 +1641,7 @@ const CREATED_OPERATIONS = new Set([ "POST /api/issues/{id}/comments", "POST /api/companies/{companyId}/issues/{issueId}/attachments", "POST /api/companies/{companyId}/projects", + "POST /api/projects/{id}/access-members", "POST /api/projects/{id}/workspaces", "POST /api/companies/{companyId}/routines", "POST /api/companies/{companyId}/folders", @@ -1956,6 +1960,28 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/access-grants", + tags: ["issues"], + summary: "Grant a user or agent access to a private issue subtree", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(z.object({ + subjectType: z.enum(["user", "agent"]), + subjectId: z.string().min(1), + }).strict()), + }, + responses: { + 200: r.ok(), + 201: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + registry.registerPath({ method: "get", path: "/api/openapi.json", @@ -4305,6 +4331,52 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/privacy-constraints", + tags: ["issues"], + summary: "Get task privacy action constraints for an authorized manager", + description: "Returns action hints without disclosing protected parent or project identity. Visibility writes recheck the current rules under the privacy-tree lock.", + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: r.ok(z.object({ + publicBlockedBy: z.enum(["parent", "project"]).nullable(), + leavesPersonalProject: z.boolean(), + }).strict()), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/access-grants", + tags: ["issues"], + summary: "List issue access grants", + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/access-grants/{grantId}/revoke", + tags: ["issues"], + summary: "Revoke an issue access grant", + request: { params: z.object({ id: z.string(), grantId: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + registry.registerPath({ method: "get", path: "/api/issues/{id}/approvals", @@ -4676,6 +4748,49 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/projects/{id}/access-members", + tags: ["projects"], + summary: "List project access members", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/access-members", + tags: ["projects"], + summary: "Add a project access member", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addProjectAccessMemberSchema), + }, + responses: { + 201: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 422: r.unprocessable, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}/access-members/{memberId}", + tags: ["projects"], + summary: "Remove a project access member", + request: { params: z.object({ id: z.string(), memberId: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 422: r.unprocessable, + }, +}); + registry.registerPath({ method: "get", path: "/api/projects/{id}/workspaces", diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 4023bdfffce..edfb4a2f127 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,14 +8,19 @@ import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from import { toolAccessService } from "../services/tool-access.js"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; +import { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db"; +import { inArray } from "drizzle-orm"; import { + addProjectAccessMemberSchema, createProjectSchema, projectDiscoverySchema, type ProjectDiscoveryPage, createProjectWorkspaceSchema, + deriveProjectUrlKey, findWorkspaceCommandDefinition, isUuidLike, matchWorkspaceRuntimeServiceToCommand, + normalizeProjectUrlKey, updateProjectSchema, updateProjectWorkspaceSchema, workspaceRuntimeControlTargetSchema, @@ -28,6 +33,7 @@ import { conflict, forbidden, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; +import { ensureProjectAccessMember } from "../services/projects.js"; import { buildWorkspaceRuntimeDesiredStatePatch, listConfiguredRuntimeServiceEntries, @@ -137,11 +143,17 @@ export function projectRoutes(db: Db) { if (isUuidLike(rawId)) return rawId; const companyId = await resolveCompanyIdForProjectReference(req); if (!companyId) return rawId; - const resolved = await svc.resolveByReference(companyId, rawId); - if (resolved.ambiguous) { + const urlKey = normalizeProjectUrlKey(rawId); + if (!urlKey) return rawId; + const visibleProjects = await filterProjectsForActor( + req, + await svc.list(companyId, { includeArchived: true }), + ); + const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey); + if (matches.length > 1) { throw conflict("Project shortname is ambiguous in this company. Use the project ID."); } - return resolved.project?.id ?? rawId; + return matches[0]?.id ?? rawId; } async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) { @@ -151,10 +163,87 @@ export function projectRoutes(db: Db) { resource: { type: "project", companyId: project.companyId, projectId: project.id }, }); if (decision.allowed) return true; - res.status(403).json({ error: "Project is outside this actor's authorization boundary" }); + res.status(404).json({ error: "Project not found" }); return false; } + async function assertCanManageProjectPrivacy(req: Request, res: Response, project: { + companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null; + }) { + const actor = req.actor; + if (actor.type === "board" && actor.userId) { + if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true; + if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true; + const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0); + if (instanceAdmin) return true; + const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships) + .where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1); + if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true; + } + res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" }); + return false; + } + + async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) { + const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId; + if (ownerUserId) await ensureProjectAccessMember(dbOrTx, { + companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId, + }); + if (req.actor.type === "board" && req.actor.userId) { + await ensureProjectAccessMember(dbOrTx, { + companyId: project.companyId, + projectId: project.id, + subjectType: "user", + subjectId: req.actor.userId, + }); + return; + } + if (req.actor.type === "agent" && req.actor.agentId) { + if (req.actor.onBehalfOfUserId) { + await ensureProjectAccessMember(dbOrTx, { + companyId: project.companyId, + projectId: project.id, + subjectType: "user", + subjectId: req.actor.onBehalfOfUserId, + }); + } + await ensureProjectAccessMember(dbOrTx, { + companyId: project.companyId, + projectId: project.id, + subjectType: "agent", + subjectId: req.actor.agentId, + }); + } + } + + async function enrichProjectAccessMembers(rows: Array) { + const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId); + const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId); + const [agentRows, userRows] = await Promise.all([ + agentIds.length === 0 + ? Promise.resolve([]) + : db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)), + userIds.length === 0 + ? Promise.resolve([]) + : db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)), + ]); + const agentsById = new Map(agentRows.map((row) => [row.id, row])); + const usersById = new Map(userRows.map((row) => [row.id, row])); + return rows.map((row) => { + const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null; + const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null; + return { + ...row, + subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null, + subjectAvatarUrl: user?.image ?? null, + }; + }); + } + async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) { const decision = await access.decide({ actor: req.actor, @@ -248,6 +337,7 @@ export function projectRoutes(db: Db) { const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project) return; + if (!(await assertProjectReadAllowed(req, res, project))) return; const summary = await externalObjectsSvc.getProjectSummary(project.id); res.json(summary); }); @@ -261,6 +351,7 @@ export function projectRoutes(db: Db) { }; const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] }; + projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null; const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId ? await projectToolContext(db, req.actor, true) : null; await assertProjectEnvironmentSelection( @@ -305,6 +396,7 @@ export function projectRoutes(db: Db) { if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true); const service = projectService(tx as unknown as Db); const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData); + if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db); const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase())); const registeredUrls: typeof urlRepositories = []; for (const repo of urlRepositories) { @@ -347,7 +439,12 @@ export function projectRoutes(db: Db) { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; const body = { ...req.body }; + if (body.visibility !== undefined) { + if (!(await assertCanManageProjectPrivacy(req, res, existing))) return; + } + if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing); assertNoAgentHostWorkspaceCommandMutation( req, collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), @@ -399,10 +496,101 @@ export function projectRoutes(db: Db) { res.json(project); }); + router.get("/projects/:id/access-members", async (req, res) => { + const id = req.params.id as string; + const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); + if (!project || !(await assertProjectReadAllowed(req, res, project))) return; + const rows = await db + .select() + .from(projectAccessMembers) + .where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id))); + res.json(await enrichProjectAccessMembers(rows)); + }); + + router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); + if (!project || !(await assertProjectReadAllowed(req, res, project))) return; + if (!(await assertCanManageProjectPrivacy(req, res, project))) return; + const subjectType = req.body.subjectType as "user" | "agent"; + const subjectId = req.body.subjectId as string; + const subjectExists = subjectType === "agent" + ? await db.select({ id: agents.id }).from(agents) + .where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId))) + .then((rows) => rows.length > 0) + : await db.select({ id: companyMemberships.id }).from(companyMemberships) + .where(and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, subjectId), + eq(companyMemberships.companyId, project.companyId), + eq(companyMemberships.status, "active"), + )) + .then((rows) => rows.length > 0); + if (!subjectExists) { + res.status(422).json({ error: "Project access member must belong to this company" }); + return; + } + await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId }); + const rows = await db.select().from(projectAccessMembers).where(and( + eq(projectAccessMembers.projectId, project.id), + eq(projectAccessMembers.subjectType, subjectType), + eq(projectAccessMembers.subjectId, subjectId), + )); + const [member] = await enrichProjectAccessMembers(rows); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "project.access_member_added", + entityType: "project", + entityId: project.id, + details: { subjectType, subjectId }, + }); + res.status(201).json(member); + }); + + router.delete("/projects/:id/access-members/:memberId", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); + if (!project || !(await assertProjectReadAllowed(req, res, project))) return; + if (!(await assertCanManageProjectPrivacy(req, res, project))) return; + const member = await db.select().from(projectAccessMembers).where(and( + eq(projectAccessMembers.id, req.params.memberId as string), + eq(projectAccessMembers.companyId, project.companyId), + eq(projectAccessMembers.projectId, project.id), + )).then((rows) => rows[0] ?? null); + if (!member) { + res.status(404).json({ error: "Project access member not found" }); + return; + } + if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) { + res.status(422).json({ error: "The project privacy owner cannot be removed" }); + return; + } + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "project.access_member_removed", + entityType: "project", + entityId: project.id, + details: { subjectType: member.subjectType, subjectId: member.subjectId }, + }); + res.json(member); + }); + router.get("/projects/:id/workspaces", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; const workspaces = await svc.listWorkspaces(id); res.json(workspaces); }); @@ -411,6 +599,7 @@ export function projectRoutes(db: Db) { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; assertNoAgentHostWorkspaceCommandMutation( req, collectProjectWorkspaceCommandPaths(req.body), @@ -450,6 +639,7 @@ export function projectRoutes(db: Db) { const workspaceId = req.params.workspaceId as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; assertNoAgentHostWorkspaceCommandMutation( req, collectProjectWorkspaceCommandPaths(req.body), @@ -496,6 +686,7 @@ export function projectRoutes(db: Db) { const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project) return; + if (!(await assertProjectReadAllowed(req, res, project))) return; const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null; if (!workspace) { @@ -770,6 +961,7 @@ export function projectRoutes(db: Db) { const workspaceId = req.params.workspaceId as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; const workspace = await svc.removeWorkspace(id, workspaceId); if (!workspace) { res.status(404).json({ error: "Project workspace not found" }); @@ -798,6 +990,7 @@ export function projectRoutes(db: Db) { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; + if (!(await assertProjectReadAllowed(req, res, existing))) return; const project = await svc.remove(id); if (!project) { res.status(404).json({ error: "Project not found" }); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index a1553e4a8f5..da4f91680bb 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -11,7 +11,7 @@ import { } from "@paperclipai/shared"; import { forbidden, notFound, unprocessable } from "../errors.js"; import { validate } from "../middleware/validate.js"; -import { authorizationDeniedDetails } from "../services/authorization.js"; +import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js"; import { accessService, heartbeatService, instanceSettingsService, issueService, logActivity, statusCardService } from "../services/index.js"; import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js"; import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; @@ -144,7 +144,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake assertCompanyAccess(req, companyId); await assertStatusCardsEnabled(); const query = listStatusCardsQuerySchema.parse(req.query); - res.json(await service.list(companyId, query.archived)); + res.json(await service.list(companyId, query.archived, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + })); }); router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => { @@ -171,7 +173,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake await assertStatusCardsEnabled(); const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found"); if (!card) return; - res.json(await service.hydrate(card)); + res.json(await service.hydrate(card, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + })); }); router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => { @@ -260,11 +264,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake if (!decision.allowed) { throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision)); } + const issueReadCondition = await issueReadSqlCondition(db, req.actor); res.json({ cardId: card.id, queryVersion: card.queryVersion, - queries: await service.dryRun(card), - mentionedIssues: await service.listMentionedIssues(card), + queries: await service.dryRun(card, { issueReadCondition }), + mentionedIssues: await service.listMentionedIssues(card, { issueReadCondition }), }); }); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 4c75f075273..7021b3e3825 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -174,6 +174,7 @@ export async function persistActivity(db: Db, input: LogActivityInput) { }).returning({ id: activityLog.id }); const payload = { + issueId: input.issueId ?? (input.entityType === "issue" ? input.entityId : null), actorType: input.actorType, actorId: input.actorId, action: input.action, diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 96685ad2870..d171d62966f 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,5 +1,5 @@ import { executionProjectionsForRuns } from "./execution-projection.js"; -import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -27,6 +27,7 @@ export interface ActivityFilters { entityType?: string; entityId?: string; limit?: number; + readCondition?: SQL; } const DEFAULT_ACTIVITY_LIMIT = 100; @@ -366,7 +367,7 @@ export function activityService(db: Db) { ...conditions, or( sql`${activityLog.entityType} != 'issue'`, - visibleIssueCondition(), + and(visibleIssueCondition(), filters.readCondition ?? sql`true`), ), ), ) @@ -435,7 +436,7 @@ export function activityService(db: Db) { and( eq(heartbeatRuns.companyId, companyId), or( - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + eq(heartbeatRuns.issueId, issueId), sql`exists ( select 1 from ${activityLog} @@ -568,7 +569,7 @@ export function activityService(db: Db) { const run = await db .select({ companyId: heartbeatRuns.companyId, - contextSnapshot: heartbeatRuns.contextSnapshot, + issueId: heartbeatRuns.issueId, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) @@ -595,11 +596,7 @@ export function activityService(db: Db) { ) .orderBy(issueIdAsText); - const context = run.contextSnapshot; - const contextIssueId = - context && typeof context === "object" && typeof (context as Record).issueId === "string" - ? ((context as Record).issueId as string) - : null; + const contextIssueId = run.issueId; if (!contextIssueId) return fromActivity; if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity; diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index e2e16a5bc5f..d6ec34cf880 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -1,5 +1,5 @@ import { agentAppearanceSchema } from "@paperclipai/shared"; -import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; @@ -87,9 +87,10 @@ export function approvalService(db: Db) { } return { - list: (companyId: string, status?: string) => { + list: (companyId: string, status?: string, readCondition?: SQL) => { const conditions = [eq(approvals.companyId, companyId)]; if (status) conditions.push(eq(approvals.status, status)); + if (readCondition) conditions.push(readCondition); return db.select().from(approvals).where(and(...conditions)); }, diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index e270f8906c2..dd46de7d91e 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -58,6 +58,7 @@ import { import { executionIssueCondition } from "./issue-visibility.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { isProspectiveBlockedTransition } from "./routable-blocked.js"; +import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js"; import { canonicalizeStoredResolverPolicy } from "./issue-thread-interaction-resolution.js"; import { decisionQueueService } from "./decision-queues.js"; @@ -156,6 +157,8 @@ type BlockingIssueSummary = { type AttentionListOptions = AttentionFeedQuery & { userId?: string | null; + includeDismissed?: boolean; + actor?: AuthorizationActor; /** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */ allowUnscopedAll?: boolean; }; @@ -814,7 +817,12 @@ async function dismissalByKey(db: Db, companyId: string, userId: string | null | }])); } -async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { +async function issueSummaryMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -843,7 +851,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array [row.id, { id: row.id, companyId: row.companyId, @@ -870,7 +883,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { +async function issueImageMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -881,11 +899,13 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array) { +async function planDocumentMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -910,16 +935,23 @@ async function planDocumentMap(db: Db, companyId: string, issueIds: Array [row.issueId, { title: row.title, body: row.body }])); } -async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array) { +async function blockingIssueMap( + db: Db, + companyId: string, + blockedIssueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -937,6 +969,7 @@ async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Arra eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, ids), isNull(issues.hiddenAt), + readCondition, )) .orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id)); const map = new Map(); @@ -1076,6 +1109,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions if (options.all && !options.queue && !options.allowUnscopedAll) { throw badRequest("all requires a queue filter"); } + const readCondition = options.actor + ? await issueReadSqlCondition(db, options.actor) + : sql`true`; const [prefix, dismissals] = await Promise.all([ companyPrefix(db, companyId), dismissalByKey(db, companyId, options.userId), @@ -1120,8 +1156,16 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions for (const row of approvalIssueRows) { if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId); } + const readableApprovalIssues = await issueSummaryMap( + db, + companyId, + approvalIssueRows.map((row) => row.issueId), + readCondition, + ); for (const approval of pendingApprovals) { + const linkedIssueId = approvalIssueMap.get(approval.id) ?? null; + if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue; const dedupKey = `approval:${approval.id}`; const title = approvalTitle(approval.type, approval.payload); add(createItem({ @@ -1213,13 +1257,14 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions ); const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows); const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([ - issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)), - issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)), - planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)), + issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), + issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), + planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), ]); for (const interaction of visibleInteractionRows) { const issue = interactionIssueMap.get(interaction.issueId) ?? null; + if (!issue) continue; const payload = readRecord(interaction.payload); const detail = interactionDetail({ kind: interaction.kind, @@ -1291,7 +1336,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions const bundleIds = [...new Set(openDecisions.map((decision) => decision.bundleId).filter((value): value is string => Boolean(value)))]; const bundleTitleMap = new Map(); const [decisionIssueMap, bundleRows] = await Promise.all([ - issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId)), + issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId), readCondition), bundleIds.length > 0 ? db.select({ id: decisionBundles.id, title: decisionBundles.title }) .from(decisionBundles).where(and(eq(decisionBundles.companyId, companyId), inArray(decisionBundles.id, bundleIds))) @@ -1300,6 +1345,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions for (const row of bundleRows) bundleTitleMap.set(row.id, row.title); for (const decision of openDecisions) { const issue = decisionIssueMap.get(decision.originIssueId) ?? null; + if (decision.originIssueId && !issue) continue; add(createItem({ companyId, sourceKind: "decision", @@ -1400,12 +1446,14 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions db, companyId, recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]), + readCondition, ), - issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId)), + issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition), ]); for (const recovery of recoveryRows) { const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; + if (!sourceIssue) continue; const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null; const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`; add(createItem({ @@ -1450,7 +1498,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions })); } - const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); + const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true, readCondition }); type BlockedAttentionIssue = IssueSubjectRow & { blockerAttention?: { state?: string; @@ -1467,14 +1515,15 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions .map((issue) => issue.blockerAttention?.terminalBlockerIssueId) .filter((issueId): issueId is string => Boolean(issueId)); const [blockedIssueSummaries, terminalBlockerSummaries, blockerImageMap, blockingIssues] = await Promise.all([ - issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)), - issueSummaryMap(db, companyId, terminalBlockerIssueIds), + issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition), + issueSummaryMap(db, companyId, terminalBlockerIssueIds, readCondition), issueImageMap( db, companyId, [...blockedIssues.map((issue) => issue.id), ...terminalBlockerIssueIds], + readCondition, ), - blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)), + blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition), ]); const terminalCandidates = new Map row.id); const pendingReviewApprovalRows = reviewIssueIds.length === 0 @@ -1603,8 +1657,8 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId])); const [reviewAttentionByIssueId, reviewIssueMap, reviewImageMap] = await Promise.all([ issueService(db).listReviewAttention(companyId, reviewRows), - issueSummaryMap(db, companyId, reviewIssueIds), - issueImageMap(db, companyId, reviewIssueIds), + issueSummaryMap(db, companyId, reviewIssueIds, readCondition), + issueImageMap(db, companyId, reviewIssueIds, readCondition), ]); for (const review of reviewRows) { @@ -1673,8 +1727,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions db, companyId, failedIssueIds, + readCondition, ), - issueImageMap(db, companyId, failedIssueIds), + issueImageMap(db, companyId, failedIssueIds, readCondition), oldestFailedRunCreatedAt && failedAgentIds.length > 0 ? db .select({ @@ -1709,6 +1764,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions if (hasNewerRun) continue; const issue = issueId ? failedIssueMap.get(issueId) ?? null : null; + if (issueId && !issue) continue; const dedupKey = `run:${run.id}`; add(createItem({ companyId, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 6cc78e7a526..47732acf76e 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1,16 +1,24 @@ -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { withHumanDirectedWork } from "./human-directed-work.js"; import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js"; import { agents, + approvals, + issueApprovals, authUsers, companyMemberships, + chatConversations, + chatDeliveries, + chatIdentityLinks, heartbeatRuns, + executionWorkspaces, instanceUserRoles, issueComments, + issueAccessGrants, issues, principalPermissionGrants, + projectAccessMembers, projects, userInboxAgentPolicies, } from "@paperclipai/db"; @@ -140,6 +148,8 @@ export type AuthorizationDecision = { | "deny_no_grant" | "deny_policy_restricted" | "deny_low_trust_boundary" + | "deny_issue_private" + | "deny_project_private" | "deny_scope" | "deny_unsupported_action"; grant?: { @@ -262,7 +272,7 @@ type ProjectAuthorizationRow = { type IssueAuthorizationRow = { id: string; companyId: string; - projectId: string | null; + projectId?: string | null; parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; @@ -502,6 +512,160 @@ type ResponsibleUserActorWithMemo = AuthorizationActor & { __responsibleUserSnapshotMemo?: Map>; }; +const responsibleUserSnapshotCache = new Map< + string, + { expiresAt: number; promise: Promise } +>(); + +export type IssuePrivacyRow = { + id: string; + companyId: string; + visibility: string; + privacyRootIssueId: string | null; + responsibleUserId: string | null; + createdByUserId: string | null; + assigneeUserId: string | null; + assigneeAgentId: string | null; + projectId?: string | null; +}; + +export type ProjectPrivacyRow = { + id: string; + companyId: string; + visibility: string; +}; + +export function issuePrivacyMode(): "off" | "shadow" | "enforce" { + const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase(); + if (mode === "off" || mode === "shadow") return mode; + return "enforce"; +} + +function issuePrivacyPrincipal(actor: AuthorizationActor) { + if (actor.type === "board" && actor.userId) return { type: "user" as const, id: actor.userId }; + if (actor.type === "agent" && actor.agentId) return { type: "agent" as const, id: actor.agentId }; + return null; +} + +function projectPrivacyPrincipalCondition(actor: AuthorizationActor): SQL { + const principal = issuePrivacyPrincipal(actor); + return sql`(${projects.visibility} = 'open' or ${principal ? sql`exists ( + select 1 from ${projectAccessMembers} m where m.company_id = ${projects.companyId} + and m.project_id = ${projects.id} and m.subject_type = ${principal.type} + and m.subject_id = ${principal.id})` : sql`false`})`; +} + +function projectPrivacyCondition(actor: AuthorizationActor): SQL { + const own = projectPrivacyPrincipalCondition(actor); + if (actor.type !== "agent" || !actor.onBehalfOfUserId) return own; + return sql`(${own} and ${projectPrivacyPrincipalCondition({ type: "board", userId: actor.onBehalfOfUserId })} + and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId} + and m.principal_type = 'user' and m.principal_id = ${actor.onBehalfOfUserId} and m.status = 'active'))`; +} + +export async function canActorReadProjectPrivacy(db: Db | DbTransaction, actor: AuthorizationActor, project: ProjectPrivacyRow) { + return db.select({ id: projects.id }).from(projects) + .where(and(eq(projects.id, project.id), eq(projects.companyId, project.companyId), projectPrivacyCondition(actor))) + .limit(1).then(rows => rows.length > 0); +} + +/** One predicate for direct reads and SQL pushdown. Grants flow only from a task + * to its privacy descendants, never from a child to its ancestors or siblings. + * UNION bounds traversal even if historical data contains a cycle. */ +function issuePrivacyPrincipalCondition(actor: AuthorizationActor): SQL { + const principal = issuePrivacyPrincipal(actor); + const implicit = principal?.type === "agent" + ? sql`n.assignee_agent_id = ${principal.id}` + : principal?.type === "user" + ? sql`(n.responsible_user_id = ${principal.id} or n.created_by_user_id = ${principal.id} or n.assignee_user_id = ${principal.id})` + : sql`false`; + const grant = principal ? sql`exists ( + select 1 from ${issueAccessGrants} g where g.issue_id = n.id + and g.subject_type = ${principal.type} and g.subject_id = ${principal.id} + and g.revoked_at is null + )` : sql`false`; + const projectMember = principal ? sql`exists ( + select 1 from ${projectAccessMembers} m + join ${projects} p on p.id = m.project_id and p.company_id = m.company_id + where p.id = n.project_id and p.company_id = n.company_id and p.visibility = 'private' + and m.subject_type = ${principal.type} and m.subject_id = ${principal.id} + )` : sql`false`; + return sql`exists ( + with recursive privacy_ancestors as ( + select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id, + p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id + from issues p where p.id = ${issues.id} and p.company_id = ${issues.companyId} + union + select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id, + p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id + from issues p join privacy_ancestors c on p.id = c.privacy_parent_issue_id and p.company_id = c.company_id + where p.visibility = 'private' or exists (select 1 from projects pp where pp.id = p.project_id and pp.company_id = p.company_id and pp.visibility = 'private') + ) + select 1 from privacy_ancestors n + where ${implicit} or ${grant} or ${projectMember} + or not exists ( + select 1 from privacy_ancestors restricted + left join projects private_project on private_project.id = restricted.project_id + and private_project.company_id = restricted.company_id + where restricted.visibility = 'private' or private_project.visibility = 'private' + ) + )`; +} + +function issuePrivacyCondition(actor: AuthorizationActor): SQL { + const principalCondition = issuePrivacyPrincipalCondition(actor); + if (actor.type !== "agent" || !actor.onBehalfOfUserId) return principalCondition; + const userId = actor.onBehalfOfUserId; + // A shared agent's grants cannot amplify the user whose run is making the call. + return sql`(${principalCondition} and ${issuePrivacyPrincipalCondition({ type: "board", userId })} + and exists (select 1 from ${companyMemberships} m where m.company_id = ${issues.companyId} + and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`; +} + +export async function canActorReadIssuePrivacy(db: Db | DbTransaction, actor: AuthorizationActor, issue: IssuePrivacyRow) { + return db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), issuePrivacyCondition(actor))) + .limit(1).then(rows => rows.length > 0); +} + +/** Filter every externally reachable task query with the same privacy predicate. */ +export async function issueReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { + if (issuePrivacyMode() !== "enforce") return sql`true`; + if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`true`; + if (actor.type === "board" && !actor.ignoreInstanceAdmin && actor.source !== "cloud_tenant" + && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, actor.userId ?? ""), eq(instanceUserRoles.role, "instance_admin"))) + .limit(1).then(rows => rows.length > 0)) return sql`true`; + return issuePrivacyCondition(actor); +} + +/** SQL equivalent used for project list/search pushdown. */ +export async function projectReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { + if (issuePrivacyMode() !== "enforce") return sql`true`; + if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`true`; + if ( + actor.type === "board" + && !actor.ignoreInstanceAdmin + && actor.source !== "cloud_tenant" + && Boolean(await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and( + eq(instanceUserRoles.userId, actor.userId ?? ""), + eq(instanceUserRoles.role, "instance_admin"), + )) + .limit(1) + .then((rows) => rows[0] ?? null)) + ) return sql`true`; + return projectPrivacyCondition(actor); +} + +function responsibleUserSnapshotTtlMs() { + const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim(); + if (!raw) return 5_000; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000; +} export function responsibleUserAuthzShadowMode() { const mode = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE?.trim().toLowerCase(); const shadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW?.trim().toLowerCase(); @@ -2462,7 +2626,84 @@ export function authorizationService(db: Db | DbTransaction) { scope?: Record | null; }): Promise { const agentDecision = await decideBase(input); - return applyResponsibleUserIntersection(input, agentDecision); + const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision); + if ( + input.action === "project:read" + && input.resource.type === "project" + && input.resource.projectId + && intersectedDecision.allowed + && intersectedDecision.reason !== "allow_instance_admin" + && intersectedDecision.reason !== "allow_local_board" + && issuePrivacyMode() !== "off" + ) { + const project = await db + .select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility }) + .from(projects) + .where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId))) + .then((rows) => rows[0] ?? null); + if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) { + const denied = deny({ + action: input.action, + reason: "deny_project_private", + explanation: "Project is private and the actor is not an access member.", + }); + logger.warn({ + authzMode: issuePrivacyMode(), + action: input.action, + companyId: project.companyId, + projectId: project.id, + actorType: input.actor.type, + actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, + actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, + reason: denied.reason, + }, "project privacy would deny read"); + return issuePrivacyMode() === "shadow" ? intersectedDecision : denied; + } + } + if ( + input.action !== "issue:read" + || input.resource.type !== "issue" + || !input.resource.issueId + || !intersectedDecision.allowed + || intersectedDecision.reason === "allow_instance_admin" + || intersectedDecision.reason === "allow_local_board" + || issuePrivacyMode() === "off" + ) return intersectedDecision; + + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, + responsibleUserId: issues.responsibleUserId, + createdByUserId: issues.createdByUserId, + assigneeUserId: issues.assigneeUserId, + assigneeAgentId: issues.assigneeAgentId, + projectId: issues.projectId, + }) + .from(issues) + .where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId))) + .then((rows) => rows[0] ?? null); + if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision; + + const denied = deny({ + action: input.action, + reason: "deny_issue_private", + explanation: "Issue is private and the actor is not an implicit principal or active grantee.", + }); + logger.warn({ + authzMode: issuePrivacyMode(), + action: input.action, + companyId: issue.companyId, + issueId: issue.id, + privacyRootIssueId: issue.privacyRootIssueId, + actorType: input.actor.type, + actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, + actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, + reason: denied.reason, + }, "issue privacy would deny read"); + return issuePrivacyMode() === "shadow" ? intersectedDecision : denied; } // A candidate filter only: project policies and responsible-user grants are @@ -2490,3 +2731,83 @@ export function authorizationService(db: Db | DbTransaction) { decidePrincipalGrant, }; } + +/** A workspace can contain several tasks. Reading files/operations requires + * access to every task bound to it, not just the task that originally made it. */ +export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { + const issueCondition = await issueReadSqlCondition(db, actor); + const projectCondition = await projectReadSqlCondition(db, actor); + return sql`( + (${executionWorkspaces.projectId} is null or exists ( + select 1 from ${projects} where ${projects.id} = ${executionWorkspaces.projectId} + and ${projects.companyId} = ${executionWorkspaces.companyId} and ${projectCondition})) + and not exists (select 1 from jsonb_object_keys(coalesce(${executionWorkspaces.metadata}->'_issuePrivacySources', '{}'::jsonb)) source(id) + where not exists (select 1 from ${issues} where ${issues.id}::text = source.id + and ${issues.companyId} = ${executionWorkspaces.companyId} and ${issueCondition})) + and not exists (select 1 from ${issues} + where ${issues.companyId} = ${executionWorkspaces.companyId} + and (${issues.executionWorkspaceId} = ${executionWorkspaces.id} or ${issues.id} = ${executionWorkspaces.sourceIssueId}) + and not ${issueCondition}) + )`; +} + +/** An approval may contain task-derived payload; every linked task must be readable. */ +export async function approvalReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { + const readable = await issueReadSqlCondition(db, actor); + return sql`not exists ( + select 1 from (values (${approvals.payload}->>'issueId'), (${approvals.payload}->>'sourceIssueId')) claimed(id) + where claimed.id is not null and not exists ( + select 1 from ${issues} where ${issues.id}::text = claimed.id and ${issues.companyId} = ${approvals.companyId}) + ) and not exists ( + select 1 from ${issues} where ${issues.companyId} = ${approvals.companyId} + and (${issues.id}::text = ${approvals.payload}->>'issueId' + or ${issues.id}::text = ${approvals.payload}->>'sourceIssueId' + or exists (select 1 from ${issueApprovals} ia where ia.approval_id = ${approvals.id} + and ia.company_id = ${approvals.companyId} and ia.issue_id = ${issues.id})) + and not (${readable}) + )`; +} + +export async function canActorReadApproval(db: Db | DbTransaction, actor: AuthorizationActor, approvalId: string) { + return db.select({ id: approvals.id }).from(approvals) + .where(and(eq(approvals.id, approvalId), await approvalReadSqlCondition(db, actor))) + .limit(1).then(rows => rows.length > 0); +} + +export async function canActorReadExecutionWorkspace(db: Db, actor: AuthorizationActor, id: string) { + return db.select({ id: executionWorkspaces.id }).from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, id), await executionWorkspaceReadSqlCondition(db, actor))) + .limit(1).then(rows => rows.length > 0); +} + +/** Recheck the external audience immediately before publishing task-derived content. */ +export async function canPublishIssueToChatAudience( + tx: Db | DbTransaction, + publication: { issueId: string; companyId: string; conversationId: string; endpointId: string }, +): Promise { + const [privacyIssue] = await tx.select().from(issues) + .where(and(eq(issues.id, publication.issueId), eq(issues.companyId, publication.companyId))); + if (!privacyIssue) return false; + if (!(await canActorReadIssuePrivacy(tx, { type: "none" }, privacyIssue))) { + const [conversation] = await tx.select().from(chatConversations).where(and( + eq(chatConversations.id, publication.conversationId), eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId))); + // Shared channels have no bounded, authenticated recipient set. Private + // output may leave only through a DM whose known recipients still qualify. + if (!conversation?.isDirectMessage) return false; + const recipients = await tx.selectDistinct({ principalId: chatDeliveries.principalId }).from(chatDeliveries) + .where(and(eq(chatDeliveries.conversationId, conversation.id), eq(chatDeliveries.companyId, publication.companyId), + inArray(chatDeliveries.eventKind, ["message", "mention"]))); + if (!recipients.length || recipients.some(recipient => !recipient.principalId)) return false; + for (const recipient of recipients) { + const [link] = await tx.select().from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.companyId, publication.companyId), eq(chatIdentityLinks.endpointId, publication.endpointId), + eq(chatIdentityLinks.principalId, recipient.principalId!), eq(chatIdentityLinks.status, "linked"), isNull(chatIdentityLinks.revokedAt))); + if (!link?.paperclipUserId) return false; + const allowed = await authorizationService(tx).decide({ actor: { type: "board", userId: link.paperclipUserId, source: "session", ignoreInstanceAdmin: true }, + action: "issue:read", resource: { type: "issue", companyId: publication.companyId, issueId: publication.issueId } }); + if (!allowed.allowed) return false; + } + } + return true; +} diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 3dd8aeb3682..c8b2628a228 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,4 @@ +import { authorizationService, canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js"; import { withSlackBoardLease } from "./slack-board-lease.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { authorizeSlackBoardPublication } from "./slack-board-authority.js"; @@ -11757,6 +11758,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if (!admission?.allowed) throw deny(); authorization.userId = admission.responsibleUserId ?? null; } + if (!(await canActorReadIssuePrivacy(tx, authorization.userId + ? { type: "board", userId: authorization.userId } + : { type: "none" }, issue))) throw deny(); const expectedUserId = payload.requestedByActorType === "user" ? payload.requestedByActorId @@ -13627,6 +13631,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect, ): Promise { + if (!(await canPublishIssueToChatAudience(tx, publication))) return false; const notice = parseInboundWakePublicationKey(publication.idempotencyKey); let runId = runIdFromMilestonePublication(publication); if (!runId && publication.commentId && !notice) { diff --git a/server/src/services/company-search-extract.ts b/server/src/services/company-search-extract.ts index e8c59d17f15..81c491ace36 100644 --- a/server/src/services/company-search-extract.ts +++ b/server/src/services/company-search-extract.ts @@ -151,7 +151,11 @@ function extractMatches(sources: ExtractSource[], query: CompanySearchExtractQue export function companySearchExtractService(db: Db) { return { - extract: async (companyId: string, query: CompanySearchExtractQuery): Promise => { + extract: async ( + companyId: string, + query: CompanySearchExtractQuery, + options?: { issueReadCondition?: SQL }, + ): Promise => { const containsPattern = `%${escapeLikePattern(query.contains)}%`; const urlPattern = urlContainsPattern(query.contains); const scopeConditions: SQL[] = []; @@ -198,6 +202,7 @@ export function companySearchExtractService(db: Db) { visibleIssueCondition(), or(...scopeConditions)!, ]; + if (options?.issueReadCondition) conditions.push(options.issueReadCondition); if (query.status.length > 0) conditions.push(inArray(issues.status, query.status)); const updatedWithin = updatedWithinStart(query.updatedWithin); if (updatedWithin) conditions.push(gte(issues.updatedAt, updatedWithin)); diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 8c6e96bafd4..220967e362d 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -539,7 +539,11 @@ export function companySearchService(db: Db) { const extractService = companySearchExtractService(db); return { extract: extractService.extract, - search: async (companyId: string, query: CompanySearchQuery): Promise => { + search: async ( + companyId: string, + query: CompanySearchQuery, + options?: { issueReadCondition?: SQL; projectReadCondition?: SQL }, + ): Promise => { const taskSearch = parseTaskSearch(query.q); const normalizedQuery = taskSearch.normalizedQuery; const hasSearchText = normalizedQuery.length > 0; @@ -718,7 +722,7 @@ export function companySearchService(db: Db) { } const resultRows = await db.execute(sql` - ${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters))} + ${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters), options?.issueReadCondition)} ${sql.join(branches, sql` UNION ALL `)} `) as unknown as Array>; @@ -868,6 +872,7 @@ export function companySearchService(db: Db) { sql`${projects.name}`, sql`${projects.description}`, ], containsPattern, tokenPatternArray); + const projectReadCondition = options?.projectReadCondition ?? sql`true`; async function fetchAgentRows() { if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return []; @@ -897,7 +902,7 @@ export function companySearchService(db: Db) { updatedAt: projects.updatedAt, }) .from(projects) - .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)) + .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition)) .orderBy(desc(projects.updatedAt), desc(projects.id)) .limit(fetchLimit); } @@ -984,7 +989,7 @@ export function companySearchService(db: Db) { const rows = await db .select({ count: sql`count(*)::int` }) .from(projects) - .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)); + .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition)); return Number(rows[0]?.count ?? 0); } diff --git a/server/src/services/decision-training.ts b/server/src/services/decision-training.ts index 0134b8fb789..c920b35724a 100644 --- a/server/src/services/decision-training.ts +++ b/server/src/services/decision-training.ts @@ -47,6 +47,7 @@ type SourceDecision = { }; type ListInput = { + readCondition?: SQL; projectId?: string; kind?: DecisionTrainingSourceKind; author?: string; @@ -304,6 +305,7 @@ export function decisionTrainingService(db: Db) { }, list: async (companyId: string, input: ListInput = {}) => { const filters: SQL[] = [eq(decisionTrainingExamples.companyId, companyId)]; + if (input.readCondition) filters.push(input.readCondition); if (input.projectId) filters.push(eq(issues.projectId, input.projectId)); if (input.kind) filters.push(eq(decisionTrainingExamples.sourceKind, input.kind)); if (input.author) filters.push(eq(decisionTrainingExamples.createdByUserId, input.author)); diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index acda541cd66..864e0510b89 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -452,7 +452,7 @@ export async function buildExecutionContinuation(input: { kind: row.kind, status: row.status, result: row.result, - })), ...await childReviewOutcomes(db, companyId, issueId)], + })), ...await childReviewOutcomes(db, companyId, issueId, { type: "agent", agentId: input.agentId, companyId, onBehalfOfUserId: issue.responsibleUserId })], // Low-trust evidence only: renderPaperclipWakePrompt removes completedWork // from requestContext and encodes it in the fenced, non-authoritative // continuation-evidence section. It cannot supply objective or authority. diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 2382faef829..c3bbb536196 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,3 +1,4 @@ +import type { SQL } from "drizzle-orm"; import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -1832,9 +1833,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic issueId?: string; status?: string; reuseEligible?: boolean; + readCondition?: SQL; }, ) { const conditions = [eq(executionWorkspaces.companyId, companyId)]; + if (filters?.readCondition) conditions.push(filters.readCondition); if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); if (filters?.projectWorkspaceId) { conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); @@ -1869,8 +1872,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic listOverview: async ( companyId: string, filters: WorkspaceOverviewQuery, + readCondition?: SQL, ): Promise => { const conditions = buildOverviewConditions(companyId, filters); + if (readCondition) conditions.push(readCondition); const whereClause = and(...conditions); const [totalRow, rows] = await Promise.all([ @@ -2081,6 +2086,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic issueId?: string; status?: string; reuseEligible?: boolean; + readCondition?: SQL; }) => { const conditions = buildListConditions(companyId, filters); const rows = await db @@ -2107,6 +2113,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic issueId?: string; status?: string; reuseEligible?: boolean; + readCondition?: SQL; }) => { const conditions = buildListConditions(companyId, filters); const rows = await db diff --git a/server/src/services/heartbeat-run-privacy.ts b/server/src/services/heartbeat-run-privacy.ts new file mode 100644 index 00000000000..64027132012 --- /dev/null +++ b/server/src/services/heartbeat-run-privacy.ts @@ -0,0 +1,169 @@ +import type { Db } from "@paperclipai/db"; +import { issues, heartbeatRuns } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { canActorReadExecutionWorkspace, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js"; + +type RunIssueBinding = { + companyId: string; + scopeKind?: "company" | "issue" | null; + issueId?: string | null; +}; + +export async function canActorReadHeartbeatRun( + db: Db, + access: { + decide(input: { + actor: AuthorizationActor; + action: "issue:read"; + resource: { + type: "issue"; + companyId: string; + issueId: string; + projectId: null; + parentIssueId: null; + assigneeAgentId: null; + assigneeUserId: null; + }; + }): Promise; + }, + actor: AuthorizationActor, + run: RunIssueBinding, +): Promise { + if (run.scopeKind === "company") return true; + if (!run.issueId) return false; + + const issueExists = await db + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, run.issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows.length > 0); + if (!issueExists) return false; + + const decision = await access.decide({ + actor, + action: "issue:read", + resource: { + type: "issue", + companyId: run.companyId, + issueId: run.issueId, + projectId: null, + parentIssueId: null, + assigneeAgentId: null, + assigneeUserId: null, + }, + }); + return decision.allowed; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finiteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function firstFinite(...values: unknown[]): number | null { + for (const value of values) { + const parsed = finiteNumber(value); + if (parsed !== null) return parsed; + } + return null; +} + +function pickTokenUsage(value: unknown) { + const usage = asRecord(value); + if (!usage) return null; + const picked = { + inputTokens: firstFinite(usage.inputTokens, usage.input_tokens), + cachedInputTokens: firstFinite(usage.cachedInputTokens, usage.cached_input_tokens), + outputTokens: firstFinite(usage.outputTokens, usage.output_tokens), + totalTokens: firstFinite(usage.totalTokens, usage.total_tokens), + }; + return Object.values(picked).some((item) => item !== null) ? picked : null; +} + +function runDurationMs(row: Record) { + const startedAt = row.startedAt instanceof Date ? row.startedAt : new Date(String(row.startedAt ?? "")); + const finishedAt = row.finishedAt instanceof Date ? row.finishedAt : new Date(String(row.finishedAt ?? "")); + if (Number.isNaN(startedAt.getTime())) return null; + const endMs = Number.isNaN(finishedAt.getTime()) ? Date.now() : finishedAt.getTime(); + return Math.max(0, endMs - startedAt.getTime()); +} + +/** + * Locked list-row disclosure for an issue-bound run the actor cannot read. + * Keep only identity, timing, status, and budget-oversight fields. + */ +export function redactHeartbeatRunListRow & { id: string }>(row: T) { + const usage = asRecord(row.usageJson); + const result = asRecord(row.resultJson); + const costUsd = firstFinite( + row.costUsd, + result?.costUsd, + result?.cost_usd, + result?.total_cost_usd, + usage?.costUsd, + usage?.cost_usd, + usage?.total_cost_usd, + ); + + return { + id: row.id, + companyId: row.companyId, + agentId: row.agentId, + agentName: row.agentName, + adapterType: row.adapterType, + invocationSource: row.invocationSource, + status: row.status, + startedAt: row.startedAt ?? null, + finishedAt: row.finishedAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + durationMs: runDurationMs(row), + costUsd, + usageJson: pickTokenUsage(row.usageJson ?? { + inputTokens: row.inputTokens, + cachedInputTokens: row.cachedInputTokens, + outputTokens: row.outputTokens, + totalTokens: row.totalTokens, + }), + redacted: true, + }; +} + +/** Check all durable sources, including tombstones left after a task/run is deleted. */ +export async function canActorReadWorkspaceOperation( + db: Db, + access: Parameters[1], + actor: AuthorizationActor, + operation: { companyId: string; issueId?: string | null; heartbeatRunId?: string | null; executionWorkspaceId?: string | null; metadata?: Record | null }, +): Promise { + const sourceIds = (key: string, current?: string | null) => { + const history = asRecord(operation.metadata?.[key]); + return new Set([...Object.keys(history ?? {}), ...(current ? [current] : [])]); + }; + const issueIds = sourceIds("_issuePrivacySources", operation.issueId); + const runIds = sourceIds("_runPrivacySources", operation.heartbeatRunId); + for (const issueId of issueIds) { + if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(issueId)) return false; + if (!(await canActorReadHeartbeatRun(db, access, actor, { companyId: operation.companyId, scopeKind: "issue", issueId }))) return false; + } + for (const runId of runIds) { + if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(runId)) return false; + const [run] = await db.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, operation.companyId))).limit(1); + if (!run || !(await canActorReadHeartbeatRun(db, access, actor, run))) return false; + } + if (operation.executionWorkspaceId) { + if (!(await canActorReadExecutionWorkspace(db, actor, operation.executionWorkspaceId))) return false; + } + return issueIds.size > 0 || runIds.size > 0 || Boolean(operation.executionWorkspaceId); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 75edc6d8f5b..9f9b697fe98 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3338,9 +3338,20 @@ const heartbeatRunListColumns = { responsibleUserId: heartbeatRuns.responsibleUserId, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, status: heartbeatRuns.status, + inputTokens: sql`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"), + cachedInputTokens: sql`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"), + outputTokens: sql`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"), + totalTokens: sql`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"), + costUsd: sql`coalesce( + (${heartbeatRuns.resultJson} ->> 'costUsd')::numeric, + (${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric, + (${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric + )`.as("costUsd"), startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, error: heartbeatRuns.error, @@ -3395,34 +3406,17 @@ const heartbeatRunSummaryListColumns = { } 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", - ), + contextIssueId: sql`coalesce( + ${heartbeatRuns.issueId}::text, + ${heartbeatRuns.contextSnapshot} ->> 'issueId' + )`.as("contextIssueId"), + contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), + contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), + contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), + contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), } as const; const heartbeatRunListResultColumns = { @@ -3612,6 +3606,8 @@ const heartbeatRunSqlAsciiSafeColumns = { const heartbeatRunLogAccessColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, logStore: heartbeatRuns.logStore, logRef: heartbeatRuns.logRef, } as const; @@ -3645,9 +3641,7 @@ const heartbeatRunIssueSummaryColumns = { lastOutputSeq: heartbeatRuns.lastOutputSeq, lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes, - issueId: sql< - string | null - >`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), + issueId: heartbeatRuns.issueId, } as const; function appendExcerpt(prev: string, chunk: string) { @@ -14315,6 +14309,8 @@ export function heartbeatService( .values({ companyId: run.companyId, agentId: run.agentId, + scopeKind: "issue", + issueId, invocationSource: "automation", triggerDetail: "system", status: "queued", @@ -15538,7 +15534,10 @@ export function heartbeatService( : null; const transientRetryNotBefore = transientRecovery?.retryNotBefore ?? null; const contextSnapshot = parseObject(run.contextSnapshot); - const issueId = readNonEmptyString(contextSnapshot.issueId); + // A retry inherits the durable authorization scope of its source run. Do + // not promote an untrusted or legacy contextSnapshot.issueId into a new + // issue binding: that could either violate the FK or misclassify history. + const issueId = run.scopeKind === "issue" ? run.issueId : null; if (!baseSchedule) { const exhaustion = { @@ -16112,6 +16111,8 @@ export function heartbeatService( id: scheduledRunId, companyId: run.companyId, agentId: run.agentId, + scopeKind: run.scopeKind, + issueId, invocationSource: "automation", triggerDetail: "system", status: "scheduled_retry", @@ -28927,6 +28928,8 @@ export function heartbeatService( ...(explicitContinuation ? { id: explicitContinuationRunId } : {}), companyId: agent.companyId, agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", + issueId: readNonEmptyString(enrichedContextSnapshot.issueId), invocationSource: source, triggerDetail, status: "queued", @@ -29199,6 +29202,8 @@ export function heartbeatService( .values({ companyId: agent.companyId, agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", + issueId: readNonEmptyString(enrichedContextSnapshot.issueId), invocationSource: source, triggerDetail, status: "queued", diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index dc1b6df6e27..3b4c78bc075 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -114,6 +114,7 @@ import { } from "./issue-review-policy.js"; import { issueService, + ensureAssignmentIssueAccessGrant, readAcceptedPlanConfirmationTarget, runWorkspaceIsFinalized, } from "./issues.js"; @@ -3693,6 +3694,13 @@ export function issueThreadInteractionService( }) .returning(); + if (row.addresseeAgentId || row.addresseeUserId) { + const [privacyIssue] = await tx.select().from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))); + await ensureAssignmentIssueAccessGrant(tx, { ...privacyIssue!, + assigneeAgentId: row.addresseeAgentId, assigneeUserId: row.addresseeUserId, + }, null, { agentId: actor.agentId, userId: actor.userId }); + } + // An agent replacing its own still-pending card supersedes the older // one so the thread never accumulates stale sibling cards. This covers // request_confirmation drafts and ordinary task questions. Agent Chat diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b477ab358f9..f8c862ad91b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -55,6 +55,7 @@ import { routineRuns, executionWorkspaces, issueApprovals, + issueAccessGrants, issueAttachments, issueCreateIdempotencyKeys, issueInboxArchives, @@ -197,6 +198,7 @@ import { type ActivityPublication, } from "./activity-log.js"; import { buildIssueChanges } from "./issue-change-receipt.js"; +import { ensurePersonalPrivateProject } from "./projects.js"; import { projectSafeChatPublication } from "./chat-publication-projection.js"; import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js"; @@ -1830,6 +1832,7 @@ export interface IssueFilters { includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; lowTrustBoundary?: LowTrustBoundary & { companyId: string }; + readCondition?: SQL; q?: string; limit?: number; offset?: number; @@ -3602,6 +3605,84 @@ function summarizeIssueRelationRow( }; } +/** Inline draft images become task attachments in the same transaction as publication. */ +async function attachOwnedDraftImages(tx: DbTransaction, issue: typeof issues.$inferSelect, body: string | null | undefined, + actor: { userId?: string | null; agentId?: string | null }) { + const ids = [...new Set(Array.from((body ?? "").matchAll(/\/api\/assets\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/content/gi), match => match[1]!))]; + if (!ids.length || (!actor.userId && !actor.agentId)) return; + const owned = await tx.select().from(assets).where(and(eq(assets.companyId, issue.companyId), inArray(assets.id, ids), + actor.userId ? eq(assets.createdByUserId, actor.userId) : eq(assets.createdByAgentId, actor.agentId!))); + for (const asset of owned) { + if (!asset.objectKey.startsWith(`${issue.companyId}/assets/issues/drafts/`)) continue; + await tx.insert(issueAttachments).values({ companyId: issue.companyId, issueId: issue.id, assetId: asset.id }).onConflictDoNothing(); + } +} + +export async function ensureAssignmentIssueAccessGrant( + dbOrTx: any, + issue: typeof issues.$inferSelect, + previous: Pick | null, + actor: { agentId?: string | null; userId?: string | null }, +) { + if (issue.visibility !== "private") return null; + const subject = issue.assigneeAgentId + ? { subjectType: "agent" as const, subjectId: issue.assigneeAgentId } + : issue.assigneeUserId + ? { subjectType: "user" as const, subjectId: issue.assigneeUserId } + : null; + if (!subject) return null; + if ( + previous + && previous.visibility === "private" + && previous.assigneeAgentId === issue.assigneeAgentId + && previous.assigneeUserId === issue.assigneeUserId + ) return null; + + const grantIssueId = issue.id; + const lockKey = `issue-access-grant:${grantIssueId}:${subject.subjectType}:${subject.subjectId}`; + await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); + const active = await dbOrTx + .select({ id: issueAccessGrants.id }) + .from(issueAccessGrants) + .where(and( + eq(issueAccessGrants.issueId, grantIssueId), + eq(issueAccessGrants.subjectType, subject.subjectType), + eq(issueAccessGrants.subjectId, subject.subjectId), + isNull(issueAccessGrants.revokedAt), + )) + .limit(1) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (active) return null; + + const grant = await dbOrTx + .insert(issueAccessGrants) + .values({ + issueId: grantIssueId, + ...subject, + source: "assignment", + grantedByUserId: actor.userId ?? null, + grantedByAgentId: actor.agentId ?? null, + }) + .returning() + .then((rows: Array) => rows[0]!); + await logActivity(dbOrTx as Db, { + companyId: issue.companyId, + actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system", + actorId: actor.agentId ?? actor.userId ?? "issue_service", + agentId: actor.agentId ?? null, + action: "issue_access_grant.created", + entityType: "issue_access_grant", + entityId: grant.id, + details: { + issueId: grant.issueId, + subjectType: grant.subjectType, + subjectId: grant.subjectId, + source: grant.source, + }, + }); + return grant; +} + async function terminalExplicitBlockersByRoot( companyId: string, roots: IssueRelationIssueSummary[], @@ -4843,9 +4924,6 @@ async function listIssueReviewAttentionMap( } const issueListSelect = { - visibility: issues.visibility, - privacyRootIssueId: issues.privacyRootIssueId, - privacyParentIssueId: issues.privacyParentIssueId, externalConversationState: externalConversationStateSql(), conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, @@ -4858,6 +4936,9 @@ const issueListSelect = { projectWorkspaceId: issues.projectWorkspaceId, goalId: issues.goalId, parentId: issues.parentId, + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, + privacyParentIssueId: issues.privacyParentIssueId, title: issues.title, titleNeedsGeneration: issues.titleNeedsGeneration, description: sql` @@ -6197,6 +6278,7 @@ async function blockedInboxIssueConditions( visibleIssueCondition(), notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), ]; + if (filters?.readCondition) conditions.push(filters.readCondition); const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; @@ -7934,6 +8016,7 @@ export function issueService(db: Db) { } } if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId)); + if (filters?.readCondition) conditions.push(filters.readCondition); const assigneeAgentFilter = parseIssueAssigneeAgentFilter( filters?.assigneeAgentId, ); @@ -8085,7 +8168,7 @@ export function issueService(db: Db) { const issueSource = db.select(issueListSelect).from(issues); const searchedSource = hasSearch ? issueSource.innerJoin(sql`( - ${taskSearchCtes(companyId, taskSearch, true, and(...conditions))} + ${taskSearchCtes(companyId, taskSearch, true, and(...conditions), filters?.readCondition)} SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m ) task_search`, sql`task_search.id = ${issues.id}`) : issueSource; @@ -8261,6 +8344,7 @@ export function issueService(db: Db) { } const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; + if (filters?.readCondition) conditions.push(filters.readCondition); if (!filters?.q?.trim()) { conditions.push(isNull(issues.conversationAgentId)); if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) { @@ -9851,6 +9935,7 @@ export function issueService(db: Db) { throw unprocessable("in_progress issues require an assignee"); } const persist = async (tx: DbTransaction) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`); await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId); if (issueData.conversationAgentId && issueData.conversationUserId) { const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`; @@ -10196,8 +10281,64 @@ export function issueService(db: Db) { }, ); + let visibility = issueData.visibility ?? "open"; + let privacyRootIssueId: string | null = null; + // Provenance survives standalone chat handoffs. This is an access edge, + // independent of the product's structural parent/child relationship. + let privacyParentIssueId = issueData.parentId ?? null; + const sourceRunId = issueData.originRunId ?? actorRunId; + if (!privacyParentIssueId && sourceRunId) { + const [sourceRun] = await tx.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId))); + const sourceId = sourceRun?.nativeIssueId ?? sourceRun?.issueId ?? sourceRun?.contextSnapshot?.issueId; + if (typeof sourceId === "string") privacyParentIssueId = sourceId; + } + let inheritedPrivateSubtree = false; + if (privacyParentIssueId) { + const [source] = await tx.select().from(issues) + .where(and(eq(issues.id, privacyParentIssueId), eq(issues.companyId, companyId))); + if (!source) privacyParentIssueId = null; + else if (source.visibility === "private" || (source.projectId && await tx.select({ id: projects.id }).from(projects) + .where(and(eq(projects.id, source.projectId), eq(projects.visibility, "private"))).limit(1).then(rows => rows.length > 0))) { + visibility = "private"; + privacyRootIssueId = source.privacyRootIssueId ?? source.id; + inheritedPrivateSubtree = true; + } + } + if (visibility === "private" && !privacyRootIssueId) { + issueData.id ??= randomUUID(); + privacyRootIssueId = issueData.id; + } + + if (issueData.projectId) { + const selectedProject = await tx + .select({ visibility: projects.visibility }) + .from(projects) + .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!selectedProject) throw notFound("Project not found"); + if (selectedProject.visibility === "private") { + visibility = "private"; + if (!privacyRootIssueId) { + issueData.id ??= randomUUID(); + privacyRootIssueId = issueData.id; + } + } + } + + if (visibility === "private" && !issueData.projectId && !inheritedPrivateSubtree) { + if (!responsibleUserId) { + throw unprocessable("Private tasks require a responsible user"); + } + const personalProject = await ensurePersonalPrivateProject(tx, companyId, responsibleUserId); + issueData.projectId = personalProject.id; + } + const values = { ...issueData, + visibility, + privacyRootIssueId, + privacyParentIssueId, titleNeedsGeneration, originRunId: issueData.originRunId ?? actorRunId ?? null, responsibleUserId, @@ -10241,6 +10382,20 @@ export function issueService(db: Db) { ); const [issue] = await tx.insert(issues).values(values).returning(); + await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId }); + if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) { + await tx.insert(issueAccessGrants).values({ + issueId: issue.id, + subjectType: "agent", + subjectId: issueData.createdByAgentId, + source: "explicit", + grantedByAgentId: issueData.createdByAgentId, + }).onConflictDoNothing(); + } + await ensureAssignmentIssueAccessGrant(tx, issue, null, { + agentId: issueData.createdByAgentId ?? null, + userId: issueData.createdByUserId ?? null, + }); await recordChatHandoff(tx, issue, actorRunId); if (idempotencyKey) { await tx.insert(issueCreateIdempotencyKeys).values({ @@ -10764,6 +10919,23 @@ export function issueService(db: Db) { ...issueData, updatedAt: new Date(), }; + if (issueData.visibility === "private") { + patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id; + } else if (issueData.visibility === "open") { + patch.privacyRootIssueId = null; + } + if (issueData.projectId) { + const selectedProject = await dbOrTx + .select({ visibility: projects.visibility }) + .from(projects) + .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, existing.companyId))) + .then((rows: Array<{ visibility: string }>) => rows[0] ?? null); + if (!selectedProject) throw notFound("Project not found"); + if (selectedProject.visibility === "private") { + patch.visibility = "private"; + patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id; + } + } if (existing.status !== "blocked" && issueData.status === "blocked") { patch.blockedTransitionAt = patch.updatedAt; patch.blockedOwnerNotifiedAt = null; @@ -10958,6 +11130,8 @@ export function issueService(db: Db) { } const runUpdate = async (tx: any) => { + const changesPrivacy = issueData.visibility !== undefined || issueData.parentId !== undefined || issueData.projectId !== undefined; + if (changesPrivacy) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existing.companyId}`}, 0))`); // The receipt baseline must be read under the same row lock as the // write. Otherwise a concurrent update can be mistaken for a change // made by this request. @@ -10968,6 +11142,32 @@ export function issueService(db: Db) { .for("update") .then((rows: Array) => rows[0] ?? null); if (!receiptExisting) return null; + if (changesPrivacy) { + const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : receiptExisting.projectId; + const [privacyProject] = nextProjectId ? await tx.select().from(projects) + .where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId))) : []; + if (privacyProject?.visibility === "private") { + if (issueData.visibility === "open") { + if (!privacyProject.personalOwnerUserId) throw unprocessable("Move this task out of its private project before making it public"); + patch.projectId = null; + } else { + patch.visibility = "private"; + patch.privacyRootIssueId = receiptExisting.privacyRootIssueId ?? receiptExisting.id; + } + } + const privacyParentId = issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId; + const [privacyParent] = privacyParentId ? await tx.select().from(issues) + .where(and(eq(issues.id, privacyParentId), eq(issues.companyId, existing.companyId))) : []; + patch.privacyParentIssueId = privacyParent?.id ?? null; + if (privacyParent && (privacyParent.visibility === "private" || (privacyParent.projectId && await tx.select({ id: projects.id }).from(projects) + .where(and(eq(projects.id, privacyParent.projectId), eq(projects.visibility, "private"))).limit(1).then((rows: Array<{ id: string }>) => rows.length > 0)))) { + if (issueData.visibility === "open") throw unprocessable("A task cannot be made public while it inherits private access"); + patch.visibility = "private"; + patch.privacyRootIssueId = privacyParent.privacyRootIssueId ?? privacyParent.id; + } else if (issueData.visibility === "private") { + patch.privacyRootIssueId = receiptExisting.id; + } + } if (actorAgentId && actorRunId) { // Recheck under a run lock: a request admitted before Stop must not // commit a late Done after cancellation revoked its credentials. @@ -11040,6 +11240,33 @@ export function issueService(db: Db) { .returning() .then((rows: Array) => rows[0] ?? null); if (!updated) return null; + if (issueData.description !== undefined) await attachOwnedDraftImages(tx, updated, updated.description, + { userId: actorUserId, agentId: actorAgentId }); + if (changesPrivacy && updated.visibility === "private") { + // Creation and tree changes hold the same company lock. No child can + // slip into the tree between discovering descendants and protecting them. + const protectedRows = await tx.execute(sql`with recursive descendants as ( + select i.id, i.parent_id, i.privacy_parent_issue_id from issues i + where i.id = ${updated.id} and i.company_id = ${updated.companyId} + union + select i.id, i.parent_id, i.privacy_parent_issue_id from issues i join descendants p + on i.parent_id = p.id or i.privacy_parent_issue_id = p.id + where i.company_id = ${updated.companyId} + ) update issues i set visibility = 'private', + privacy_root_issue_id = ${updated.privacyRootIssueId ?? updated.id}, + privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id), updated_at = now() + from descendants d where i.id = d.id and i.id <> ${updated.id} returning i.id`); + if (protectedRows.length) { + const descendants = await tx.select().from(issues).where(inArray(issues.id, protectedRows.map((row: { id: string }) => row.id))); + for (const descendant of descendants) await ensureAssignmentIssueAccessGrant(tx, descendant, null, { + agentId: actorAgentId ?? null, userId: actorUserId ?? null, + }); + } + } + await ensureAssignmentIssueAccessGrant(tx, updated, receiptExisting, { + agentId: actorAgentId ?? null, + userId: actorUserId ?? null, + }); await recordChatCompletion(tx, receiptExisting, updated); // An operator explicitly choosing a disposition owns that decision, // including choosing In Review while the conversation is Idle. diff --git a/server/src/services/native-runtime/child-review-outcomes.ts b/server/src/services/native-runtime/child-review-outcomes.ts index 11bbcc1e4ec..c09e1a7dad4 100644 --- a/server/src/services/native-runtime/child-review-outcomes.ts +++ b/server/src/services/native-runtime/child-review-outcomes.ts @@ -1,12 +1,14 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { heartbeatRuns, issues, issueThreadInteractions, statusDecisions, type Db } from "@paperclipai/db"; import type { ExecutionContinuationEnvelope } from "@paperclipai/shared"; +import { issueReadSqlCondition, type AuthorizationActor } from "../authorization.js"; /** Recorded decisions are evidence for the parent, never new review authority. */ export async function childReviewOutcomes( db: Db, companyId: string, parentIssueId: string, + actor?: AuthorizationActor, ): Promise { const rows = await db.select({ id: issueThreadInteractions.id, @@ -40,6 +42,7 @@ export async function childReviewOutcomes( .where(and( eq(issues.companyId, companyId), eq(issues.parentId, parentIssueId), + actor ? await issueReadSqlCondition(db, actor) : undefined, eq(issueThreadInteractions.kind, "request_confirmation"), inArray(issueThreadInteractions.status, ["accepted", "rejected"]), sql`${issueThreadInteractions.payload}->'target'->>'type' = 'custom'`, diff --git a/server/src/services/native-runtime/handoff-plan-context.ts b/server/src/services/native-runtime/handoff-plan-context.ts index ed403a7f4d3..5529f7699bf 100644 --- a/server/src/services/native-runtime/handoff-plan-context.ts +++ b/server/src/services/native-runtime/handoff-plan-context.ts @@ -1,9 +1,11 @@ import { and, desc, eq, lte } from "drizzle-orm"; import { documentRevisions, heartbeatRuns, issueDocuments, issues, issueThreadInteractions, type Db } from "@paperclipai/db"; +import { canActorReadIssuePrivacy, type AuthorizationActor } from "../authorization.js"; + /** Approval evidence belongs to the source conversation and exact revision. * It informs scope; it does not approve the new task's document or waive gates. */ -export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelect) { +export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelect, actor?: AuthorizationActor) { if (!task.originRunId || task.conversationAgentId) return null; const [sourceRun] = await db.select().from(heartbeatRuns).where(and( eq(heartbeatRuns.id, task.originRunId), eq(heartbeatRuns.companyId, task.companyId), @@ -12,6 +14,7 @@ export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelec if (typeof sourceId !== "string" || sourceId === task.id) return null; const [source] = await db.select().from(issues).where(and(eq(issues.id, sourceId), eq(issues.companyId, task.companyId))); if (!source?.conversationAgentId) return null; + if (actor && !(await canActorReadIssuePrivacy(db, actor, source))) return null; const accepted = await db.select().from(issueThreadInteractions).where(and( eq(issueThreadInteractions.companyId, task.companyId), eq(issueThreadInteractions.issueId, source.id), eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"), diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 4bcca613086..7aebb789555 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1285,16 +1285,15 @@ describe("PaperclipRunnerToolAuthority", () => { it("captures delegation and approval origins before steering and preserves replay identity", async () => { const issueId = "00000000-0000-4000-8000-000000000120"; const runId = "00000000-0000-4000-8000-000000000121"; - for (const userId of ["person-a", "person-b"]) { - await db.insert(authUsers).values({ id: userId, name: userId, email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); - await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" }); - } + await db.insert(issues).values({ id: issueId, companyId, title: "Identity delegation", status: "in_progress", assigneeAgentId: agentId }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } }); await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + await db.insert(authUsers).values(["person-a", "person-b"].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() }))); + await db.insert(companyMemberships).values(["person-a", "person-b"].map(principalId => ({ companyId, principalType: "user", principalId, status: "active", membershipRole: "operator" }))); const origin = await initializeRunIdentity(db, { companyId, runId, issueId, responsibleUserId: "person-a", cause: "instruction", }); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 4b9b69a2c13..8a78ed58846 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -12,7 +12,7 @@ import { agentCommentaryToolInputSchema } from "@paperclipai/shared"; import { submitAgentCommentary } from "../agent-commentary.js"; import type { createAssignedMcpTools } from "./assigned-mcp-tools.js"; import { assertAssignableAgent } from "../agent-assignability.js"; -import { authorizationService } from "../authorization.js"; +import { approvalReadSqlCondition, canActorReadApproval, authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../authorization.js"; import { resolveCoreTrustPreset } from "../trust-preset-resolver.js"; import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js"; import { buildLowTrustSourceTrust } from "../source-trust.js"; @@ -482,6 +482,7 @@ export class PaperclipRunnerToolAuthority { eq(issues.companyId, this.binding.companyId), eq(issues.parentId, this.binding.issueId), isNull(issues.hiddenAt), + await issueReadSqlCondition(this.db, this.#privacyActor(context.run)), )) .orderBy(desc(issues.createdAt), desc(issues.id)) .limit(101); @@ -502,8 +503,8 @@ export class PaperclipRunnerToolAuthority { }, connectionGuidance: CONNECTION_INTENT_AGENT_GUIDANCE, acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot), - sourcePlanApproval: await handoffPlanContext(this.db, context.issue), - childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId), + sourcePlanApproval: await handoffPlanContext(this.db, context.issue, this.#privacyActor(context.run)), + childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId, this.#privacyActor(context.run)), ...(this.binding.nativeReview ? { assignedReview: (await getNativeReviewAssignment(this.db, { ...this.binding, contextSnapshot: this.binding.nativeReview, @@ -531,7 +532,7 @@ export class PaperclipRunnerToolAuthority { return { comments: comments.reverse() }; } case "search_tasks": { - const tasks = await issueService(this.db).list(this.binding.companyId); + const tasks = await issueService(this.db).list(this.binding.companyId, { readCondition: await issueReadSqlCondition(this.db, this.#privacyActor(context.run)) }); const query = typeof input.query === "string" ? input.query.toLowerCase() : ""; const statuses = Array.isArray(input.statuses) ? new Set(input.statuses.filter((value): value is string => typeof value === "string")) : null; return { tasks: tasks.filter((task) => @@ -557,7 +558,7 @@ export class PaperclipRunnerToolAuthority { return { actor: redactedActor(actor) }; } case "list_approvals": - return { approvals: await approvalService(this.db).list(this.binding.companyId) }; + return { approvals: await approvalService(this.db).list(this.binding.companyId, undefined, await approvalReadSqlCondition(this.db, this.#privacyActor(context.run))) }; case "get_approval": { const approval = await this.#approval(requiredString(input.approvalId)); return { approval }; @@ -570,6 +571,7 @@ export class PaperclipRunnerToolAuthority { eq(issueApprovals.approvalId, approval.id), eq(issueApprovals.companyId, this.binding.companyId), eq(issues.companyId, this.binding.companyId), + await issueReadSqlCondition(this.db, this.#privacyActor(context.run)), )); return { approval, tasks: tasks.map((row) => row.issue) }; } @@ -783,9 +785,16 @@ export class PaperclipRunnerToolAuthority { async #approval(id: string) { const approval = await approvalService(this.db).getById(id); if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found"); + const context = await this.#boundContext(); + if (!(await canActorReadApproval(this.db, this.#privacyActor(context.run), id))) throw new Error("paperclip_runner_approval_not_found"); return approval; } + #privacyActor(run: { responsibleUserId: string | null }): AuthorizationActor { + return { type: "agent", agentId: this.binding.agentId, companyId: this.binding.companyId, + runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId }; + } + async #boundContext() { const [row] = await this.db.select({ issue: issues, actor: agents, run: heartbeatRuns }) .from(heartbeatRuns) @@ -826,6 +835,9 @@ export class PaperclipRunnerToolAuthority { throw forbidden("The assigned review is no longer available to this run."); } } + const privacy = await authorizationService(this.db).decide({ actor: this.#privacyActor(row.run), action: "issue:read", + resource: { type: "issue", companyId: this.binding.companyId, issueId: row.issue.id } }); + if (!privacy.allowed) throw forbidden("The task is no longer available to this run"); return row; } diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 2fac28c3b91..d6213fb8b37 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -27,7 +27,7 @@ import type { PluginIssueOrchestrationSummary, PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; -import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; +import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, IssueRelationIssueSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; @@ -77,7 +77,14 @@ import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; import { accessService } from "./access.js"; -import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { + authorizationService, + canActorReadIssuePrivacy, + issuePrivacyMode, + issueReadSqlCondition, + type AuthorizationActor, + type IssuePrivacyRow, +} from "./authorization.js"; import { redactEventPayload, sanitizeRecord } from "../redaction.js"; import type { WorkerHostCallContext } from "@paperclipai/plugin-sdk"; import { @@ -760,6 +767,135 @@ export function buildHostServices( const scopedBus = eventBus.forPlugin(pluginKey); const lifecycleInbox = pluginLifecycleInbox(db, pluginId); + // --- Issue privacy for plugin-facing reads (PAP-16091 / PAP-16050) --------- + // Plugins are non-member principals: they hold no company membership, issue + // access grant, or private-project access, so the canonical issue-visibility + // predicate resolves them to the same scope as any other non-member — public + // issues only. Notification/digest plugins (e.g. a Slack notifier) fan issue + // data out to shared channels whose audience is, by definition, non-members, + // so private issue content and relationship metadata must never reach them. + // + // A synthetic { type: "none" } actor produces exactly that scope: it yields a + // null privacy principal (no implicit/grant/project-member match) and does not + // trip the local-implicit / instance-admin bypasses inside the predicate. In + // "off"/"shadow" modes these helpers are pass-through, matching every other + // enforcement surface so the enforce flip stays the single behavioral switch. + const pluginIssuePrivacyActor: AuthorizationActor = { type: "none" }; + const toIssuePrivacyRow = (issue: { + id: string; + companyId: string; + visibility?: string | null; + privacyRootIssueId?: string | null; + responsibleUserId?: string | null; + createdByUserId?: string | null; + assigneeUserId?: string | null; + assigneeAgentId?: string | null; + projectId?: string | null; + }): IssuePrivacyRow => ({ + id: issue.id, + companyId: issue.companyId, + visibility: issue.visibility ?? "open", + privacyRootIssueId: issue.privacyRootIssueId ?? null, + responsibleUserId: issue.responsibleUserId ?? null, + createdByUserId: issue.createdByUserId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + assigneeAgentId: issue.assigneeAgentId ?? null, + projectId: issue.projectId ?? null, + }); + const pluginCanReadIssue = async ( + issue: Parameters[0] | null | undefined, + ): Promise => { + if (!issue) return false; + if (issuePrivacyMode() !== "enforce") return true; + return canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)); + }; + const pluginReadableIssueIds = async ( + issueList: Array[0]>, + ): Promise> => { + if (issuePrivacyMode() !== "enforce") return new Set(issueList.map((issue) => issue.id)); + const decisions = await Promise.all( + issueList.map(async (issue) => ({ + id: issue.id, + allowed: await canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)), + })), + ); + return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id)); + }; + // Column projection matching IssuePrivacyRow, for ad-hoc readability lookups + // of bare issue IDs (relation targets, subtree descendants) that arrive + // without their privacy columns loaded. + const issuePrivacyRowColumns = { + id: issuesTable.id, + companyId: issuesTable.companyId, + visibility: issuesTable.visibility, + privacyRootIssueId: issuesTable.privacyRootIssueId, + responsibleUserId: issuesTable.responsibleUserId, + createdByUserId: issuesTable.createdByUserId, + assigneeUserId: issuesTable.assigneeUserId, + assigneeAgentId: issuesTable.assigneeAgentId, + projectId: issuesTable.projectId, + } as const; + // Resolve which of the given issue IDs a non-member plugin may read. Loads + // the privacy-relevant columns for those IDs and evaluates the canonical + // predicate. An ID with no surviving row (deleted, cross-company) is absent + // from the result set — fail closed. + const pluginReadableIssueIdsByIds = async ( + companyId: string, + ids: Iterable, + ): Promise> => { + const uniqueIds = [...new Set(ids)]; + if (issuePrivacyMode() !== "enforce" || uniqueIds.length === 0) { + return new Set(uniqueIds); + } + const rows = await db + .select(issuePrivacyRowColumns) + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, uniqueIds))); + return pluginReadableIssueIds(rows); + }; + // Fetch an issue and require it be both in-company and readable by a + // non-member plugin. A private (unreadable) issue throws the same "not found" + // error a genuinely missing issue would, so its existence never leaks. This + // is the single gate every issue-derived plugin read routes through, so no + // method needs its own bespoke visibility check. + const requirePluginReadableIssue = async (issueId: string, companyId: string): Promise => { + const issue = requireInCompany("Issue", await issues.getById(issueId), companyId); + if (!(await pluginCanReadIssue(issue))) { + throw new Error("Issue not found"); + } + return issue as Issue; + }; + // Redact a relation summary so blocker/blocked edges pointing at an issue the + // plugin cannot read are dropped, recursing into nested terminal blockers. An + // edge's title/status/assignee is relationship metadata a non-member must not + // see even when the issue that owns the edge is itself public. + const pluginRedactRelationSummary = async < + R extends { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] }, + >( + companyId: string, + relation: R, + ): Promise => { + if (issuePrivacyMode() !== "enforce") return relation; + const edgeIds = new Set(); + const collect = (edges: IssueRelationIssueSummary[]) => { + for (const edge of edges) { + edgeIds.add(edge.id); + if (edge.terminalBlockers?.length) collect(edge.terminalBlockers); + } + }; + collect(relation.blockedBy); + collect(relation.blocks); + if (edgeIds.size === 0) return relation; + const readable = await pluginReadableIssueIdsByIds(companyId, edgeIds); + const redact = (edges: IssueRelationIssueSummary[]): IssueRelationIssueSummary[] => + edges + .filter((edge) => readable.has(edge.id)) + .map((edge) => (edge.terminalBlockers?.length + ? { ...edge, terminalBlockers: redact(edge.terminalBlockers) } + : edge)); + return { ...relation, blockedBy: redact(relation.blockedBy), blocks: redact(relation.blocks) }; + }; + // Track active session event subscriptions for cleanup const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType }>(); let disposed = false; @@ -1912,13 +2048,21 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); assertReadableOriginFilter(params.originKind); - return applyWindow((await issues.list(companyId, params as any)) as Issue[], params); + // Push the non-member visibility predicate into the list query so private + // issues are filtered at the SQL layer before they can reach a plugin. + const readCondition = await issueReadSqlCondition(db, pluginIssuePrivacyActor); + return applyWindow( + (await issues.list(companyId, { ...(params as any), readCondition })) as Issue[], + params, + ); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = await issues.getById(params.issueId); - return (inCompany(issue, companyId) ? issue : null) as Issue | null; + if (!inCompany(issue, companyId)) return null; + if (!(await pluginCanReadIssue(issue))) return null; + return issue as Issue; }, async create(params) { const companyId = ensureCompanyId(params.companyId); @@ -1929,6 +2073,17 @@ export function buildHostServices( ? pluginOperationIssueOriginKind(pluginKey) : originKind, ); + if (issueInput.parentId) await requirePluginReadableIssue(issueInput.parentId, companyId); + const sourceRunId = params.originRunId ?? actorRunId; + if (sourceRunId) { + const [sourceRun] = await db.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId))); + if (!sourceRun) throw new Error("Origin run not found"); + if (sourceRun.scopeKind !== "company") { + if (!sourceRun.issueId) throw new Error("Origin run not found"); + await requirePluginReadableIssue(sourceRun.issueId, companyId); + } + } const issue = (await issues.create(companyId, { ...(issueInput as any), originKind: normalizedOriginKind, @@ -1960,6 +2115,7 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + await requirePluginReadableIssue(params.issueId, companyId); const patch = { ...(params.patch as Record) }; const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null; const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null; @@ -1996,13 +2152,20 @@ export function buildHostServices( async getRelations(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); - return await issues.getRelationSummaries(params.issueId); + // Private root -> indistinguishable from missing; readable root -> + // still redact any edge that points at an issue the plugin cannot read. + await requirePluginReadableIssue(params.issueId, companyId); + return await pluginRedactRelationSummary( + companyId, + await issues.getRelationSummaries(params.issueId), + ); }, async setBlockedBy(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - return setBlockedByWithActivity({ + // The echoed relation summary can name a private blocker target, so + // redact unreadable edges before returning it to a non-member plugin. + return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: params.blockedByIssueIds, @@ -2010,7 +2173,7 @@ export function buildHostServices( actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, - }); + })); }, async addBlockers(params) { const companyId = ensureCompanyId(params.companyId); @@ -2023,7 +2186,7 @@ export function buildHostServices( ...params.blockerIssueIds, ]), ]; - return setBlockedByWithActivity({ + return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: nextBlockedByIssueIds, @@ -2031,7 +2194,7 @@ export function buildHostServices( actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, - }); + })); }, async removeBlockers(params) { const companyId = ensureCompanyId(params.companyId); @@ -2042,7 +2205,7 @@ export function buildHostServices( const nextBlockedByIssueIds = previous.blockedBy .map((relation) => relation.id) .filter((issueId) => !removals.has(issueId)); - return setBlockedByWithActivity({ + return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: nextBlockedByIssueIds, @@ -2050,7 +2213,7 @@ export function buildHostServices( actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, - }); + })); }, async assertCheckoutOwner(params) { const companyId = ensureCompanyId(params.companyId); @@ -2089,17 +2252,31 @@ export function buildHostServices( async getSubtree(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + // A private root is invisible to a non-member plugin: report not-found + // rather than leak its existence or any descendant metadata. + const rootIssue = await requirePluginReadableIssue(params.issueId, companyId); const includeRoot = params.includeRoot !== false; const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id); - const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id); - const issueRows = issueIds.length > 0 + const candidateIssueIds = includeRoot + ? subtreeIssueIds + : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id); + const issueRows = candidateIssueIds.length > 0 ? await db .select() .from(issuesTable) - .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds))) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, candidateIssueIds))) : []; - const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue])); + // Filter private descendants BEFORE fetching any relations, documents, + // runs, or assignees so private issue rows and their metadata never + // enter the result. The root has already cleared the readability gate. + const readableSubtreeIds = await pluginReadableIssueIds(issueRows); + readableSubtreeIds.add(rootIssue.id); + const issueIds = candidateIssueIds.filter((issueId) => readableSubtreeIds.has(issueId)); + const issuesById = new Map( + issueRows + .filter((issue) => readableSubtreeIds.has(issue.id)) + .map((issue) => [issue.id, issue as Issue]), + ); const outputIssues = issueIds .map((issueId) => issuesById.get(issueId)) .filter((issue): issue is Issue => Boolean(issue)); @@ -2110,7 +2287,10 @@ export function buildHostServices( const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([ params.includeRelations - ? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const)) + ? Promise.all(issueIds.map(async (issueId) => [ + issueId, + await pluginRedactRelationSummary(companyId, await issues.getRelationSummaries(issueId)), + ] as const)) : Promise.resolve(null), params.includeDocuments ? Promise.all( @@ -2308,12 +2488,27 @@ export function buildHostServices( async getOrchestrationSummary(params): Promise { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); - const subtreeIssueIds = params.includeSubtree + // A private root is invisible to a non-member plugin: report not-found + // rather than leak its existence or any subtree relationship metadata. + const rootIssue = await requirePluginReadableIssue(params.issueId, companyId); + const rawSubtreeIssueIds = params.includeSubtree ? await collectIssueSubtreeIds(companyId, rootIssue.id) : [rootIssue.id]; + // Drop any private descendant the plugin may not read so its relations, + // approvals, runs, costs, and budget blocks never enter the summary. + const readableSubtreeIds = await pluginReadableIssueIdsByIds(companyId, rawSubtreeIssueIds); + readableSubtreeIds.add(rootIssue.id); + const subtreeIssueIds = rawSubtreeIssueIds.filter((id) => readableSubtreeIds.has(id)); + // Blocker edges can point at private issues outside the readable subtree + // (e.g. a public task blocked by a private one). Their title/status/ + // assignee are relationship metadata a non-member must not see, so + // pluginRedactRelationSummary drops any edge whose target is unreadable, + // recursing into nested terminal blockers. const relationPairs = await Promise.all( - subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const), + subtreeIssueIds.map(async (issueId) => [ + issueId, + await pluginRedactRelationSummary(companyId, await issues.getRelationSummaries(issueId)), + ] as const), ); const approvalRows = ( await Promise.all( @@ -2383,7 +2578,10 @@ export function buildHostServices( async listComments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return []; + // Comment visibility follows the ACL of the issue they were posted on. + if (!(await pluginCanReadIssue(issue))) return []; return (await issues.listComments(params.issueId)) as IssueComment[]; }, async createComment(params) { @@ -2498,7 +2696,11 @@ export function buildHostServices( async listInteractions(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return []; + // Interaction payloads follow the ACL of the issue they belong to; a + // private issue yields no interactions to a non-member plugin. + if (!(await pluginCanReadIssue(issue))) return []; return (await interactions.listForIssue(params.issueId)) as any; }, async respondInteraction(params) { @@ -2589,7 +2791,9 @@ export function buildHostServices( async listAttachments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return []; + if (!(await pluginCanReadIssue(issue))) return []; return (await issues.listAttachments(params.issueId)) as any; }, async getAttachmentContent(params) { @@ -2599,6 +2803,9 @@ export function buildHostServices( // Unknown and cross-company ids are deliberately indistinguishable to // the plugin: both return null (no existence oracle across companies). if (!attachment || attachment.companyId !== companyId) return null; + // A private issue's attachment content must not reach a non-member + // plugin: gate on the parent issue's visibility, same null response. + if (!(await pluginCanReadIssue(await issues.getById(attachment.issueId)))) return null; const maxBytes = typeof params.maxBytes === "number" && params.maxBytes > 0 ? params.maxBytes : null; if (maxBytes !== null && attachment.byteSize > maxBytes) { @@ -2729,14 +2936,15 @@ export function buildHostServices( async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); + // Document bodies follow the issue ACL; a private issue is not-found. + await requirePluginReadableIssue(params.issueId, companyId); const rows = await documents.listIssueDocuments(params.issueId); return rows as any; }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); + await requirePluginReadableIssue(params.issueId, companyId); const doc = await documents.getIssueDocumentByKey(params.issueId, params.key); return (doc ?? null) as any; }, diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 9fe3efc255f..b5cd4d2445a 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -2,6 +2,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { projects, + projectAccessMembers, projectGoals, goals, issues, @@ -59,6 +60,69 @@ type CreateWorkspaceInput = { }; type UpdateWorkspaceInput = Partial; +export async function ensureProjectAccessMember( + dbOrTx: any, + input: { + companyId: string; + projectId: string; + subjectType: "user" | "agent"; + subjectId: string; + }, +) { + return dbOrTx + .insert(projectAccessMembers) + .values(input) + .onConflictDoNothing() + .returning() + .then((rows: Array) => rows[0] ?? null); +} + +/** + * Concurrency-safe lazy creation for the per-user private parking project. + * The partial unique index is the final arbiter; the advisory lock avoids + * wasting project ids and keeps the membership insert in the same transaction. + */ +export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: string, userId: string) { + const lockKey = `personal-private-project:${companyId}:${userId}`; + await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); + let project = await dbOrTx + .select() + .from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId))) + .limit(1) + .then((rows: ProjectRow[]) => rows[0] ?? null); + if (!project) { + project = await dbOrTx + .insert(projects) + .values({ + companyId, + name: "My private tasks", + description: "Private tasks visible only to people and agents explicitly granted access.", + status: "in_progress", + visibility: "private", + personalOwnerUserId: userId, + privacyOwnerUserId: userId, + }) + .onConflictDoNothing() + .returning() + .then((rows: ProjectRow[]) => rows[0] ?? null); + project ??= await dbOrTx + .select() + .from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId))) + .limit(1) + .then((rows: ProjectRow[]) => rows[0] ?? null); + } + if (!project) throw new Error("Failed to create personal private project"); + await ensureProjectAccessMember(dbOrTx, { + companyId, + projectId: project.id, + subjectType: "user", + subjectId: userId, + }); + return project; +} + interface ProjectWithGoals extends Omit { urlKey: string; goalIds: string[]; @@ -925,12 +989,30 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool updates.goalId = ids.length > 0 ? ids[0] : null; } - const row = await db - .update(projects) - .set(updates) - .where(eq(projects.id, id)) - .returning() - .then((rows) => rows[0] ?? null); + const row = await db.transaction(async (tx) => { + if (updates.visibility !== undefined) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existingProject.companyId}`}, 0))`); + } + const [updated] = await tx.update(projects).set(updates).where(eq(projects.id, id)).returning(); + if (updated?.visibility === "private" && updates.visibility === "private") { + const protectedRows = await tx.execute(sql`with recursive protected_tasks as ( + select i.id from issues i where i.project_id = ${id} and i.company_id = ${updated.companyId} + union + select i.id from issues i join protected_tasks p on i.parent_id = p.id or i.privacy_parent_issue_id = p.id + where i.company_id = ${updated.companyId} + ) update issues i set visibility = 'private', privacy_root_issue_id = coalesce(i.privacy_root_issue_id, i.id), + privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id), updated_at = now() + from protected_tasks p where i.id = p.id returning i.id`); + // Assignment access stays with the task after unassignment. + if (protectedRows.length) await tx.execute(sql`insert into issue_access_grants (issue_id, subject_type, subject_id, source) + select i.id, principal.kind, principal.id, 'assignment' from issues i + cross join lateral (values ('agent', i.assignee_agent_id::text), ('user', i.assignee_user_id)) principal(kind, id) + where i.id in (${sql.join(protectedRows.map(row => sql`${row.id}`), sql`, `)}) and principal.id is not null + and not exists (select 1 from issue_access_grants g where g.issue_id = i.id + and g.subject_type = principal.kind and g.subject_id = principal.id and g.revoked_at is null)`); + } + return updated ?? null; + }); if (!row) return null; if (ids !== undefined) { diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 89fcb42b809..4a374ef5cbb 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2662,6 +2662,8 @@ export function recoveryService( .values({ companyId: input.issue.companyId, agentId: input.agentId, + scopeKind: "issue", + issueId: input.issue.id, invocationSource: "automation", triggerDetail: "system", status: "scheduled_retry", diff --git a/server/src/services/status-cards.ts b/server/src/services/status-cards.ts index 0838e77c3e8..5a4c8ae6c19 100644 --- a/server/src/services/status-cards.ts +++ b/server/src/services/status-cards.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm"; import { agents, costEvents, @@ -44,6 +44,7 @@ import { type StatusCardActor = { agentId: string | null; userId: string | null }; type StatusCardWriter = { agentId: string | null; runId: string | null }; type StatusCardRow = typeof statusCards.$inferSelect; +type StatusCardReadOptions = { issueReadCondition?: SQL }; const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); @@ -154,10 +155,15 @@ export function statusCardService( const issuesSvc = deps.issuesSvc ?? issueService(db); const searchSvc = companySearchService(db); - async function readWatchedIssueCount(card: StatusCardRow) { - if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) return 0; + async function readWatchedIssueVisibility(card: StatusCardRow, options: StatusCardReadOptions = {}) { + if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) { + return { count: 0, privacyRedacted: false }; + } try { - return (await executeQueries(card)).length; + const visible = await executeQueries(card, options); + if (!options.issueReadCondition) return { count: visible.length, privacyRedacted: false }; + const total = await executeQueries(card); + return { count: visible.length, privacyRedacted: total.length !== visible.length }; } catch (err) { logger.warn( { err, cardId: card.id, companyId: card.companyId }, @@ -167,7 +173,7 @@ export function statusCardService( } } - async function hydrate(card: StatusCardRow) { + async function hydrate(card: StatusCardRow, options: StatusCardReadOptions = {}) { const dayStart = new Date(); dayStart.setUTCHours(0, 0, 0, 0); const [document, today, watchedIssues] = await Promise.all([ @@ -184,25 +190,28 @@ export function statusCardService( .from(statusCardUpdates) .where(and(eq(statusCardUpdates.cardId, card.id), gte(statusCardUpdates.startedAt, dayStart))) .then((rows) => rows[0] ?? { tokens: 0, costCents: 0 }), - readWatchedIssueCount(card), + readWatchedIssueVisibility(card, options), ]); return { ...card, - summaryBody: document?.latestBody ?? null, - ...(watchedIssues === undefined ? {} : { watchedIssueCount: watchedIssues }), + summaryBody: watchedIssues?.privacyRedacted ? null : document?.latestBody ?? null, + ...(watchedIssues === undefined ? {} : { + watchedIssueCount: watchedIssues.count, + privacyRedacted: watchedIssues.privacyRedacted, + }), todayTokens: today.tokens, todayCostCents: today.costCents, }; } - async function list(companyId: string, archived: boolean) { + async function list(companyId: string, archived: boolean, options: StatusCardReadOptions = {}) { const cards = await db .select() .from(statusCards) .where(and(eq(statusCards.companyId, companyId), archived ? isNotNull(statusCards.archivedAt) : isNull(statusCards.archivedAt))) .orderBy(desc(statusCards.updatedAt)); - return Promise.all(cards.map(hydrate)); + return Promise.all(cards.map((card) => hydrate(card, options))); } async function getById(id: string) { @@ -515,7 +524,11 @@ export function statusCardService( }); } - async function loadIssueSummaries(companyId: string, issueIds: string[]): Promise { + async function loadIssueSummaries( + companyId: string, + issueIds: string[], + options: StatusCardReadOptions = {}, + ): Promise { if (issueIds.length === 0) return []; const rows = await db .select({ @@ -530,7 +543,11 @@ export function statusCardService( updatedAt: issues.updatedAt, }) .from(issues) - .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds))); + .where(and( + eq(issues.companyId, companyId), + inArray(issues.id, issueIds), + options.issueReadCondition ?? sql`true`, + )); return rows.map((row) => ({ ...row, status: row.status as CompanySearchIssueSummary["status"], @@ -559,15 +576,17 @@ export function statusCardService( return rows.map((row) => row.id).sort(); } - async function listMentionedIssues(card: StatusCardRow) { - return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? []); + async function listMentionedIssues(card: StatusCardRow, options: StatusCardReadOptions = {}) { + return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? [], options); } - async function executeQueries(card: StatusCardRow) { + async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}) { const issueMap = new Map(); for (const storedQuery of card.queries) { const query = companySearchQuerySchema.parse(storedQuery); - const response = await searchSvc.search(card.companyId, query); + const response = await searchSvc.search(card.companyId, query, { + issueReadCondition: options.issueReadCondition, + }); for (const result of response.results) { if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue); } @@ -577,6 +596,7 @@ export function statusCardService( const mentioned = await loadIssueSummaries( card.companyId, (card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)), + options, ); for (const issue of mentioned) issueMap.set(issue.id, issue); const snapshot = [...issueMap.values()]; @@ -909,8 +929,16 @@ export function statusCardService( }); } - async function dryRun(card: StatusCardRow) { - return Promise.all(card.queries.map(async (query) => ({ query, result: await searchSvc.search(card.companyId, query) }))); + async function dryRun(card: StatusCardRow, options: StatusCardReadOptions = {}) { + return Promise.all(card.queries.map(async (storedQuery) => { + const query = companySearchQuerySchema.parse(storedQuery); + return { + query, + result: await searchSvc.search(card.companyId, query, { + issueReadCondition: options.issueReadCondition, + }), + }; + })); } return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, listMentionedIssues, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun }; diff --git a/server/src/services/task-search.ts b/server/src/services/task-search.ts index ac5044719a3..600e355217e 100644 --- a/server/src/services/task-search.ts +++ b/server/src/services/task-search.ts @@ -81,7 +81,7 @@ export function taskSearchScore(search: TaskSearch): SQL { * Uses existing pg_trgm indexes and current rows: no derived corpus or worker. * The tagged comment/document sets are evaluated once, not once per task. */ -export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL): SQL { +export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL, readCondition?: SQL): SQL { const n = search.tokens.length; const comments = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false` : sql.join(search.patterns.map((_, index) => sql` @@ -161,7 +161,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon document_matches AS MATERIALIZED (${documents}), literal_candidates AS MATERIALIZED ( SELECT issues.id FROM issues - WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} + WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`} AND ${n === 0 ? sql`${search.normalizedQuery.length === 0}` : sql`( ${taskSearchAny(sql`issues.title`, search)} OR ${taskSearchAny(sql`issues.identifier`, search)} @@ -172,7 +172,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon UNION SELECT issue_id FROM document_matches ), search_flags AS MATERIALIZED ( ${flags(sql`false`)} - WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} + WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`} AND issues.id IN (SELECT id FROM literal_candidates) ), literal_matches AS MATERIALIZED ( SELECT * FROM search_flags @@ -184,7 +184,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon JOIN issues ON issues.id = literal.id ${fallbackFilters ? sql`WHERE ${fallbackFilters}` : sql``} ) - AND issues.company_id = ${companyId} AND ${visibleIssueCondition()} + AND issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`} AND ${fuzzy} ), matched AS MATERIALIZED ( SELECT * FROM literal_matches diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 90b8534ce63..31c6eb0df31 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -1,3 +1,4 @@ +import { issueReadSqlCondition, executionWorkspaceReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import fs from "node:fs/promises"; import path from "node:path"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; @@ -1036,9 +1037,23 @@ async function listChangedWorkspaceFiles(input: { }; } -export function workspaceFileResourceService(db: Db) { +export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) { + async function visibleCandidates(candidates: WorkspaceCandidate[]) { + if (!actor) return candidates; + const visible: WorkspaceCandidate[] = []; + for (const candidate of candidates) { + const rows = candidate.workspaceKind === "execution_workspace" + ? await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, candidate.workspaceId), await executionWorkspaceReadSqlCondition(db, actor))) + : await db.select({ id: projectWorkspaces.id }).from(projectWorkspaces) + .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) + .where(and(eq(projectWorkspaces.id, candidate.workspaceId), await projectReadSqlCondition(db, actor))); + if (rows.length) visible.push(candidate); + } + return visible; + } async function getIssue(issueId: string): Promise { - const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)).limit(1); + const [issue] = await db.select().from(issues).where(and(eq(issues.id, issueId), actor ? await issueReadSqlCondition(db, actor) : undefined)).limit(1); if (!issue) throw notFound("Issue not found"); return issue; } @@ -1064,7 +1079,9 @@ export function workspaceFileResourceService(db: Db) { throw unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }); } - return candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }); + const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]); + if (!candidate) throw notFound("Project workspace not found"); + return candidate; } async function listCandidates( @@ -1131,7 +1148,7 @@ export function workspaceFileResourceService(db: Db) { } } - return candidates; + return visibleCandidates(candidates); } async function loadAvailabilityTargets( @@ -1171,9 +1188,8 @@ export function workspaceFileResourceService(db: Db) { error: unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }), }); } else { - targets.set(targetKey, { - candidate: candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }), - }); + const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]); + targets.set(targetKey, candidate ? { candidate } : { error: notFound("Project workspace not found") }); } } return targets; @@ -1205,7 +1221,7 @@ export function workspaceFileResourceService(db: Db) { seen.add(row.workspace.id); candidates.push(candidateFromProjectWorkspace(row.workspace, row.project)); } - return candidates; + return visibleCandidates(candidates); } async function discoverUniqueProjectWorkspaceMatch( From abbd88007f059dbadbd3f266febf264577b6ee78 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:07:32 -0500 Subject: [PATCH 03/55] fix(hermes): keep managed instructions out of resumed user turns (#15439) Deliver managed instructions through Hermes's native system overlay while keeping current wake and runtime identity in user turns. Add fresh/resumed regression coverage and include Hermes in the default CI test roster. Fixes #15385 Co-Authored-By: Paperclip --- packages/adapters/hermes/README.md | 37 +++ packages/adapters/hermes/src/index.ts | 8 + .../src/server/execute.instructions.test.ts | 210 ++++++++++++++++++ .../adapters/hermes/src/server/execute.ts | 55 +++-- .../src/server/prompt-rendering.test.ts | 17 +- scripts/run-vitest-stable.mjs | 1 + vitest.config.ts | 1 + 7 files changed, 295 insertions(+), 34 deletions(-) create mode 100644 packages/adapters/hermes/src/server/execute.instructions.test.ts diff --git a/packages/adapters/hermes/README.md b/packages/adapters/hermes/README.md index fb9d267e7bf..1ee190c753c 100644 --- a/packages/adapters/hermes/README.md +++ b/packages/adapters/hermes/README.md @@ -312,6 +312,43 @@ up where the last one left off, maintaining conversation context, memories, and tool state across heartbeats. The `sessionCodec` validates and migrates session state between runs. +### Managed instructions and resumed turns + +The local adapter reads `instructionsFilePath` on every run. It sends that +bundle, its relative-reference base directory, and the standard Paperclip API +guidance through Hermes's native `HERMES_EPHEMERAL_SYSTEM_PROMPT` overlay. +Hermes applies this overlay at each model request, including after context +compression. It does not append the overlay to saved user turns. This requires +a Hermes CLI that supports the [documented environment hook](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md). + +Fresh sessions, resumed sessions, and session resets all receive the current +bundle. Edits take effect on the next run; removing the bundle removes it from +the next overlay. The server's session compatibility and conversation generation +checks still control session reuse. A missing or failed resume remains a failed +run and requires an explicit session reset; it does not automatically restart +work. The next fresh run receives the complete current instruction overlay. + +The `-q` user turn contains current runtime identity, task/wake context, handoff +content, and the rendered custom `promptTemplate`, if configured. Custom templates +run on every normal wake. `quiet` changes CLI display only. Disabling persistence +uses full fresh-session context even if old session parameters are supplied. + +An explicit `HERMES_EPHEMERAL_SYSTEM_PROMPT` in `env` is preserved before the +Paperclip overlay. Hermes gives this environment hook precedence over its profile's +configured personality/system prompt. Put required additional profile guidance in +that explicit environment value or in the managed bundle. Paperclip does not write +Hermes profile files or modify the parent process environment. Existing copies in +old user turns remain in history until Hermes compresses them or the session resets. + +Run the credential-free capture-process regression tests with: + +```sh +pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/server/execute.instructions.test.ts +``` + +These tests verify actual child-process arguments and environment, not provider +billing, model behavior, or measured token savings. + ### Skills Integration The adapter scans two skill sources and merges them: diff --git a/packages/adapters/hermes/src/index.ts b/packages/adapters/hermes/src/index.ts index 1c1b9d33984..4ff7488b997 100644 --- a/packages/adapters/hermes/src/index.ts +++ b/packages/adapters/hermes/src/index.ts @@ -114,6 +114,14 @@ tools, persistent memory, session persistence, skills, and MCP support. | env | object | {} | Extra environment variables | | promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders | +Managed \`instructionsFilePath\` bundles and standard Paperclip API guidance use +Hermes's native \`HERMES_EPHEMERAL_SYSTEM_PROMPT\` overlay on every run, including +resume and reset. They are applied at request time and are not appended to user +history. The query keeps current runtime identity, wake/task content, and custom +templates. Use a Hermes CLI that supports this environment hook. An explicit +overlay in \`env\` is preserved; the hook takes precedence over Hermes's configured +profile personality/system prompt. + ## Hermes-Originated Paperclip Tasks This adapter package also ships a Hermes-facing Paperclip task bridge skill: diff --git a/packages/adapters/hermes/src/server/execute.instructions.test.ts b/packages/adapters/hermes/src/server/execute.instructions.test.ts new file mode 100644 index 00000000000..5423e5568d2 --- /dev/null +++ b/packages/adapters/hermes/src/server/execute.instructions.test.ts @@ -0,0 +1,210 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +import { execute } from "./execute.js"; +import { sessionCodec } from "./index.js"; + +const SESSION_ID = "20261006_200000_abcdef"; +const INSTRUCTIONS = "Synthetic governance marker: request approval before governed actions."; + +interface Invocation { + args: string[]; + systemPrompt: string | null; + runId: string; +} + +describe("Hermes managed instruction delivery (real capture process)", () => { + let dir: string; + let command: string; + let instructionsFilePath: string; + let capturePath: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-instructions-")); + command = path.join(dir, "hermes.cjs"); + instructionsFilePath = path.join(dir, "instructions.md"); + capturePath = path.join(dir, "invocations.jsonl"); + await fs.writeFile(instructionsFilePath, INSTRUCTIONS); + await fs.writeFile(command, `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + args, + systemPrompt: process.env.HERMES_EPHEMERAL_SYSTEM_PROMPT ?? null, + runId: process.env.PAPERCLIP_RUN_ID, +}) + "\\n"); +if (process.env.FIXTURE_STDOUT) console.log(process.env.FIXTURE_STDOUT); +if (args.includes("missing-session")) { + console.error("\\u001b[1;31mSession missing-session not found.\\u001b[0m"); + process.exit(1); +} +if (process.env.FIXTURE_PROVIDER_ERROR) { + console.error(process.env.FIXTURE_PROVIDER_ERROR); + process.exit(1); +} +console.log("Fixture completed\\nsession_id: ${SESSION_ID}"); +`, { mode: 0o700 }); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + function context(overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-fresh", + agent: { + id: "agent-synthetic", companyId: "company-synthetic", name: "Hermes fixture", + adapterType: "hermes_local", adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task-synthetic" }, + config: { + hermesCommand: command, provider: "openrouter", model: "fixture-model", cwd: dir, + instructionsFilePath, env: { CAPTURE_PATH: capturePath }, timeoutSec: 10, graceSec: 1, + }, + context: { + issueId: "task-synthetic", + paperclipTaskMarkdownAssignment: "Complete current synthetic task.", + paperclipTaskMarkdownCompact: "Current synthetic task.", + paperclipWake: { + reason: "issue_commented", + issue: { id: "task-synthetic", title: "Synthetic task", status: "in_progress" }, + comments: [{ id: "comment-synthetic", body: "Fresh human direction." }], + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + fallbackFetchNeeded: false, + }, + }, + onLog: async () => {}, + ...overrides, + }; + } + + async function invocations(): Promise { + return (await fs.readFile(capturePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + } + + function query(invocation: Invocation): string { + return invocation.args[invocation.args.indexOf("-q") + 1]; + } + + it.each([false, true])("keeps instructions out of fresh and resumed user turns (conversation=%s)", async (conversationMode) => { + const fresh = context(); + fresh.context.conversationMode = conversationMode; + const first = await execute(fresh); + const params = sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)); + expect(params?.sessionId).toBe(SESSION_ID); + await execute({ ...fresh, runId: "run-resumed", runtime: { ...fresh.runtime, sessionParams: params } }); + + const [initial, resumed] = await invocations(); + expect(initial.args).not.toContain("--resume"); + expect(resumed.args.slice(resumed.args.indexOf("--resume"))).toEqual(["--resume", SESSION_ID]); + for (const invocation of [initial, resumed]) { + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain(`Resolve any relative file references from ${dir}/`); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(query(invocation)).not.toContain("Safe multiline update pattern:"); + expect(query(invocation)).toContain("Fresh human direction."); + expect(query(invocation)).toContain(invocation.runId); + expect(query(invocation)).toContain("company-synthetic"); + expect(invocation.systemPrompt).toContain("$PAPERCLIP_API_KEY"); + expect(invocation.systemPrompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); + expect(invocation.systemPrompt).toContain("body=$(cat <<'MD'"); + expect(invocation.systemPrompt).toContain("--data-binary @-"); + expect(invocation.systemPrompt).toContain('$api/issues/$PAPERCLIP_TASK_ID'); + expect(invocation.systemPrompt).not.toContain(invocation.runId); + } + }); + + it("reapplies current instructions after edits and a session reset", async () => { + const ctx = context(); + await execute(ctx); + await fs.writeFile(instructionsFilePath, "Updated governance marker."); + await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + await execute({ ...ctx, runId: "run-reset" }); + const [, edited, reset] = await invocations(); + for (const invocation of [edited, reset]) { + expect(invocation.systemPrompt).toContain("Updated governance marker."); + expect(invocation.systemPrompt).not.toContain(INSTRUCTIONS); + expect(query(invocation)).not.toContain("Updated governance marker."); + } + expect(reset.args).not.toContain("--resume"); + }); + + it("uses fresh wake context when persistence is disabled despite stored session params", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, persistSession: false }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [invocation] = await invocations(); + expect(invocation.args).not.toContain("--resume"); + expect(query(invocation)).toContain("## Paperclip Wake Payload"); + expect(query(invocation)).not.toContain("## Paperclip Resume Delta"); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + expect(result.sessionParams).toBeUndefined(); + }); + + it("keeps custom templates and quiet mode dynamic while preserving a configured system overlay", async () => { + const ctx = context(); + await execute({ ...ctx, config: { + ...ctx.config, quiet: true, promptTemplate: "Current custom direction for {{runId}}.", + env: { CAPTURE_PATH: capturePath, HERMES_EPHEMERAL_SYSTEM_PROMPT: "User configured system guidance." }, + }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [invocation] = await invocations(); + expect(invocation.args).toContain("-Q"); + expect(query(invocation)).toContain("Current custom direction for run-fresh."); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain("User configured system guidance."); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + }); + + it.each([false, true])("keeps an empty wake runnable with current identity (conversation=%s)", async (conversationMode) => { + await execute(context({ context: { conversationMode } })); + const [invocation] = await invocations(); + expect(query(invocation)).toContain("run-fresh"); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + }); + + it("surfaces a missing session without automatically restarting work", async () => { + const ctx = context(); + const result = await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: "missing-session" } } }); + const calls = await invocations(); + expect(calls).toHaveLength(1); + expect(calls[0].args).toContain("--resume"); + expect(calls[0].systemPrompt).toContain(INSTRUCTIONS); + expect(result.exitCode).toBe(1); + expect(result.sessionParams).toBeUndefined(); + }); + + it("does not retry provider failures as a missing session", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, env: { CAPTURE_PATH: capturePath, FIXTURE_PROVIDER_ERROR: "Error: provider unavailable" } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + expect(await invocations()).toHaveLength(1); + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toBe("Error: provider unavailable"); + }); + + it("preserves failed work even when its output contains a missing-session diagnostic", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, env: { + CAPTURE_PATH: capturePath, + FIXTURE_STDOUT: `Already updated the task.\nSession ${SESSION_ID} not found.`, + FIXTURE_PROVIDER_ERROR: "Error: provider unavailable after tool execution", + } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + expect(await invocations()).toHaveLength(1); + expect(result.exitCode).toBe(1); + expect(result.summary).toContain("Already updated the task."); + expect(result.errorMessage).toBe("Error: provider unavailable after tool execution"); + }); + + it("removes an obsolete instruction overlay when the bundle is removed", async () => { + const ctx = context(); + await execute(ctx); + await execute({ ...ctx, config: { ...ctx.config, instructionsFilePath: undefined }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [, resumed] = await invocations(); + expect(resumed.systemPrompt).not.toContain(INSTRUCTIONS); + expect(resumed.systemPrompt).toContain("Paperclip API guidance:"); + expect(query(resumed)).toContain("Fresh human direction."); + }); +}); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 828a3e2e002..98a5d85846e 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -34,7 +34,6 @@ import { renderTemplate, ensureAbsoluteDirectory, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, - DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, joinPromptSections, selectPaperclipPromptSections, stringifyPaperclipWakePayload, @@ -82,7 +81,7 @@ export function resolveHermesCommand(config: Record): string { // Wake-up prompt builder // --------------------------------------------------------------------------- -const HERMES_DEFAULT_PROMPT_TEMPLATE = [ +const HERMES_RUNTIME_IDENTITY_TEMPLATE = [ 'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.', "", "Paperclip runtime identity:", @@ -90,7 +89,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "- Company ID: {{agent.companyId}}", "- Run ID: {{run.id}}", "- API base: {{paperclipApiUrl}}", - "", +].join("\n"); + +// Hermes applies this overlay at each API call, including after compaction, +// without storing it in conversation history. Keep run/task deltas in -q. +const HERMES_SYSTEM_PROMPT_TEMPLATE = [ "Paperclip API guidance:", "- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.", "- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.", @@ -112,7 +115,7 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "MD", ")", "jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\", - " curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\", + " curl -sS -X PATCH \"$api/issues/$PAPERCLIP_TASK_ID\" \\", " -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\", " -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\", " -H \"Content-Type: application/json\" \\", @@ -141,9 +144,7 @@ export function buildPrompt( options: { resumedSession?: boolean } = {}, ): string { const context = (ctx as any).context || {}; - const template = cfgString(config.promptTemplate) || (context.conversationMode === true - ? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE - : HERMES_DEFAULT_PROMPT_TEMPLATE); + const template = cfgString(config.promptTemplate); const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || ""; @@ -200,10 +201,11 @@ export function buildPrompt( paperclipRunIdEnv: "PAPERCLIP_RUN_ID", }; - const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake) - ? "" - : renderTemplate(renderConditionalSections(template, vars), vars); + const rendered = template && !isPaperclipRecoveryWakePayload(context.paperclipWake) + ? renderTemplate(renderConditionalSections(template, vars), vars) + : ""; return joinPromptSections([ + renderTemplate(HERMES_RUNTIME_IDENTITY_TEMPLATE, vars), wakePrompt, sessionHandoffMarkdown, taskContextMarkdown, @@ -404,7 +406,7 @@ export async function execute( // ── Load agent instructions file (Paperclip instruction bundles) ────── // Paperclip can materialize managed instructions into instructionsFilePath; - // when present, inject that bundle into the Hermes prompt. + // when present, reapply that bundle through Hermes's native system overlay. const instructionsFilePath = cfgString(config.instructionsFilePath); let agentInstructions = ""; if (instructionsFilePath) { @@ -430,10 +432,8 @@ export async function execute( } // ── Build prompt ─────────────────────────────────────────────────────── - let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) }); - if (agentInstructions) { - prompt = agentInstructions + "\n\n---\n\n" + prompt; - } + const sessionId = persistSession ? prevSessionId : undefined; + const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) }); // ── Build command args ───────────────────────────────────────────────── // Use -Q (quiet) to get clean output: just response + session_id line @@ -474,13 +474,9 @@ export async function execute( // system is designed for human-attended interactive sessions. args.push("--yolo"); - if (persistSession && prevSessionId) { - args.push("--resume", prevSessionId); - } - - if (extraArgs?.length) { - args.push(...extraArgs); - } + // Failed resumes remain failures. Do not infer a safe restart from CLI text. + if (sessionId) args.push("--resume", sessionId); + if (extraArgs?.length) args.push(...extraArgs); // ── Build environment ────────────────────────────────────────────────── const userEnv = config.env as Record | undefined; @@ -491,6 +487,17 @@ export async function execute( ...buildRuntimeToolsEnv(ctx.runtimeTools), }; + // Scope the overlay to this child process. Read the current bundle on every + // invocation; never infer its availability from a persisted session ID. + // Preserve an operator's explicit overlay before Paperclip's instructions. + env.HERMES_EPHEMERAL_SYSTEM_PROMPT = joinPromptSections([ + env.HERMES_EPHEMERAL_SYSTEM_PROMPT, + agentInstructions, + renderTemplate(HERMES_SYSTEM_PROMPT_TEMPLATE, { + agent: ctx.agent, + }), + ]); + if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; // PAPERCLIP_API_KEY is never accepted from config — the harness-minted run @@ -527,10 +534,10 @@ export async function execute( "stdout", `[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`, ); - if (prevSessionId) { + if (sessionId) { await ctx.onLog( "stdout", - `[hermes] Resuming session: ${prevSessionId}\n`, + `[hermes] Resuming session: ${sessionId}\n`, ); } diff --git a/packages/adapters/hermes/src/server/prompt-rendering.test.ts b/packages/adapters/hermes/src/server/prompt-rendering.test.ts index b47dca03619..dc6ba503110 100644 --- a/packages/adapters/hermes/src/server/prompt-rendering.test.ts +++ b/packages/adapters/hermes/src/server/prompt-rendering.test.ts @@ -234,19 +234,16 @@ test("keeps authoritative parent and ancestor context from task markdown", () => expect(prompt).not.toContain("check the issue body or comments for references"); }); -test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => { +test("keeps current runtime identity in the user turn without repeating static API examples", () => { const prompt = buildPrompt(baseContext(), { paperclipApiUrl: "http://paperclip.local/api", }); - expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`"); - expect(prompt).toContain("Displayed command logs may redact secrets"); - expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"'); - expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); - expect(prompt).toContain("body=$(cat <<'MD'"); - expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\""); - expect(prompt).toContain("--data-binary @-"); - expect(prompt).not.toContain("Authorization: Bearer <"); + expect(prompt).toContain("- Agent ID: agent-1"); + expect(prompt).toContain("- Company ID: company-1"); + expect(prompt).toContain("- Run ID: run-1"); + expect(prompt).toContain("- API base: http://paperclip.local/api"); + expect(prompt).not.toContain("Safe multiline update pattern:"); }); test("preserves custom prompt templates while exposing runtime and wake variables", () => { @@ -276,7 +273,7 @@ test("preserves custom prompt templates while exposing runtime and wake variable expect(prompt).toContain('"reason":"issue_assigned"'); expect(prompt).toContain("## Paperclip Wake Payload"); expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); - expect(prompt).not.toContain("Paperclip runtime identity:"); + expect(prompt).toContain("Paperclip runtime identity:"); }); test("keeps historical task markdown available to custom templates while automatic context uses assignment markdown", () => { diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index b275680550a..aec054d48c9 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -31,6 +31,7 @@ const nonServerProjects = [ "@paperclipai/adapter-cursor-local", "@paperclipai/adapter-gemini-local", "@paperclipai/adapter-grok-local", + "@paperclipai/hermes-paperclip-adapter", "@paperclipai/adapter-kimi-local", "@paperclipai/adapter-openclaw-gateway", "@paperclipai/adapter-opencode-local", diff --git a/vitest.config.ts b/vitest.config.ts index cceb1767e2b..d2d9e52e12e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "packages/adapters/cursor-local", "packages/adapters/gemini-local", "packages/adapters/grok-local", + "packages/adapters/hermes", "packages/adapters/kimi-local", "packages/adapters/openclaw-gateway", "packages/adapters/opencode-local", From b31558064093564fb937cf046596d1d497f5be27 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:27:52 -0500 Subject: [PATCH 04/55] feat: add private task creation and sharing controls (#14718) Add private task creation and sharing controls, effective access explanations, private project management, and locked task references. Constrain the mobile composer plus menu to the viewport and explain named inherited privacy on hover, focus, or tap. Include 125 production-component Storybook stories and thirteen interactive user journeys. Final head passes all CI checks and Greptile Apex 5/5 with no findings. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 2 +- ui/src/api/issues.ts | 24 +- ui/src/api/projects.ts | 12 + ui/src/components/AccessSelectIdentity.tsx | 10 + ui/src/components/Identity.tsx | 4 +- ui/src/components/IssueBlockedNotice.test.tsx | 9 + ui/src/components/IssueBlockedNotice.tsx | 15 +- ui/src/components/IssuePrivacyActions.tsx | 199 +++++ ui/src/components/IssueProperties.test.tsx | 14 + ui/src/components/IssueReferencePill.tsx | 13 +- ui/src/components/IssueShareSheet.test.tsx | 220 +++++ ui/src/components/IssueShareSheet.tsx | 506 ++++++++++++ ui/src/components/LockedIssueChip.test.tsx | 75 ++ ui/src/components/LockedIssueChip.tsx | 51 ++ ui/src/components/MarkdownBody.test.tsx | 26 + ui/src/components/MarkdownBody.tsx | 29 +- ui/src/components/NewIssueDialog.test.tsx | 132 ++- ui/src/components/NewIssueDialog.tsx | 52 +- .../PrivateTaskPrivacyControls.test.tsx | 123 +++ ui/src/components/ProjectAccessMembers.tsx | 165 ++++ ui/src/components/ProjectProperties.tsx | 54 ++ ui/src/components/SidebarProjects.tsx | 3 +- .../SidebarStarredProjects.production.tsx | 3 +- ui/src/components/SidebarStarredProjects.tsx | 3 +- .../issue-properties/IssueProperties.tsx | 10 +- .../issue-properties/relation-controls.tsx | 33 +- .../components/task-chat/ComposerAddMenu.tsx | 46 +- .../components/task-chat/TaskChatComposer.tsx | 5 +- .../task-detail/TaskDetailRelationsPanel.tsx | 8 +- .../task-detail/TaskDetailTasksPanel.test.tsx | 8 + ui/src/lib/issuePrivacy.test.ts | 93 +++ ui/src/lib/issuePrivacy.ts | 68 ++ ui/src/lib/queryKeys.ts | 9 +- ui/src/lib/sentry.test.ts | 6 +- ui/src/pages/DesignGuide.tsx | 14 + ui/src/pages/IssueDetail.tsx | 53 +- ui/src/pages/ProjectDetail.test.tsx | 23 +- ui/src/pages/ProjectDetail.tsx | 8 +- ui/src/pages/Projects.tsx | 2 + ui/storybook/fixtures/privateTasks.test.ts | 228 +++++ ui/storybook/fixtures/privateTasks.ts | 780 ++++++++++++++++++ .../private-tasks/00-review-guide.stories.tsx | 272 ++++++ .../private-tasks/01-creation.stories.tsx | 257 ++++++ .../private-tasks/02-sharing.stories.tsx | 242 ++++++ .../private-tasks/03-task-actions.stories.tsx | 152 ++++ .../04-references-and-blockers.stories.tsx | 219 +++++ .../private-tasks/05-projects.stories.tsx | 244 ++++++ .../06-full-product-pages.stories.tsx | 247 ++++++ .../private-tasks/07-user-stories.stories.tsx | 210 +++++ .../stories/private-tasks/PrivacyStory.tsx | 94 +++ 50 files changed, 5019 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/AccessSelectIdentity.tsx create mode 100644 ui/src/components/IssuePrivacyActions.tsx create mode 100644 ui/src/components/IssueShareSheet.test.tsx create mode 100644 ui/src/components/IssueShareSheet.tsx create mode 100644 ui/src/components/LockedIssueChip.test.tsx create mode 100644 ui/src/components/LockedIssueChip.tsx create mode 100644 ui/src/components/PrivateTaskPrivacyControls.test.tsx create mode 100644 ui/src/components/ProjectAccessMembers.tsx create mode 100644 ui/src/lib/issuePrivacy.test.ts create mode 100644 ui/src/lib/issuePrivacy.ts create mode 100644 ui/storybook/fixtures/privateTasks.test.ts create mode 100644 ui/storybook/fixtures/privateTasks.ts create mode 100644 ui/storybook/stories/private-tasks/00-review-guide.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/01-creation.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/02-sharing.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/03-task-actions.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/05-projects.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/07-user-stories.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/PrivacyStory.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 502b1d42dd9..97560d6d9ea 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -135,7 +135,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**, - Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. - Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. - Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane. -- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. +- Do not interpret agent-profile privacy flags as project/issue privacy. Work remains company-open by default, while explicitly private issues and projects use their own task ACLs. - Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. - Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 4f1b6d907d5..4b057f5e772 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { FeedbackTrace, FeedbackVote, Issue, + IssueAccessGrant, + IssueAccessGrantSubjectType, + IssueVisibility, + IssuePrivacyConstraints, IssueChanges, IssueAttachment, IssueCostSummary, @@ -589,8 +593,24 @@ export const issuesApi = { form, ); }, - deleteAttachment: (id: string) => - api.delete<{ ok: true }>(`/attachments/${id}`), + deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`), + // --- Privacy / sharing (PAP-16066) ------------------------------------- + // Enriched grants for the share sheet: implicit-by-source rows (assignment / + // project) plus explicit grants, each carrying subjectDisplayName / avatar / + // agentVisibility from the server enrichment pass. + privacyConstraints: (id: string) => api.get(`/issues/${id}/privacy-constraints`), + listAccessGrants: (id: string, options?: RequestOptions) => + options + ? api.get(`/issues/${id}/access-grants`, options) + : api.get(`/issues/${id}/access-grants`), + createAccessGrant: ( + id: string, + data: { subjectType: IssueAccessGrantSubjectType; subjectId: string }, + ) => api.post(`/issues/${id}/access-grants`, data), + revokeAccessGrant: (id: string, grantId: string) => + api.post(`/issues/${id}/access-grants/${grantId}/revoke`, {}), + setVisibility: (id: string, visibility: IssueVisibility) => + api.patch(`/issues/${id}`, { visibility }), listApprovals: (id: string) => api.get(`/issues/${id}/approvals`), linkApproval: (id: string, approvalId: string) => api.post(`/issues/${id}/approvals`, { approvalId }), diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 32f296e31c4..35b5c33567f 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,5 +1,6 @@ import type { Project, + ProjectAccessMember, ProjectRepositoryOptions, ProjectWorkspace, WorkspaceOperation, @@ -32,6 +33,17 @@ export const projectsApi = { api.post(`/companies/${companyId}/projects`, data), update: (id: string, data: Record, companyId?: string) => api.patch(projectPath(id, companyId), data), + listAccessMembers: (id: string, companyId?: string) => + api.get(projectPath(id, companyId, "/access-members")), + addAccessMember: ( + id: string, + data: { subjectType: "user" | "agent"; subjectId: string }, + companyId?: string, + ) => api.post(projectPath(id, companyId, "/access-members"), data), + removeAccessMember: (id: string, memberId: string, companyId?: string) => + api.delete( + projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`), + ), listWorkspaces: (projectId: string, companyId?: string) => api.get(projectPath(projectId, companyId, "/workspaces")), createWorkspace: (projectId: string, data: Record, companyId?: string) => diff --git a/ui/src/components/AccessSelectIdentity.tsx b/ui/src/components/AccessSelectIdentity.tsx new file mode 100644 index 00000000000..5e3ddb19ba7 --- /dev/null +++ b/ui/src/components/AccessSelectIdentity.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { SearchableSelectOption } from "./SearchableSelect"; + +export interface AccessSelectOption extends SearchableSelectOption { + identity: ReactNode; +} + +export function renderAccessIdentity(option: AccessSelectOption | null) { + return option ? {option.identity} : null; +} diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx index e1decbebdaf..b6e3945b18a 100644 --- a/ui/src/components/Identity.tsx +++ b/ui/src/components/Identity.tsx @@ -34,8 +34,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", shape = className={cn("inline-flex min-w-0 gap-1.5 items-center", size === "xs" && "gap-1", size === "lg" && "gap-2", className)} title={name} > - - {avatarUrl && } + {name} diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 8651ff7ac05..49ca976585e 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import type { + IssueRelationIssueSummary, IssueRecoveryAction, IssueRetryNowOutcome, IssueScheduledRetry, @@ -123,6 +124,14 @@ function render(element: ReactElement) { } describe("IssueBlockedNotice", () => { + it("renders a locked blocker without reading its withheld status", () => { + const node = render(); + expect(node.textContent).toContain("PAP-42"); + expect(node.textContent).toContain("Private"); + }); + it("renders a successful-run next-step notice without requiring blockers", () => { const node = render( { + // A private blocker arrives as a locked stub (no title/status). Show the + // locked chip plus the access note instead of a link into a 404. + if (isLockedIssueStub(blocker)) { + return ( + + + + Private — you don't have access + + + ); + } const issuePathId = blocker.identifier ?? blocker.id; const recoveryAction = blocker.activeRecoveryAction ?? null; return ( diff --git a/ui/src/components/IssuePrivacyActions.tsx b/ui/src/components/IssuePrivacyActions.tsx new file mode 100644 index 00000000000..98a6edf4beb --- /dev/null +++ b/ui/src/components/IssuePrivacyActions.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Globe, Lock, Users } from "lucide-react"; +import type { Issue, IssueVisibility } from "@paperclipai/shared"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { issuesApi } from "@/api/issues"; +import { useToastActions } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { IssueShareSheet, type ShareSheetImplicitPrincipal } from "./IssueShareSheet"; + +const MENU_ITEM_CLASS = + "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"; + +const NON_SETTER_TOOLTIP = + "Only the task owner or an admin can change who can see this task."; + +/** + * Privacy actions for the task `⋯` menu. Renders the menu buttons plus the + * two dialogs. The render prop keeps this component outside the menu's + * unmounting content, so dialogs survive the menu closing. `closeMenu` collapses the parent popover when an item that + * opens a dialog is clicked. + * + * Setter rules (locked decision, server-gated via + * `resolveManagedIssueForPrivacy`): only the responsible user + admins can + * change visibility or grants. Non-setters see the items disabled with a + * tooltip. Make-**public** always confirms (one-way disclosure); make-private + * never does. + */ +export function IssuePrivacyActions({ + issue, + companyId, + canManage, + closeMenu, + implicitPrincipals = [], + children, +}: { + issue: Pick & Partial>; + companyId: string; + canManage: boolean; + closeMenu: () => void; + implicitPrincipals?: ShareSheetImplicitPrincipal[]; + children: (menuItems: React.ReactNode) => React.ReactNode; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [shareOpen, setShareOpen] = useState(false); + const [makePublicOpen, setMakePublicOpen] = useState(false); + const isPrivate = issue.visibility === "private"; + const constraintsQuery = useQuery({ + queryKey: [...queryKeys.issues.privacyConstraints(issue.id), issue.privacyParentIssueId ?? null, issue.projectId ?? null], + queryFn: () => issuesApi.privacyConstraints(issue.id), + enabled: canManage && isPrivate, + }); + const publicBlockedReason = constraintsQuery.isError + ? "Couldn't check task privacy. Retry before making this task public." + : !constraintsQuery.data || constraintsQuery.isFetching + ? "Checking task privacy…" + : constraintsQuery.data.publicBlockedBy === "parent" + ? "Move this task out of its private parent before making it public." + : constraintsQuery.data.publicBlockedBy === "project" + ? "Move this task out of its private project before making it public." + : null; + + const visibilityMutation = useMutation({ + mutationFn: (visibility: IssueVisibility) => issuesApi.setVisibility(issue.id, visibility), + onSuccess: (_result, visibility) => { + // Privacy can change descendants and remove a task from a private project. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) }); + if (issue.identifier) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.identifier) }); + } + queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issue.id) }); + setMakePublicOpen(false); + pushToast({ + title: visibility === "private" ? "Task is now private" : "Task is now public", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ title: "Couldn't change visibility", body: (error as Error).message, tone: "error" }); + }, + }); + + function withTooltip(node: React.ReactNode, blockedReason?: string | null) { + if (canManage && !blockedReason) return node; + return ( + + + {node} + + {canManage ? blockedReason : NON_SETTER_TOOLTIP} + + ); + } + + return ( + + {children(isPrivate ? ( + <> + {withTooltip( + , + )} + {withTooltip( + , + publicBlockedReason, + )} + {canManage && constraintsQuery.isError ? ( + + ) : null} + + ) : ( + withTooltip( + , + ) + ))} + + + + + + + Make this task public? + + Everyone in the company will be able to read this task, its comments, documents, and + run history. Existing private subtasks keep their privacy.{" "} + {constraintsQuery.data?.leavesPersonalProject + ? "This task will also leave its personal project. " : null} + Content already seen by others cannot be taken back. + + + + Keep private + { + event.preventDefault(); + visibilityMutation.mutate("open"); + }} + disabled={visibilityMutation.isPending || Boolean(publicBlockedReason)} + > + {visibilityMutation.isPending ? "Making public…" : "Make public"} + + + + + + ); +} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 38a550a2217..683acb658ec 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -1363,6 +1363,20 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("preserves a locked parent stub without manufacturing a title or navigation link", async () => { + const root = renderProperties(container, { + issue: createIssue({ parentId: "private-parent", ancestors: [{ id: "private-parent", identifier: "PAP-99", locked: true }] as unknown as Issue["ancestors"] }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + expect(container.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toContain("PAP-99"); + expect(container.querySelector('a[href="/issues/PAP-99"]')).toBeNull(); + expect(container.querySelector('[title="private-"]')).toBeNull(); + act(() => root.unmount()); + }); + it("links relationship status and IDs and removes only the selected blocker with its X", async () => { const onUpdate = vi.fn(); const blockers = [ diff --git a/ui/src/components/IssueReferencePill.tsx b/ui/src/components/IssueReferencePill.tsx index e376db9444d..e47c8fb142f 100644 --- a/ui/src/components/IssueReferencePill.tsx +++ b/ui/src/components/IssueReferencePill.tsx @@ -1,8 +1,9 @@ import { X } from "lucide-react"; import type { ReactNode } from "react"; -import type { IssueRelationIssueSummary } from "@paperclipai/shared"; +import type { IssueLockedStub, IssueRelationIssueSummary } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { cn } from "../lib/utils"; +import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip"; import { badgeVariants } from "./ui/badge"; import { StatusIcon } from "./StatusIcon"; @@ -14,8 +15,10 @@ export function IssueReferencePill({ onRemove, variant = "mention", }: { - issue: Pick & - { status?: string }; + issue: + | (Pick & + { status?: string }) + | IssueLockedStub; strikethrough?: boolean; variant?: "mention" | "property"; className?: string; @@ -23,6 +26,10 @@ export function IssueReferencePill({ /** Reserves space for a separate hover/focus action without moving the task link. */ onRemove?: (issueId: string) => void; }) { + // Private issue referenced from a visible edge — never leak a title or a link. + if (isLockedIssueStub(issue)) { + return ; + } const issueLabel = issue.identifier ?? issue.title; const classNames = cn( variant === "property" || onRemove diff --git a/ui/src/components/IssueShareSheet.test.tsx b/ui/src/components/IssueShareSheet.test.tsx new file mode 100644 index 00000000000..84ab5c0c900 --- /dev/null +++ b/ui/src/components/IssueShareSheet.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IssueAccessGrant } from "@paperclipai/shared"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +const originalScrollIntoView = Element.prototype.scrollIntoView; +const listAccessGrants = vi.fn(); +const createAccessGrant = vi.fn(); +const revokeAccessGrant = vi.fn(); +const listUserDirectory = vi.fn(); + +vi.mock("@/api/issues", () => ({ + issuesApi: { + listAccessGrants: (...args: unknown[]) => listAccessGrants(...args), + createAccessGrant: (...args: unknown[]) => createAccessGrant(...args), + revokeAccessGrant: (...args: unknown[]) => revokeAccessGrant(...args), + }, +})); +vi.mock("@/api/access", () => ({ + accessApi: { listUserDirectory: (...args: unknown[]) => listUserDirectory(...args) }, +})); +vi.mock("@/api/agents", () => ({ + agentsApi: { list: vi.fn().mockResolvedValue([]) }, +})); +vi.mock("@/context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: vi.fn() }), +})); + +import { IssueShareSheet } from "./IssueShareSheet"; +import { queryKeys } from "@/lib/queryKeys"; + +function grant(overrides: Partial): IssueAccessGrant { + return { + id: "g", + issueId: "i1", + subjectType: "user", + subjectId: "u1", + source: "explicit", + grantedByUserId: null, + grantedByAgentId: null, + createdAt: new Date("2026-08-01T00:00:00Z"), + revokedAt: null, + subjectDisplayName: "Ada", + subjectAvatarUrl: null, + subjectInitials: "A", + agentVisibility: null, + ...overrides, + }; +} + +// React Query resolves its mocked promises on the microtask/macrotask queue, +// outside flushSync. Interleave real awaits with flushSync to let the observer +// receive data and re-render. +async function settle(times = 6) { + for (let i = 0; i < times; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); + } +} + +describe("IssueShareSheet", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + Element.prototype.scrollIntoView = () => {}; + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + listAccessGrants.mockReset(); + createAccessGrant.mockReset(); + revokeAccessGrant.mockReset(); + listUserDirectory.mockReset().mockResolvedValue({ users: [] }); + }); + + afterEach(() => { + if (root) act(() => root?.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + Element.prototype.scrollIntoView = originalScrollIntoView; + }); + + async function renderSheet(canManage = true, implicitPrincipals: import("./IssueShareSheet").ShareSheetImplicitPrincipal[] = []) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(queryKeys.issues.accessGrants("descendant"), []); + root = createRoot(container); + act(() => { + root!.render( + + {}} + /> + , + ); + }); + await settle(); + return client; + } + + it("renders a source badge per grant and gates Revoke by source", async () => { + listAccessGrants.mockResolvedValue([ + grant({ id: "g1", source: "explicit", subjectDisplayName: "Ada" }), + grant({ id: "g2", source: "assignment", subjectType: "agent", subjectId: "a1", subjectDisplayName: "Helper" }), + grant({ id: "g3", source: "project", subjectDisplayName: "Proj User" }), + ]); + await renderSheet(true); + + // Body is portalled to document.body by Radix Dialog. + const scope = document.body; + expect(scope.querySelector('[data-testid="grant-source-badge-explicit"]')).not.toBeNull(); + expect(scope.querySelector('[data-testid="grant-source-badge-assignment"]')).not.toBeNull(); + expect(scope.querySelector('[data-testid="grant-source-badge-project"]')).not.toBeNull(); + + const revokeButtons = [...scope.querySelectorAll("button")].filter((b) => + b.textContent?.trim() === "Revoke", + ); + // explicit + assignment are revocable; project is not. + expect(revokeButtons).toHaveLength(2); + expect(scope.textContent).toContain("project-managed"); + }); + + it("removes a saved grant while explaining that assignment access remains", async () => { + listAccessGrants.mockResolvedValue([grant({ id: "g1", source: "assignment", subjectType: "agent", subjectId: "a1" })]); + const client = await renderSheet(true, [{ id: "agent:a1", displayName: "Helper", roleLabel: "Current assignee" }]); + expect([...document.body.querySelectorAll("button")].filter(button => button.textContent?.trim() === "Revoke")).toHaveLength(0); + expect(document.body.textContent).toContain("access remains while this role applies"); + const removeSaved = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove saved grant"); + expect(removeSaved).toBeDefined(); + act(() => removeSaved!.click()); + await settle(); + expect(document.body.textContent).toContain("saved grant?"); + revokeAccessGrant.mockResolvedValue({}); + const confirm = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove grant"); + act(() => confirm!.click()); + await settle(); + expect(revokeAccessGrant).toHaveBeenCalledWith("i1", "g1"); + expect(client.getQueryState(queryKeys.issues.accessGrants("descendant"))?.isInvalidated).toBe(true); + }); + + it.each([ + { source: "project" as const, inherited: true }, + { source: "explicit" as const, inherited: true }, + ])("allows an independent direct grant alongside $source access", async (access) => { + listAccessGrants.mockResolvedValue([grant({ id: "broader", ...access })]); + listUserDirectory.mockResolvedValue({ users: [{ user: { id: "u1", name: "Ada", email: "ada@example.test", image: null } }] }); + createAccessGrant.mockResolvedValue(grant({ id: "direct" })); + const client = await renderSheet(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add someone")!.click()); + await settle(); + act(() => (document.body.querySelector('[role="combobox"]') as HTMLElement).click()); + await settle(); + const option = [...document.body.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Ada")); + expect(option).toBeDefined(); + act(() => (option as HTMLElement).click()); + await settle(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add")!.click()); + await settle(); + expect(createAccessGrant).toHaveBeenCalledWith("i1", { subjectType: "user", subjectId: "u1" }); + expect(client.getQueryState(queryKeys.issues.accessGrants("descendant"))?.isInvalidated).toBe(true); + }); + + it("does not offer a second grant for an existing direct assignment grant", async () => { + listAccessGrants.mockResolvedValue([grant({ source: "assignment" })]); + listUserDirectory.mockResolvedValue({ users: [{ user: { id: "u1", name: "Ada", image: null } }] }); + await renderSheet(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add someone")!.click()); + await settle(); + act(() => (document.body.querySelector('[role="combobox"]') as HTMLElement).click()); + await settle(); + expect([...document.body.querySelectorAll('[role="option"]')].some(option => option.textContent?.includes("Ada"))).toBe(false); + expect(createAccessGrant).not.toHaveBeenCalled(); + }); + + it("hides Revoke entirely for non-setters", async () => { + listAccessGrants.mockResolvedValue([grant({ id: "g1", source: "explicit" })]); + await renderSheet(false); + const revokeButtons = [...document.body.querySelectorAll("button")].filter((b) => + b.textContent?.trim() === "Revoke", + ); + expect(revokeButtons).toHaveLength(0); + }); + + it("shows the empty state when there are no grants", async () => { + listAccessGrants.mockResolvedValue([]); + await renderSheet(true); + expect(document.body.querySelector('[data-testid="share-sheet-empty"]')?.textContent).toContain( + "Only you can see this task", + ); + }); + + it("ignores revoked grants", async () => { + listAccessGrants.mockResolvedValue([ + grant({ id: "g1", source: "explicit", revokedAt: new Date("2026-08-01T01:00:00Z") }), + ]); + await renderSheet(true); + expect(document.body.querySelector('[data-testid="share-sheet-empty"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/IssueShareSheet.tsx b/ui/src/components/IssueShareSheet.tsx new file mode 100644 index 00000000000..c2269bc397b --- /dev/null +++ b/ui/src/components/IssueShareSheet.tsx @@ -0,0 +1,506 @@ +import { AgentAvatar } from "./AgentAvatar"; +import { Identity } from "./Identity"; +import { renderAccessIdentity, type AccessSelectOption } from "./AccessSelectIdentity"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + Check, + Folder, + Lock, + RotateCcw, + UserPlus, +} from "lucide-react"; +import type { IssueAccessGrant, IssueAccessGrantSource } from "@paperclipai/shared"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect"; +import { Skeleton } from "@/components/ui/skeleton"; +import { agentsApi } from "@/api/agents"; +import { accessApi } from "@/api/access"; +import { issuesApi } from "@/api/issues"; +import { useToastActions } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { timeAgo } from "@/lib/timeAgo"; +import { cn } from "@/lib/utils"; +import { + agentVisibilityFromPermissions, + grantIsRevocable, + isSharedAgentVisibility, +} from "@/lib/issuePrivacy"; + +/** + * A role-based principal that can read the task by virtue of *who they are* + * (responsible user, creator, current assignee) rather than an explicit grant. + * Implicit principals are shown with a muted role badge and have NO revoke — + * you change their access by changing the role, not from the share sheet. + * The caller derives these (it holds the agent/user directories); the sheet + * only renders them. + */ +export interface ShareSheetImplicitPrincipal { + id: string; + displayName: string; + roleLabel: string; + subtitle?: string | null; + avatarUrl?: string | null; + initials?: string | null; +} + +interface SourceBadgeSpec { + label: string; + icon: typeof UserPlus; + className: string; +} + +// Source labels and icons distinguish access reasons; colors use semantic tokens. +const SOURCE_BADGES: Record = { + owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" }, + explicit: { + label: "shared directly", + icon: UserPlus, + className: + "border-primary/30 bg-primary/10 text-primary", + }, + assignment: { + label: "via assignment", + icon: Check, + className: + "border-border bg-secondary text-secondary-foreground", + }, + project: { + label: "via project", + icon: Folder, + className: + "border-border bg-accent text-accent-foreground", + }, +}; + +function SourceBadge({ source }: { source: IssueAccessGrantSource }) { + const spec = SOURCE_BADGES[source]; + const Icon = spec.icon; + return ( + + + ); +} + +function PrincipalAvatar({ + displayName, + avatarUrl, + initials, +}: { + displayName: string; + avatarUrl?: string | null; + initials?: string | null; +}) { + return ( + + ); +} + +// The named type ladder in index.css explicitly prefers Tailwind text-xs and +// text-sm for its 12px and 14px tiers; these are the shared scale tokens. +function RowShell({ + avatar, + title, + subtitle, + badge, + action, +}: { + avatar: React.ReactNode; + title: React.ReactNode; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+ {avatar} +
+
+ {title} + {badge} +
+ {subtitle ?
{subtitle}
: null} +
+ {action ?
{action}
: null} +
+ ); +} + +export function IssueShareSheet({ + issueId, + companyId, + canManage, + open, + onOpenChange, + aclDescription = "Sharing grants access to this task and its descendants, including comments, documents, and run history. Parents and siblings need separate access.", + implicitPrincipals = [], + initialView = "list", + initialAddSelection = "", +}: { + issueId: string; + companyId: string; + canManage: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + aclDescription?: string; + implicitPrincipals?: ShareSheetImplicitPrincipal[]; + /** Initial panel — `add` opens straight into the add flow (stories/tests). */ + initialView?: "list" | "add"; + /** Pre-selected add subject (`type:id`), used by stories/tests to show the caution. */ + initialAddSelection?: string; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [view, setView] = useState<"list" | "add">(initialView); + const [addSelection, setAddSelection] = useState(initialAddSelection); + const [revokeTarget, setRevokeTarget] = useState(null); + + const grantsQuery = useQuery({ + queryKey: queryKeys.issues.accessGrants(issueId), + queryFn: () => issuesApi.listAccessGrants(issueId), + enabled: open, + }); + const directoryQuery = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(companyId), + queryFn: () => accessApi.listUserDirectory(companyId), + enabled: open && view === "add" && canManage, + }); + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(companyId), + queryFn: () => agentsApi.list(companyId), + enabled: open && view === "add" && canManage, + }); + + const activeGrants = useMemo( + () => (grantsQuery.data ?? []).filter((grant) => grant.revokedAt === null), + [grantsQuery.data], + ); + const grantedSubjectKeys = useMemo( + () => new Set(activeGrants.filter((grant) => !grant.inherited && grant.source !== "project") + .map((grant) => `${grant.subjectType}:${grant.subjectId}`)), + [activeGrants], + ); + + const addMutation = useMutation({ + mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) => + issuesApi.createAccessGrant(issueId, payload), + onSuccess: () => { + // A task grant also changes descendant access and cached task discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + setAddSelection(""); + setView("list"); + pushToast({ title: "Access granted", tone: "success" }); + }, + onError: (error) => { + pushToast({ title: "Couldn't add access", body: (error as Error).message, tone: "error" }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (grant: IssueAccessGrant) => issuesApi.revokeAccessGrant(issueId, grant.id), + onSuccess: () => { + // A task grant also changes descendant access and cached task discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + setRevokeTarget(null); + pushToast({ title: "Access revoked", tone: "success" }); + }, + onError: (error) => { + pushToast({ title: "Couldn't revoke access", body: (error as Error).message, tone: "error" }); + }, + }); + + // Candidate subjects for the "Add someone" combobox, minus anyone who already + // holds a direct grant on this task. Inherited/project access can be supplemented + // with an independent child grant before broader access is removed. Value encodes both type and id: `type:id`. + const addGroups = useMemo[]>(() => { + const users = (directoryQuery.data?.users ?? []) + .filter((entry) => entry.user && !grantedSubjectKeys.has(`user:${entry.user.id}`)) + .map((entry) => ({ + key: `user:${entry.user!.id}`, + value: `user:${entry.user!.id}`, + label: entry.user!.name ?? entry.user!.email ?? "Unknown user", + searchText: [entry.user!.name, entry.user!.email].filter(Boolean).join(" "), + identity: , + })); + const agents = (agentsQuery.data ?? []) + .filter((agent) => !grantedSubjectKeys.has(`agent:${agent.id}`)) + .map((agent) => ({ + key: `agent:${agent.id}`, + value: `agent:${agent.id}`, + label: agent.name, + searchText: agent.name, + identity: <>{agent.name}, + })); + const groups: SearchableSelectGroup[] = []; + if (users.length > 0) groups.push({ id: "people", label: "People", options: users }); + if (agents.length > 0) groups.push({ id: "agents", label: "Agents", options: agents }); + return groups; + }, [directoryQuery.data, agentsQuery.data, grantedSubjectKeys]); + + // Is the currently-selected add subject a *shared* agent? Drives the caution. + const selectedSharedAgentName = useMemo(() => { + if (!addSelection.startsWith("agent:")) return null; + const agentId = addSelection.slice("agent:".length); + const agent = (agentsQuery.data ?? []).find((candidate) => candidate.id === agentId); + if (!agent) return null; + return isSharedAgentVisibility(agentVisibilityFromPermissions(agent.permissions)) + ? agent.name + : null; + }, [addSelection, agentsQuery.data]); + + function resetAndClose(next: boolean) { + if (!next) { + setView("list"); + setAddSelection(""); + setRevokeTarget(null); + } + onOpenChange(next); + } + + function submitAdd() { + if (!addSelection) return; + const [subjectType, subjectId] = addSelection.split(/:(.+)/) as ["user" | "agent", string]; + if (!subjectId) return; + addMutation.mutate({ subjectType, subjectId }); + } + + const isLoading = grantsQuery.isLoading; + const isError = grantsQuery.isError; + const isEmpty = !isLoading && !isError && activeGrants.length === 0 && implicitPrincipals.length === 0; + + return ( + + + + + + {aclDescription} + + + {view === "list" ? ( +
+ {isLoading ? ( +
+ {[0, 1, 2].map((row) => ( +
+ +
+ + +
+
+ ))} +
+ ) : isError ? ( +
+ + + +
+ ) : isEmpty ? ( +

+ Only you can see this task. +

+ ) : ( +
+ {implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => ( + + } + title={principal.displayName} + subtitle={principal.subtitle ?? undefined} + badge={ + + {principal.roleLabel} + + } + /> + ))} + {activeGrants.map((grant) => { + const displayName = grant.subjectDisplayName ?? "Unknown"; + const implicitPrincipal = implicitPrincipals.find(principal => + principal.id === `${grant.subjectType}:${grant.subjectId}`); + const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source); + const granter = grant.source === "explicit" ? "Shared" : "Granted"; + return ( + + } + title={displayName} + subtitle={implicitPrincipal ? `${implicitPrincipal.roleLabel} — access remains while this role applies` : grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`} + badge={} + action={ + revocable ? ( + + ) : grant.source === "project" ? ( + + project-managed + + ) : null + } + /> + ); + })} +
+ )} + + {canManage ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ option ? renderAccessIdentity(option) : "Choose a person or agent…"} + renderOption={renderAccessIdentity} + onValueChange={(value) => setAddSelection(value)} + placeholder="Choose a person or agent…" + searchPlaceholder="Search people and agents…" + loading={directoryQuery.isLoading || agentsQuery.isLoading} + loadingMessage="Loading directory…" + emptyMessage="No one left to add." + triggerClassName="w-full" + /> + + {selectedSharedAgentName ? ( +
+
+ ) : null} + +
+ + +
+
+ )} +
+ + { + if (!next) setRevokeTarget(null); + }} + > + + + + Remove {revokeTarget?.subjectDisplayName ?? "this subject"}'s saved grant? + + + This removes this grant and the access it supplies to this task's descendants. + Ownership, current assignment, project membership, inherited access, or another grant + can still provide access. Revoking access does not erase + content already seen or retained in an agent's memory. + + + + Cancel + { + event.preventDefault(); + if (revokeTarget) revokeMutation.mutate(revokeTarget); + }} + disabled={revokeMutation.isPending} + > + {revokeMutation.isPending ? "Removing…" : "Remove grant"} + + + + +
+ ); +} diff --git a/ui/src/components/LockedIssueChip.test.tsx b/ui/src/components/LockedIssueChip.test.tsx new file mode 100644 index 00000000000..f94bb6a1ff1 --- /dev/null +++ b/ui/src/components/LockedIssueChip.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void) { + flushSync(callback); +} + +describe("LockedIssueChip", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) act(() => root?.unmount()); + container.remove(); + }); + + function renderChip(node: React.ReactNode) { + root = createRoot(container); + act(() => root!.render(node)); + } + + it("shows the identifier and a lock icon, never a title, and is not a link", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip).not.toBeNull(); + expect(chip?.tagName).toBe("SPAN"); + expect(container.querySelector("a")).toBeNull(); + expect(chip?.querySelector("svg")).not.toBeNull(); + expect(chip?.textContent).toContain("PAP-1234"); + expect(chip?.getAttribute("aria-label")).toContain("private"); + }); + + it("does not claim a missing or forbidden Markdown target is private", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip?.getAttribute("aria-label")).toContain("unavailable"); + expect(chip?.getAttribute("aria-label")).not.toContain("private"); + }); + + it("falls back to a neutral Private label when the identifier is withheld", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip?.textContent).toContain("Private"); + }); + + it("uses a dashed, muted border (design-token classes)", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip?.className).toContain("border-dashed"); + expect(chip?.className).toContain("text-muted-foreground"); + }); +}); + +describe("isLockedIssueStub", () => { + it("recognizes only objects with locked === true", () => { + expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", locked: true })).toBe(true); + expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", title: "t", status: "todo" })).toBe(false); + expect(isLockedIssueStub({ locked: false })).toBe(false); + expect(isLockedIssueStub(null)).toBe(false); + expect(isLockedIssueStub(undefined)).toBe(false); + }); +}); diff --git a/ui/src/components/LockedIssueChip.tsx b/ui/src/components/LockedIssueChip.tsx new file mode 100644 index 00000000000..a714c88c68b --- /dev/null +++ b/ui/src/components/LockedIssueChip.tsx @@ -0,0 +1,51 @@ +import { CircleHelp, Lock } from "lucide-react"; +import type { IssueLockedStub } from "@paperclipai/shared"; +import { cn } from "../lib/utils"; + +/** + * Existence-only chip for a private issue referenced from a surface the current + * viewer *can* see (a visible blocker/relation edge, or a mention in a visible + * comment). Renders the mono identifier + a lock icon and nothing else — no + * title ever leaks, and the chip is intentionally non-interactive (a private + * issue 404s on direct fetch, so there is nowhere to link to). + * + * Feed it the exact `{ id, identifier, locked: true }` stub the server emits in + * place of an `IssueRelationIssueSummary`; use `isLockedIssueStub` to branch. + */ +export function isLockedIssueStub(value: unknown): value is IssueLockedStub { + return ( + typeof value === "object" && + value !== null && + (value as { locked?: unknown }).locked === true + ); +} + +export function LockedIssueChip({ + identifier, + className, + unavailable = false, +}: { + identifier: string | null; + className?: string; + unavailable?: boolean; +}) { + // Identifier is the only viewer-safe field on the stub. When the server + // withheld even that, fall back to a neutral "Private" label so the chip + // still reads as a locked reference rather than an empty box. + const label = identifier ?? (unavailable ? "Unavailable" : "Private"); + const Icon = unavailable ? CircleHelp : Lock; + return ( + + + ); +} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 7d5aba00be0..4c07fdef31a 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -307,6 +307,32 @@ describe("MarkdownBody", () => { expect(html).not.toContain("paperclip-mention-chip--issue"); }); + it("holds the quicklook preview until an issue mention is confirmed readable (PAP-16070)", () => { + // While the fetch is in flight we don't know if the viewer can read the + // issue. The mention stays a clickable link, but the IssueLinkQuicklook + // hover preview (a Radix Popover portal) must NOT mount yet: a private + // mention 404s straight to the locked chip, and mounting then tearing down + // that portal to swap in the chip is the element churn that crashed the + // chat transcript renderer. + const html = renderMarkdown("Depends on PAP-1271 for the hover state."); + + expect(html).toContain('data-mention-pending="true"'); + expect(html).toContain('data-mention-kind="issue"'); + expect(html).toContain("paperclip-markdown-issue-ref"); + // Clickability is preserved through the loading state. + expect(html).toContain('href="/issues/PAP-1271"'); + expect(html).toContain(">PAP-1271<"); + }); + + it("drops the pending marker once a seeded (readable) issue mention resolves", () => { + const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [ + { identifier: "PAP-1271", status: "done" }, + ]); + + expect(html).toContain('href="/issues/PAP-1271"'); + expect(html).not.toContain('data-mention-pending="true"'); + }); + it("uses concise issue aria labels until a distinct title is available", () => { const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [ { identifier: "PAP-1271", status: "done" }, diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 6c4a9f11b45..e45e93fd9b0 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -10,6 +10,8 @@ import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; +import { ApiError } from "../api/client"; +import { LockedIssueChip } from "./LockedIssueChip"; import { agentsApi } from "../api/agents"; import { getCachedIssueDetail } from "../lib/issueDetailCache"; import { queryKeys } from "../lib/queryKeys"; @@ -145,7 +147,7 @@ function MarkdownIssueLink({ }) { const queryClient = useQueryClient(); const [engaged, setEngaged] = useState(false); - const { data } = useQuery({ + const { data, error } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), // A transcript can mention dozens of tasks. Their full detail projections // are hover information, not prerequisites for reading this conversation. @@ -153,17 +155,42 @@ function MarkdownIssueLink({ placeholderData: getCachedIssueDetail(queryClient, issuePathId), queryFn: () => issuesApi.get(issuePathId), staleTime: 60_000, + // A private issue 404s on direct fetch (indistinguishable from deleted, by + // design). Don't burn retries on it — settle straight to the locked chip. + retry: (failureCount, err) => + !(err instanceof ApiError && err.status === 404) && failureCount < 3, }); + // Mention of an issue this viewer can't read → existence-only locked chip + // (never a title or a link), matching the locked-stub treatment on edges. + if (error instanceof ApiError && error.status === 404) { + return ; + } + const identifier = data?.identifier ?? issuePathId; const title = data?.title ?? identifier; const status = data?.status; const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`; + // Until the fetch settles we don't yet know whether this viewer can read the + // issue. Keep the mention a plain link (clickable, styled as today) but hold + // off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal — + // until `data` confirms the issue is readable. + // - Correctness (PAP-16070): a private mention 404s straight to the locked + // chip. Mounting the quicklook popover during the loading `` only to + // tear the portal down and swap in a plain `` chip on the 404 is the + // element churn that crashed the chat transcript's primary renderer (it + // fell through to the safe fallback). Loading link → chip stays portal-free. + // - Privacy: don't prefetch / hover-preview an issue of unconfirmed + // readability. `data-mention-pending` marks the transient state for tests. + const pending = !data; + return ( setEngaged(true)} onFocus={() => setEngaged(true)} // Boxless inline mention: the unified status glyph + a regular-weight diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index a203a293e84..731ed34a569 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -49,6 +49,7 @@ const toastState = vi.hoisted(() => ({ const navigateMock = vi.hoisted(() => vi.fn()); const mockIssuesApi = vi.hoisted(() => ({ + get: vi.fn(), list: vi.fn(), create: vi.fn(), upsertDocument: vi.fn(), @@ -245,8 +246,12 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/toggle-switch", () => ({ - ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => ( - + ToggleSwitch: ({ checked, onCheckedChange, ...props }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + "aria-label"?: string; + }) => ( + ), })); @@ -368,6 +373,7 @@ describe("NewIssueDialog", () => { mockIssuesApi.list.mockReset().mockResolvedValue([]); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open" }); mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); @@ -691,6 +697,128 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("restores private visibility from a saved draft without a project", async () => { + localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Private draft", description: "", status: "todo", priority: "", projectId: "", isPrivate: true })); + const { root } = renderDialog(container); + await flush(); + const toggle = container.querySelector('button[data-testid="composer-private-chip"]')!; + await waitForAssertion(() => expect(toggle).not.toBeNull()); + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create task")!; + await act(async () => submit.click()); + await flush(); + expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ title: "Private draft", visibility: "private" })); + act(() => root.unmount()); + }); + + it("waits for parent access before offering privacy or creating a child", async () => { + let resolveParent!: (value: unknown) => void; + mockIssuesApi.get.mockReturnValue(new Promise(resolve => { resolveParent = resolve; })); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + const submit = container.querySelector('button[aria-label="Create sub-task"]')!; + expect(submit.disabled).toBe(true); + expect(container.textContent).toContain("Checking parent access"); + await act(async () => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + await flush(); + expect(document.querySelector('[data-testid="composer-add-private"]')).toBeNull(); + expect(mockIssuesApi.create).not.toHaveBeenCalled(); + await act(async () => resolveParent({ id: "issue-1", visibility: "private" })); + await flush(); + await waitForAssertion(() => expect(container.querySelector('[data-testid="composer-private-chip"]')?.disabled).toBe(true)); + expect(submit.disabled).toBe(false); + act(() => root.unmount()); + }); + + it("keeps submission blocked after a parent lookup error and lets the user retry", async () => { + mockIssuesApi.get.mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValue({ id: "issue-1", visibility: "private" }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await waitForAssertion(() => expect(container.textContent).toContain("Couldn't check parent access.")); + expect(container.querySelector('button[aria-label="Create sub-task"]')?.disabled).toBe(true); + await act(async () => Array.from(container.querySelectorAll('button')).find(button => button.textContent === "Retry")!.click()); + await flush(); + await waitForAssertion(() => expect(container.querySelector('[data-testid="composer-private-chip"]')?.disabled).toBe(true)); + expect(mockIssuesApi.create).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("locks inherited privacy and submits the child as private", async () => { + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "private", title: "Board briefing" }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await flush(); + const toggle = container.querySelector('button[data-testid="composer-private-chip"]')!; + await waitForAssertion(() => expect(toggle).not.toBeNull()); + expect(toggle.disabled).toBe(true); + expect(toggle.title).toBe("Subtask of private task Board briefing"); + await typeTextareaValue(container.querySelector('textarea')!, "Private child draft"); + // Saving this form must retain only the explicit toggle choice. A later + // standalone draft must not acquire a sticky private flag from its parent. + await act(async () => { await new Promise(resolve => setTimeout(resolve, 850)); }); + expect(JSON.parse(localStorage.getItem("paperclip:issue-draft")!).isPrivate).toBe(false); + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create sub-task")!; + await act(async () => submit.click()); + await flush(); + expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ parentId: "issue-1", visibility: "private" })); + act(() => root.unmount()); + }); + + it("names the private project inherited through an open parent", async () => { + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open", title: "Board briefing", project: { id: "private-project", name: "Executive planning", visibility: "private" } }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await waitForAssertion(() => { + const chip = container.querySelector('[data-testid="composer-private-chip"]'); + expect(chip?.disabled).toBe(true); + expect(chip?.title).toBe("In private project Executive planning"); + }); + act(() => root.unmount()); + }); + + it("defaults a private task to the current user's personal private project", async () => { + mockProjectsApi.list.mockResolvedValue([ + { + id: "personal-project", + companyId: "company-1", + name: "My private tasks", + visibility: "private", + personalOwnerUserId: "user-1", + archivedAt: null, + }, + ]); + dialogState.newIssueDefaults = { title: "Private scratch task" }; + + const { root } = renderDialog(container); + await flush(); + await flush(); + await act(async () => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + await flush(); + const privateToggle = document.querySelector('[data-testid="composer-add-private"]'); + expect(privateToggle).not.toBeNull(); + await act(async () => privateToggle!.click()); + await flush(); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.getAttribute("aria-label") === "Create task"); + await waitForAssertion(() => expect(submitButton?.hasAttribute("disabled")).toBe(false)); + await act(async () => submitButton!.click()); + await flush(); + + expect(mockIssuesApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + title: "Private scratch task", + visibility: "private", + projectId: "personal-project", + }), + ); + act(() => root.unmount()); + }); + it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => { const { root } = renderDialog(container); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 9967daeea83..c4eb7c7c9d9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -33,7 +33,7 @@ import { useToastActions } from "../context/ToastContext"; import { assigneeValueFromSelection, currentUserAssigneeOption, parseAssigneeValue } from "../lib/assignees"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown } from "lucide-react"; +import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown, Lock } from "lucide-react"; import { cn } from "../lib/utils"; import type { MentionOption } from "./MarkdownEditor"; import { TaskChatComposer } from "./task-chat/TaskChatComposer"; @@ -113,6 +113,7 @@ function useVisualViewportLayout(enabled: boolean) { } interface IssueDraft { + isPrivate?: boolean; title: string; description: string; status: string; @@ -305,6 +306,7 @@ export function NewIssueDialog() { const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState("shared_workspace"); const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState(""); const [workMode, setWorkMode] = useState("standard"); + const [isPrivate, setIsPrivate] = useState(false); const { isMobile } = useSidebar(); const [composerSettings, setComposerSettings] = useState(null); const [stagedFiles, setStagedFiles] = useState([]); @@ -388,6 +390,22 @@ export function NewIssueDialog() { retry: false, }); + const { data: privacyParent, isError: parentPrivacyError, refetch: refetchParentPrivacy } = useQuery({ + queryKey: queryKeys.issues.detail(newIssueDefaults.parentId ?? ""), + queryFn: () => issuesApi.get(newIssueDefaults.parentId!), + enabled: newIssueOpen && Boolean(newIssueDefaults.parentId), + retry: false, + }); + const parentPrivacyUnresolved = isSubIssueMode && !privacyParent; + const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private"; + const privateParentProject = privacyParent?.project?.visibility === "private" ? privacyParent.project : null; + const inheritedPrivateProject = privateParentProject ?? (!isPrivate && currentProject?.visibility === "private" ? currentProject : null); + const inheritedPrivacyReason = privacyParent?.visibility === "private" + ? `Subtask of private task ${privacyParent.title || newIssueDefaults.parentTitle || parentIssueLabel}` + : inheritedPrivateProject ? `In private project ${inheritedPrivateProject.name}` : undefined; + const effectivePrivate = isPrivate || inheritsPrivateAccess + || orderedProjects.some(project => project.id === projectId && project.visibility === "private"); + const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]); const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; const selectedAssigneeUserId = selectedAssignee.assigneeUserId; @@ -513,6 +531,7 @@ export function NewIssueDialog() { executionWorkspaceMode, selectedExecutionWorkspaceId, workMode, + isPrivate, composerSettings, }); }, @@ -534,6 +553,7 @@ export function NewIssueDialog() { executionWorkspaceMode, selectedExecutionWorkspaceId, workMode, + isPrivate, composerSettings, ], ); @@ -591,6 +611,7 @@ export function NewIssueDialog() { defaultProjectPendingRef.current = newIssueDefaults.projectId === undefined && !newIssueDefaults.parentId; defaultAssigneePendingRef.current = !newIssueDefaults.assigneeAgentId && !newIssueDefaults.assigneeUserId; setComposerSettings(null); + setIsPrivate(false); createIssue.reset(); if (newIssueDefaults.parentId) { const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard"; @@ -654,6 +675,7 @@ export function NewIssueDialog() { const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined; const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined; + setIsPrivate(draft.isPrivate ?? false); setIssueText(draft.title, draft.description); setComposerSettings(draft.composerSettings ?? null); setStatus(draft.status || "todo"); @@ -756,6 +778,7 @@ export function NewIssueDialog() { setWorkMode("standard"); setComposerSettings(null); + setIsPrivate(false); setStagedFiles([]); setIsFileDragOver(false); @@ -767,7 +790,7 @@ export function NewIssueDialog() { async function handleSubmit(body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) { const currentTitle = titleRef.current.trim(); const currentDescription = body.trim(); - if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || worktreeSelectionIncomplete) return; + if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return; const inheritedOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, lane: assigneeChrome ? "custom" : assigneeModelLane, @@ -815,6 +838,7 @@ export function NewIssueDialog() { status, priority: priority || "medium", workMode: mode, + ...(effectivePrivate ? { visibility: "private" } : {}), ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}), @@ -1156,6 +1180,18 @@ export function NewIssueDialog() { value: description, onChange: handleDescriptionChange, onSubmit: handleSubmit, + privacy: parentPrivacyUnresolved ? undefined : { + private: effectivePrivate, + inherited: inheritedPrivacyReason, + onChange: (checked) => { + setIsPrivate(checked); + if (!checked && currentProject?.visibility === "private") handleProjectChange(""); + if (checked && !projectId && currentUserId) { + const personalProject = orderedProjects.find(project => project.personalOwnerUserId === currentUserId); + if (personalProject) handleProjectChange(personalProject.id); + } + }, + }, submitLabel: isSubIssueMode ? "Create sub-task" : "Create task", canSubmitWithoutBody: Boolean(title.trim()), onSelectFiles: stageFiles, @@ -1195,6 +1231,12 @@ export function NewIssueDialog() { ) : undefined, details: ( <> + {parentPrivacyUnresolved ? ( +
+ {parentPrivacyError ? "Couldn't check parent access." : "Checking parent access…"} + {parentPrivacyError ? : null} +
+ ) : null} {worktreeSelectionIncomplete && !worktreesLoading ? (

{worktreesError ? "Couldn't check the selected worktree. Retry in Worktrees or choose New worktree." @@ -1334,7 +1376,7 @@ export function NewIssueDialog() { ) : null} ), - submitDisabled: worktreeSelectionIncomplete, + submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved, contextBar: ( <> {option.label} + {currentProject.visibility === "private" ? : null} ) : ( @@ -1374,7 +1417,8 @@ export function NewIssueDialog() { return ( <> - {option.label} + {option.label} + {project?.visibility === "private" ? : null} ); }} diff --git a/ui/src/components/PrivateTaskPrivacyControls.test.tsx b/ui/src/components/PrivateTaskPrivacyControls.test.tsx new file mode 100644 index 00000000000..51082cf5a3a --- /dev/null +++ b/ui/src/components/PrivateTaskPrivacyControls.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/lib/queryKeys"; +const api = vi.hoisted(() => ({ getIssue: vi.fn(), getProject: vi.fn(), constraints: vi.fn(), setVisibility: vi.fn(), + members: vi.fn(), add: vi.fn(), remove: vi.fn(), directory: vi.fn() })); +vi.mock("@/api/issues", () => ({ issuesApi: { get: api.getIssue, privacyConstraints: api.constraints, setVisibility: api.setVisibility } })); +vi.mock("@/api/projects", () => ({ projectsApi: { get: api.getProject, listAccessMembers: api.members, + addAccessMember: api.add, removeAccessMember: api.remove } })); +vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: api.directory } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: () => Promise.resolve([]) } })); +vi.mock("@/context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); +import { IssuePrivacyActions } from "./IssuePrivacyActions"; +import { ProjectAccessMembers } from "./ProjectAccessMembers"; +import type { Project } from "@paperclipai/shared"; +async function settle() { + for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } +} +const originalScrollIntoView = Element.prototype.scrollIntoView; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + Element.prototype.scrollIntoView = () => {}; + vi.resetAllMocks(); + api.directory.mockResolvedValue({ users: [] }); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + api.members.mockResolvedValue([]); + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(() => { flushSync(() => root.unmount()); client.clear(); document.body.innerHTML = ""; + vi.unstubAllGlobals(); + Element.prototype.scrollIntoView = originalScrollIntoView; }); +function render(node: React.ReactNode) { flushSync(() => root.render({node})); } +function publicButton() { return [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === "Make public")!; } +function actions(scope: { privacyParentIssueId?: string; projectId?: string } = {}) { + render( {}}>{items =>

{items}
}); +} +describe("inherited privacy controls", () => { + it("blocks a private parent and does not send an impossible visibility change", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); + expect(publicButton().disabled).toBe(true); + publicButton().click(); expect(api.setVisibility).not.toHaveBeenCalled(); + }); + it("blocks a non-personal private project but permits leaving a personal project", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "project", leavesPersonalProject: false }); + actions({ projectId: "project" }); await settle(); expect(publicButton().disabled).toBe(true); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); + await client.invalidateQueries({ queryKey: queryKeys.issues.privacyConstraints("child") }); await settle(); + expect(publicButton().disabled).toBe(false); + }); + it.each([ + ["parent", { privacyParentIssueId: "private-parent" }], + ["project", { projectId: "private-project" }], + ] as const)("refreshes task privacy after moving out of a private %s", async (kind, scope) => { + api.constraints.mockResolvedValue({ publicBlockedBy: kind, leavesPersonalProject: false }); + actions(scope); await settle(); + expect(publicButton().disabled).toBe(true); + expect(api.constraints).toHaveBeenCalledTimes(1); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + // Same task ID and mounted action menu: only the surrounding scope changes. + actions(); await settle(); + expect(api.constraints).toHaveBeenCalledTimes(2); + expect(publicButton().disabled).toBe(false); + }); + it("checks a parent's private project even when the parent itself is open", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); + }); + it("permits making a child public after its parent is open", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(false); + }); + it("allows publishing from an unreadable personal project without fetching that project", async () => { + api.getProject.mockRejectedValue(new Error("404")); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); + api.setVisibility.mockResolvedValue({}); + actions({ projectId: "protected-project" }); await settle(); + expect(publicButton().disabled).toBe(false); + expect(api.getProject).not.toHaveBeenCalled(); + flushSync(() => publicButton().click()); await settle(); + expect(document.body.textContent).toContain("leave its personal project"); + const confirm = [...document.body.querySelectorAll('[role="alertdialog"] button')].find(button => button.textContent === "Make public")!; + flushSync(() => (confirm as HTMLButtonElement).click()); await settle(); + expect(api.setVisibility).toHaveBeenCalledWith("child", "open"); + }); + it("explains a protected private parent without offering an impossible retry", async () => { + api.getIssue.mockRejectedValue(new Error("404")); + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "protected-parent" }); await settle(); + expect(publicButton().disabled).toBe(true); + expect(api.getIssue).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("Retry access check"); + }); + it("keeps unresolved and failed scope checks closed, and retries in place", async () => { + let reject!: (error: Error) => void; + api.constraints.mockReturnValue(new Promise((_resolve, rejectFn) => { reject = rejectFn; })); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); + reject(new Error("Unavailable")); await settle(); expect(publicButton().disabled).toBe(true); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent === "Retry access check")!.click()); + await settle(); expect(publicButton().disabled).toBe(false); + }); +}); +it("invalidates cached task audience when project membership is removed", async () => { + const key = queryKeys.issues.accessGrants("child"); + client.setQueryData(key, [{ subjectId: "member", source: "project" }]); + api.members.mockResolvedValue([{ id: "m1", subjectType: "user", subjectId: "member", subjectDisplayName: "Morgan" }]); + api.remove.mockResolvedValue({}); + render(); + flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent?.includes("Manage access"))!.click()); + await settle(); + flushSync(() => (document.body.querySelector('[aria-label="Remove Morgan"]') as HTMLButtonElement).click()); + await settle(); + expect(api.remove).toHaveBeenCalledWith("project", "m1", "company"); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); +}); diff --git a/ui/src/components/ProjectAccessMembers.tsx b/ui/src/components/ProjectAccessMembers.tsx new file mode 100644 index 00000000000..6c12fd8c43c --- /dev/null +++ b/ui/src/components/ProjectAccessMembers.tsx @@ -0,0 +1,165 @@ +import { AgentAvatar } from "./AgentAvatar"; +import { Identity } from "./Identity"; +import { renderAccessIdentity, type AccessSelectOption } from "./AccessSelectIdentity"; +import { agentVisibilityFromPermissions, isSharedAgentVisibility } from "@/lib/issuePrivacy"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Lock, Trash2, UserPlus } from "lucide-react"; +import type { Project } from "@paperclipai/shared"; +import { accessApi } from "@/api/access"; +import { agentsApi } from "@/api/agents"; +import { projectsApi } from "@/api/projects"; +import { useToastActions } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect"; + +export function ProjectAccessMembers({ project, canManage }: { project: Project; canManage: boolean }) { + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState(""); + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const membersKey = queryKeys.projects.accessMembers(project.id); + const membersQuery = useQuery({ + queryKey: membersKey, + queryFn: () => projectsApi.listAccessMembers(project.id, project.companyId), + enabled: open, + }); + const directoryQuery = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(project.companyId), + queryFn: () => accessApi.listUserDirectory(project.companyId), + enabled: open, + }); + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(project.companyId), + queryFn: () => agentsApi.list(project.companyId), + enabled: open, + }); + const activeKeys = useMemo( + () => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)), + [membersQuery.data], + ); + const groups = useMemo[]>(() => { + const people = (directoryQuery.data?.users ?? []) + .filter((entry) => entry.user && !activeKeys.has(`user:${entry.user.id}`)) + .map((entry) => ({ + key: `user:${entry.user!.id}`, + value: `user:${entry.user!.id}`, + label: entry.user!.name ?? entry.user!.email ?? "Unknown user", + searchText: entry.user!.email ?? "", + identity: , + })); + const agentOptions = (agentsQuery.data ?? []) + .filter((agent) => !activeKeys.has(`agent:${agent.id}`)) + .map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name, identity: <>{agent.name} })); + return [ + ...(people.length ? [{ id: "people", label: "People", options: people }] : []), + ...(agentOptions.length ? [{ id: "agents", label: "Agents", options: agentOptions }] : []), + ]; + }, [activeKeys, agentsQuery.data, directoryQuery.data]); + const selectedAgent = selection.startsWith("agent:") + ? agentsQuery.data?.find(agent => agent.id === selection.slice("agent:".length)) : null; + const sharingWithSharedAgent = selectedAgent + && isSharedAgentVisibility(agentVisibilityFromPermissions(selectedAgent.permissions)); + const addMember = useMutation({ + mutationFn: async () => { + const [subjectType, subjectId] = selection.split(/:(.+)/) as ["user" | "agent", string]; + return projectsApi.addAccessMember(project.id, { subjectType, subjectId }, project.companyId); + }, + onSuccess: () => { + setSelection(""); + queryClient.invalidateQueries({ queryKey: membersKey }); + // Project membership changes task access, descendants and cached discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + pushToast({ title: "Project access added", tone: "success" }); + }, + onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }), + }); + const removeMember = useMutation({ + mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: membersKey }); + // Project membership changes task access, descendants and cached discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + pushToast({ title: "Project access removed", tone: "success" }); + }, + onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }), + }); + + return ( + <> + + + + + Private project access + + Members can discover this project and read every task in it. A task can still be shared directly. + + +
+ {directoryQuery.isError || agentsQuery.isError ? ( +

Couldn't load the company access directory.

+ ) : null} +
+ option ? renderAccessIdentity(option) : "Add a person or agent"} + renderOption={renderAccessIdentity} + onValueChange={(value) => setSelection(value)} + placeholder="Add a person or agent" + searchPlaceholder="Search people and agents" + emptyMessage="No more company members to add" + loading={directoryQuery.isLoading || agentsQuery.isLoading} + className="min-w-0 flex-1" + /> + +
+ {sharingWithSharedAgent ? ( +

+ This agent can retain private project context in its memory and workspace for later runs. + Removing access cannot erase what it has already learned. +

+ ) : null} +
+ {(membersQuery.data ?? []).map((member) => { + const isPersonalOwner = member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId); + return ( +
+
+
{member.subjectDisplayName ?? "Unknown member"}
+
+ {isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"} +
+
+ {!isPersonalOwner ? ( + + ) : null} +
+ ); + })} + {membersQuery.isLoading ?

Loading access…

: null} + {membersQuery.isError ? ( +

Couldn't load project access members.

+ ) : null} +
+
+
+
+ + ); +} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index e7e71a5d0ed..aa65c10ee50 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -1,3 +1,6 @@ +import { authApi } from "@/api/auth"; +import { accessApi } from "@/api/access"; +import { canManageProjectPrivacy } from "@/lib/issuePrivacy"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useState, type ReactNode } from "react"; import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment"; @@ -22,6 +25,9 @@ import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { Badge } from "@/components/ui/badge"; +import { ProjectAccessMembers } from "./ProjectAccessMembers"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog"; interface ProjectPropertiesProps { project: Project; @@ -38,6 +44,7 @@ export type ProjectConfigFieldKey = | "name" | "description" | "status" + | "visibility" | "goals" | "env" | "execution_workspace_enabled" @@ -208,7 +215,11 @@ function ArchiveDangerZone({ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); + const { data: privacySession } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const { data: privacyAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() }); + const canManagePrivacy = canManageProjectPrivacy(project, privacySession?.user?.id ?? privacySession?.session?.userId ?? null, privacyAccess); const queryClient = useQueryClient(); + const [openProjectConfirmation, setOpenProjectConfirmation] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); @@ -419,6 +430,24 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda return (
+ + + + Open this project to the company? + + Everyone in the company will be able to discover this project. Its private tasks stay private. + Members who only have access through this project will lose access to those tasks. + Share tasks directly first if you want them to keep access. + + + + Keep private + commitField("visibility", { visibility: "open" })}> + Open to company + + + +
}> {onUpdate || onFieldUpdate ? ( @@ -454,6 +483,31 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda

)}
+ } alignStart> +
+ +

+ {project.visibility === "private" + ? "Only access members can discover this project. Tasks shared directly remain readable on their own." + : "Everyone in the company can discover this project. Individually private tasks keep their own access rules."} +

+ {project.visibility === "private" ? : null} +
+
{repositories ?? } } diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index f35105461bd..7e1c2a7e2d7 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { FolderOpen, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react"; +import { FolderOpen, Lock, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react"; import { DndContext, MouseSensor, @@ -146,6 +146,7 @@ function ProjectItem({ > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail ? : null} {!rail && project.pauseReason === "budget" ? : null} diff --git a/ui/src/components/SidebarStarredProjects.production.tsx b/ui/src/components/SidebarStarredProjects.production.tsx index 264be977160..f5f26fed4f9 100644 --- a/ui/src/components/SidebarStarredProjects.production.tsx +++ b/ui/src/components/SidebarStarredProjects.production.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; +import { Lock, Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { projectsApi } from "../api/projects"; @@ -130,6 +130,7 @@ export function SidebarStarredProjects() { > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail && project.pauseReason === "budget" ? ( ) : null} diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx index 14a5247f1ce..97e724b7e5e 100644 --- a/ui/src/components/SidebarStarredProjects.tsx +++ b/ui/src/components/SidebarStarredProjects.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; +import { Lock, Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { projectsApi } from "../api/projects"; @@ -130,6 +130,7 @@ export function SidebarStarredProjects() { > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail && project.pauseReason === "budget" ? ( ) : null} diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 8e2f6763199..5ea44bca681 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -1,3 +1,4 @@ +import { isLockedIssueStub } from "@/components/LockedIssueChip"; import { IssuePullRequestLinks } from "../IssuePullRequestLinks"; import { useIssueWorkProducts } from "../../hooks/useIssueWorkProducts"; import { getIssuePullRequests, pullRequestHref, pullRequestIdentity } from "../../lib/issue-pull-requests"; @@ -2109,16 +2110,17 @@ 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 parentAncestor = issue.ancestors?.find((ancestor) => ancestor.id === issue.parentId); + const parentIdentifier = parentAncestor?.identifier ?? currentParentIssue?.identifier; + const parentTitle = parentAncestor?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8); const parentTrigger = issue.parentId ? ( diff --git a/ui/src/components/issue-properties/relation-controls.tsx b/ui/src/components/issue-properties/relation-controls.tsx index 8dbc36f9a51..a5e1c6d6f2a 100644 --- a/ui/src/components/issue-properties/relation-controls.tsx +++ b/ui/src/components/issue-properties/relation-controls.tsx @@ -1,6 +1,7 @@ import { useState, type MouseEvent } from "react"; -import type { Issue } from "@paperclipai/shared"; +import type { Issue, IssueLockedStub } from "@paperclipai/shared"; import { Link } from "@/lib/router"; +import { LockedIssueChip, isLockedIssueStub } from "../LockedIssueChip"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -26,19 +27,31 @@ export function RemovableIssueReferencePill({ onRemove, isMobile = false, }: { - issue: NonNullable[number]; + issue: NonNullable[number] | IssueLockedStub; onRemove: (issueId: string) => void; isMobile?: boolean; }) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const issueLabel = issue.identifier ?? issue.title; - const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; + const locked = isLockedIssueStub(issue); + const issueLabel = locked + ? issue.identifier ?? "Private task" + : issue.identifier ?? issue.title; + const confirmLabel = locked + ? issue.identifier ?? "this private task" + : issue.identifier + ? `${issue.identifier}: ${issue.title}` + : issue.title; const chipClassName = cn( "paperclip-mention-chip paperclip-mention-chip--issue", "inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", - issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", + !locked && issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", ); - const content = ( + // A private blocker referenced from a task you *can* see: show the locked chip + // (never a title/link) but keep the detach affordance so you aren't stuck + // blocked by something invisible. + const content = locked ? ( + + ) : ( <> {issueLabel} @@ -66,14 +79,14 @@ export function RemovableIssueReferencePill({ type="button" data-mention-kind="issue" className={chipClassName} - title={issue.title} + title={locked ? "Private task" : issue.title} aria-label={`Actions for blocker ${issueLabel}`} > {content} - {issue.identifier ? ( + {!locked && issue.identifier ? ( @@ -98,7 +111,9 @@ export function RemovableIssueReferencePill({ > - {issue.identifier ? ( + {locked ? ( + content + ) : issue.identifier ? ( void; attachDisabled?: boolean; onGoal?: () => void; + privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void }; disabled?: boolean; mobile?: boolean; triggerTestId?: string; @@ -27,9 +30,10 @@ interface ComposerAddMenuProps { } export function ComposerAddMenu({ - mode, onModeChange, onAttachFile, attachDisabled, onGoal, disabled, mobile: mobileProp, triggerTestId, menuTestId, + mode, onModeChange, onAttachFile, attachDisabled, onGoal, privacy, disabled, mobile: mobileProp, triggerTestId, menuTestId, }: ComposerAddMenuProps) { const [open, setOpen] = useState(false); + const mobileViewportStyle = useMobileEntityPickerViewportStyle(); const goalFocusRef = useRef(false); const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_SHELL_QUERY).matches); useEffect(() => { @@ -40,9 +44,10 @@ export function ComposerAddMenu({ return () => query.removeEventListener("change", update); }, []); const mobile = mobileProp ?? narrow; - if (!onModeChange && !onAttachFile && !onGoal) return null; + if (!onModeChange && !onAttachFile && !onGoal && !privacy) return null; const actions: Array<{ id: string; label: string; detail?: string; Icon: LucideIcon; select: () => void; disabled?: boolean; selected?: boolean }> = [ ...(onAttachFile ? [{ id: "composer-add-file", label: "Files and images", Icon: Paperclip, select: onAttachFile, disabled: attachDisabled }] : []), + ...(privacy ? [{ id: "composer-add-private", label: "Private task", detail: privacy.inherited, Icon: Lock, select: () => privacy.onChange(!privacy.private), selected: privacy.private, disabled: Boolean(privacy.inherited) }] : []), ...(onGoal ? [{ id: "composer-add-goal", label: "Goal", detail: "Keep pursuing", Icon: Target, select: onGoal }] : []), ...(onModeChange ? [ { id: "composer-add-plan", label: "Plan mode", detail: "Plan before acting", Icon: ClipboardList, select: () => onModeChange(mode === "planning" ? "standard" : "planning"), selected: mode === "planning" }, @@ -61,14 +66,14 @@ export function ComposerAddMenu({ ; if (mobile) return {trigger} - { if (goalFocusRef.current) { event.preventDefault(); goalFocusRef.current = false; } }} - className="composer-mobile-dialog top-(--pct-50) -translate-y-(--pct-50) gap-0 overflow-y-auto p-0"> -
+ className="composer-mobile-dialog flex flex-col translate-y-0 gap-0 overflow-hidden p-0 md:translate-y-0"> +
Add - +
-
{actions.map((action) => ; } + +export function ComposerPrivacyChip({ inherited, onRemove, disabled }: { inherited?: string; onRemove: () => void; disabled?: boolean }) { + const [hintOpen, setHintOpen] = useState(false); + const explanation = inherited ?? "Only you and people you share with can read this task"; + return + + { event.preventDefault(); setHintOpen(true); } : undefined} + onKeyDown={inherited ? (event) => { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setHintOpen(true); } + } : undefined}> + + + + {explanation} + ; +} diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 19ed3daf866..7d8bc011698 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -64,7 +64,7 @@ import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeov import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; import { TaskChatComposerBar } from "./TaskChatComposerBar"; -import { ComposerAddMenu, ComposerModeChip } from "./ComposerAddMenu"; +import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu"; import type { ComposerRunSettings } from "./composer-run-settings"; import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; @@ -110,6 +110,7 @@ interface TaskChatComposerProps { details?: ReactNode; contextBar?: ReactNode; submitDisabled?: boolean; + privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void }; onSelectFiles: (files: File[]) => void; runSettings: ComposerRunSettings | null; onRunSettingsChange: (settings: ComposerRunSettings | null) => void; @@ -1460,6 +1461,7 @@ export function TaskChatComposer({ ) : null} fileInputRef.current?.click() : undefined} @@ -1471,6 +1473,7 @@ export function TaskChatComposer({ triggerTestId="task-chat-composer-add" menuTestId="task-chat-composer-add-menu" /> + {creation?.privacy?.private ? creation.privacy!.onChange(false)} disabled={disabled} /> : null} {queuedEdit ? ( {queuedEdit.stale diff --git a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx index 855fa0a13ec..5bd5442bd17 100644 --- a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx @@ -1,4 +1,5 @@ -import type { Issue, IssueStatus } from "@paperclipai/shared"; +import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; +import type { Issue, IssueStatus, IssueLockedStub } from "@paperclipai/shared"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { IssueRow } from "@/components/IssueRow"; @@ -22,7 +23,7 @@ export function RelationNavigationList({ ariaLabel, issueLinkState, }: { - items: TaskDetailRelationItem[]; + items: (TaskDetailRelationItem | IssueLockedStub)[]; emptyMessage: string; ariaLabel: string; issueLinkState?: unknown; @@ -34,6 +35,9 @@ export function RelationNavigationList({ return (
    {items.map((item) => { + if (isLockedIssueStub(item)) { + return
  • ; + } const pathId = item.identifier ?? item.id; return (
  • diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index 1e233452a52..8da9642cf0c 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -38,6 +38,14 @@ describe("TaskDetailTasksPanel", () => { expect(container.querySelector('[data-task-id="child"]')).not.toBeNull(); }); + it("renders locked ancestors as non-interactive chips alongside readable ancestors", () => { + render({ ancestors: [{ id: "private-parent", identifier: "PAP-99", locked: true }, ancestors[1]] as unknown as Issue["ancestors"], subtasks: [], createdTasks: [], projects: [] }); + const group = container.querySelector('section[aria-label="Ancestors"]')!; + expect(group.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toContain("PAP-99"); + expect([...group.querySelectorAll('a')].map(link => link.getAttribute("href"))).toEqual(["/issues/root"]); + expect(group.textContent).not.toContain("undefined"); + }); + it("keeps ancestor-only tasks navigable instead of showing an empty state", () => { render({ ancestors, subtasks: [], createdTasks: [], projects: [] }); expect(container.querySelectorAll('a')).toHaveLength(2); diff --git a/ui/src/lib/issuePrivacy.test.ts b/ui/src/lib/issuePrivacy.test.ts new file mode 100644 index 00000000000..b6618ab87d0 --- /dev/null +++ b/ui/src/lib/issuePrivacy.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { IssueAccessGrant } from "@paperclipai/shared"; +import { + agentVisibilityFromPermissions, + canManageIssuePrivacy, + canManageProjectPrivacy, + grantIsRevocable, + grantIsSharedAgent, + isSharedAgentVisibility, +} from "./issuePrivacy"; + +describe("agentVisibilityFromPermissions", () => { + it("reads the nested authorizationPolicy.agentVisibility.mode", () => { + expect( + agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "discoverable" } } }), + ).toBe("discoverable"); + expect( + agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "private" } } }), + ).toBe("private"); + }); + + it("returns null for malformed / missing shapes", () => { + expect(agentVisibilityFromPermissions(null)).toBeNull(); + expect(agentVisibilityFromPermissions({})).toBeNull(); + expect(agentVisibilityFromPermissions({ authorizationPolicy: {} })).toBeNull(); + expect( + agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "bogus" } } }), + ).toBeNull(); + }); +}); + +describe("isSharedAgentVisibility", () => { + it("treats anything not explicitly private as shared", () => { + expect(isSharedAgentVisibility("discoverable")).toBe(true); + expect(isSharedAgentVisibility("private")).toBe(false); + expect(isSharedAgentVisibility(null)).toBe(true); + }); +}); + +describe("grantIsSharedAgent", () => { + const base: IssueAccessGrant = { + id: "g1", + issueId: "i1", + subjectType: "agent", + subjectId: "a1", + source: "explicit", + grantedByUserId: null, + grantedByAgentId: null, + createdAt: new Date(), + revokedAt: null, + subjectDisplayName: "Helper", + subjectAvatarUrl: null, + subjectInitials: "H", + agentVisibility: "discoverable", + }; + + it("is true only for non-private agents", () => { + expect(grantIsSharedAgent(base)).toBe(true); + expect(grantIsSharedAgent({ ...base, agentVisibility: "private" })).toBe(false); + expect(grantIsSharedAgent({ ...base, subjectType: "user", agentVisibility: null })).toBe(false); + }); +}); + +describe("grantIsRevocable", () => { + it("allows explicit + assignment, never project", () => { + expect(grantIsRevocable("explicit")).toBe(true); + expect(grantIsRevocable("assignment")).toBe(true); + expect(grantIsRevocable("project")).toBe(false); + }); +}); + +describe("canManageIssuePrivacy", () => { + const issue = { companyId: "c1", responsibleUserId: "responsible", createdByUserId: "creator", assigneeUserId: "assignee" }; + const access = (role: string) => ({ source: "session", isInstanceAdmin: false, companyIds: ["c1"], + memberships: [{ companyId: "c1", status: "active", membershipRole: role }] }) as Parameters[2]; + it("allows task principals even with viewer membership", () => { + for (const user of ["responsible", "creator", "assignee"]) expect(canManageIssuePrivacy(issue, user, access("viewer"))).toBe(true); + }); + it("does not treat ordinary runtime management as privacy ownership", () => { + expect(canManageIssuePrivacy(issue, "other", access("member"))).toBe(false); + expect(canManageIssuePrivacy(issue, "other", access("admin"))).toBe(true); + expect(canManageIssuePrivacy(issue, "other", access("owner"))).toBe(true); + }); +}); + +describe("canManageProjectPrivacy", () => { + it("keeps project read membership separate from privacy management", () => { + const access = { source: "session", isInstanceAdmin: false, memberships: [{ companyId: "c1", status: "active", membershipRole: "member" }] } as Parameters[2]; + const project = { companyId: "c1", privacyOwnerUserId: "owner" }; + expect(canManageProjectPrivacy(project, "reader", access)).toBe(false); + expect(canManageProjectPrivacy(project, "owner", access)).toBe(true); + }); +}); diff --git a/ui/src/lib/issuePrivacy.ts b/ui/src/lib/issuePrivacy.ts new file mode 100644 index 00000000000..649b95c8fca --- /dev/null +++ b/ui/src/lib/issuePrivacy.ts @@ -0,0 +1,68 @@ +import type { CurrentBoardAccess } from "../api/access"; +import type { Issue, Project } from "@paperclipai/shared"; +import type { + IssueAccessGrant, + IssueAccessGrantAgentVisibility, + IssueAccessGrantSource, +} from "@paperclipai/shared"; + +/** + * Client mirror of the server's `issueGrantAgentVisibility` + * (server/src/routes/issues.ts) — reads an agent's visibility mode out of its + * loosely-typed `permissions.authorizationPolicy.agentVisibility.mode`. Used to + * preview the shared-agent caution *before* a grant exists (the enriched grant + * carries `agentVisibility`, but the add flow only has the candidate agent). + */ +export function agentVisibilityFromPermissions( + permissions: unknown, +): IssueAccessGrantAgentVisibility | null { + if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null; + const authorizationPolicy = (permissions as Record).authorizationPolicy; + if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) { + return null; + } + const agentVisibility = (authorizationPolicy as Record).agentVisibility; + if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null; + const mode = (agentVisibility as Record).mode; + return mode === "private" || mode === "discoverable" ? mode : null; +} + +/** + * Locked decision `trust-agent`: a *shared* agent is any agent not explicitly + * scoped to `private`. Sharing a private task with one means its memory & + * workspace may carry residual private context into later runs for other + * people, so the amber caution fires. Users and private/dedicated agents never + * trip it. + */ +export function isSharedAgentVisibility( + agentVisibility: IssueAccessGrantAgentVisibility | null, +): boolean { + return agentVisibility !== "private"; +} + +export function grantIsSharedAgent(grant: IssueAccessGrant): boolean { + return grant.subjectType === "agent" && isSharedAgentVisibility(grant.agentVisibility); +} + +/** Only explicit + assignment grants are revocable from the share sheet; project grants are managed on the project. */ +export function grantIsRevocable(source: IssueAccessGrantSource): boolean { + return source === "explicit" || source === "assignment"; +} + +/** Keep this UI gate aligned with assertCanManageIssuePrivacy on the server. */ +export function canManageIssuePrivacy( + issue: Pick | undefined, + userId: string | null, + access: CurrentBoardAccess | undefined, +): boolean { + if (!issue || !access) return false; + if (access.source === "local_implicit" || access.isInstanceAdmin) return true; + if (userId && [issue.responsibleUserId, issue.createdByUserId, issue.assigneeUserId].includes(userId)) return true; + return access.memberships?.some(member => member.companyId === issue.companyId + && member.status === "active" && ["owner", "admin"].includes(member.membershipRole ?? "")) ?? false; +} + +export function canManageProjectPrivacy(project: Pick, userId: string | null, access: CurrentBoardAccess | undefined): boolean { + return canManageIssuePrivacy({ companyId: project.companyId, responsibleUserId: project.privacyOwnerUserId ?? null, + createdByUserId: project.personalOwnerUserId ?? null, assigneeUserId: null }, userId, access); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 27a5a65a438..085e9ec20fc 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -374,10 +374,10 @@ export const queryKeys = { options.excludeRoot ? (["issues", "cost-summary", issueId, "exclude-root"] as const) : (["issues", "cost-summary", issueId] as const), - attachments: (issueId: string) => - ["issues", "attachments", issueId] as const, - attachmentPreview: (attachmentId: string) => - ["issues", "attachment-preview", attachmentId] as const, + privacyConstraints: (issueId: string) => ["issues", "privacy-constraints", issueId] as const, + accessGrants: (issueId: string) => ["issues", "access-grants", issueId] as const, + attachments: (issueId: string) => ["issues", "attachments", issueId] as const, + attachmentPreview: (attachmentId: string) => ["issues", "attachment-preview", attachmentId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, @@ -531,6 +531,7 @@ export const queryKeys = { { includeArchived: opts.includeArchived === true }, ] as const, detail: (id: string) => ["projects", "detail", id] as const, + accessMembers: (id: string) => ["projects", "access-members", id] as const, }, cases: { list: (companyId: string) => ["cases", companyId] as const, diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts index 4e87c29b20a..8ed4f83c7e5 100644 --- a/ui/src/lib/sentry.test.ts +++ b/ui/src/lib/sentry.test.ts @@ -337,12 +337,12 @@ describe("browser error diagnostics with the real SDK", () => { const gate = await importFreshSentry(); await gate.initBrowserErrorMonitoring(DSN, "staging"); const previousPath = window.location.pathname + window.location.search; - window.history.replaceState({}, "", "/tasks/private-task?token=private-capability"); + window.history.replaceState({}, "", "/tasks/sentry-fixture-secret-task-7fa83e?token=private-capability"); document.documentElement.classList.add("translated-ltr", "private-customer-class"); try { gate.captureBrowserException(new DOMException("insertBefore failed", "NotFoundError"), { boundary: "route", - componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-task?token=private-capability:3:4)", + componentStack: "\n at TaskDetail (https://tenant.example/tasks/sentry-fixture-secret-task-7fa83e?token=private-capability:3:4)", }); document.documentElement.classList.remove("translated-ltr"); gate.captureBrowserException(new Error("unrelated failure")); @@ -364,7 +364,7 @@ describe("browser error diagnostics with the real SDK", () => { expect(event).not.toHaveProperty("request"); expect(event.breadcrumbs).toBeUndefined(); } - for (const secret of ["tenant.example", "private-task", "private-capability", "private-customer-class"]) { + for (const secret of ["tenant.example", "sentry-fixture-secret-task-7fa83e", "private-capability", "private-customer-class"]) { expect(JSON.stringify(events)).not.toContain(secret); } await gate.teardownBrowserErrorMonitoring(); diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index e09b59d8d85..b129da8a233 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -172,6 +172,7 @@ import { PageSkeleton } from "@/components/PageSkeleton"; import { Identity } from "@/components/Identity"; import { AppLogo } from "@/pages/apps/AppLogo"; import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { LockedIssueChip } from "@/components/LockedIssueChip"; import { MembershipAction } from "@/components/MembershipAction"; import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection"; import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; @@ -894,6 +895,19 @@ export function DesignGuide() {
+ + +

+ Existence-only reference to a private task shown from a surface the viewer can see + (a blocker edge, a mention). Mono id + lock, dashed muted border,{" "} + no title, never a link. Falls back to "Private" when even the + identifier is withheld. +

+
+ + +
+
{/* ============================================================ */} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 31e4b70dc01..ee00bb2c464 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,3 +1,5 @@ +import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; +import { canManageIssuePrivacy } from "../lib/issuePrivacy"; import { TextAttachmentContext } from "../context/TextAttachmentContext"; import { useTaskBrowsers, useBrowserArrivals } from "@/hooks/useTaskBrowsers"; import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery"; @@ -338,6 +340,8 @@ import { SlidersHorizontal, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { IssuePrivacyActions } from "@/components/IssuePrivacyActions"; +import type { ShareSheetImplicitPrincipal } from "@/components/IssueShareSheet"; import { deriveOriginatingActor, isClosedIsolatedExecutionWorkspace, @@ -3584,6 +3588,38 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); + const canManagePrivacy = canManageIssuePrivacy(issue, currentUserId, boardAccess); + // Role-based principals for the share sheet's implicit rows (no revoke). + const privacyImplicitPrincipals = useMemo(() => { + if (!issue) return []; + const list: ShareSheetImplicitPrincipal[] = []; + const seen = new Set(); + const pushUser = (userId: string | null, roleLabel: string) => { + if (!userId || seen.has(`user:${userId}`)) return; + seen.add(`user:${userId}`); + const profile = userProfileMap.get(userId); + list.push({ + id: `user:${userId}`, + displayName: profile?.label ?? userId.slice(0, 5), + roleLabel, + avatarUrl: profile?.image ?? null, + }); + }; + const pushAgent = (agentId: string | null, roleLabel: string) => { + if (!agentId || seen.has(`agent:${agentId}`)) return; + seen.add(`agent:${agentId}`); + const agent = agentMap.get(agentId); + list.push({ + id: `agent:${agentId}`, + displayName: agent?.name ?? agentId.slice(0, 8), + roleLabel, + }); + }; + pushUser(issue.responsibleUserId, "Owner"); + pushAgent(issue.assigneeAgentId, "Assignee"); + pushUser(issue.assigneeUserId, "Assignee"); + return list; + }, [issue, userProfileMap, agentMap]); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, @@ -6869,7 +6905,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS return null; } const parent = ancestors.length > 0 ? ancestors[0] : null; - if (!parent) return null; + if (!parent || isLockedIssueStub(parent)) return null; const ref = parent.identifier ?? parent.id; return { identifier: parent.identifier ?? null, @@ -6991,7 +7027,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS {[...ancestors].reverse().map((ancestor, i) => ( {i > 0 && } - : @@ -7005,7 +7041,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS title={ancestor.title} > {ancestor.title} - + } ))} @@ -7299,6 +7335,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS "absolute right-0 top-0 flex h-7 items-center", )} > + setMoreOpen(false)} + implicitPrincipals={privacyImplicitPrincipals} +> + {(privacyMenuItems) => (
diff --git a/ui/src/pages/ProjectDetail.test.tsx b/ui/src/pages/ProjectDetail.test.tsx index 59cd9c74d48..4e987e6091f 100644 --- a/ui/src/pages/ProjectDetail.test.tsx +++ b/ui/src/pages/ProjectDetail.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { queryKeys } from "../lib/queryKeys"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import type { ReactNode } from "react"; @@ -83,7 +84,7 @@ vi.mock("@/plugins/slots", () => ({ })); vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null })); vi.mock("../components/ProjectProperties", () => ({ - ProjectProperties: () =>
, + ProjectProperties: ({ onFieldUpdate }: { onFieldUpdate: (field: string, data: Record) => void }) =>
, })); vi.mock("../components/BudgetPolicyCard", () => ({ BudgetPolicyCard: () =>
, @@ -114,6 +115,10 @@ vi.mock("../components/IssuesList", () => ({ }, })); +async function settle() { + for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } +} + async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { @@ -241,6 +246,22 @@ describe("ProjectDetail", () => { await act(() => queryClient.clear()); }); + it("refreshes cached task audiences when visibility is saved through the field autosave path", async () => { + mockLocation.pathname = "/projects/project-1/configuration"; + mockProjectsApi.update.mockResolvedValue(project({ visibility: "open" })); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const key = queryKeys.issues.accessGrants("cached-task"); + queryClient.setQueryData(key, [{ source: "project" }]); + root = createRoot(container); + await act(() => root!.render()); + await settle(); + await act(() => [...container.querySelectorAll("button")].find(button => button.textContent === "Open project through autosave")!.click()); + await settle(); + expect(mockProjectsApi.update).toHaveBeenCalledWith("project-1", { visibility: "open" }, "company-1"); + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + queryClient.clear(); + }); + it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 8c8943411d3..8ce3fe22b7a 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -449,7 +449,10 @@ export function ProjectDetail() { const updateProject = useMutation({ mutationFn: (data: Record) => projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId), - onSuccess: invalidateProject, + onSuccess: (_project, data) => { + invalidateProject(); + if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] }); + }, }); const archiveProject = useMutation({ @@ -580,6 +583,7 @@ export function ProjectDetail() { try { await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId); invalidateProject(); + if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] }); if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "saved"); scheduleFieldReset(field, 1800); @@ -589,7 +593,7 @@ export function ProjectDetail() { scheduleFieldReset(field, 3000); throw error; } - }, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]); + }, [invalidateProject, lookupCompanyId, projectLookupRef, queryClient, resolvedCompanyId, scheduleFieldReset, setFieldState]); const projectBudgetSummary = useMemo(() => { const matched = budgetOverview?.policies.find( diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 02006e0f28a..796efa180dd 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -23,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ArrowUpDown, Check, Hexagon, Plus } from "lucide-react"; +import { Lock } from "lucide-react"; import { Card } from "@/components/ui/card"; type ProjectSortField = "name" | "updated" | "created" | "targetDate"; @@ -221,6 +222,7 @@ export function Projects() { className={state === "left" ? "group text-foreground/55" : "group"} trailing={
+ {project.visibility === "private" ? : null} { + it.each([ + [{ title: "Open task" }, "open", null, null], + [{ title: "Private task", visibility: "private" }, "private", "privacy-created", null], + [{ title: "Private child", parentId: "privacy-root" }, "private", "privacy-root", "privacy-root"], + [{ title: "Private project task", projectId: "project-private" }, "private", "privacy-created", null], + ])("matches creation defaults for %j", async (data, visibility, privacyRootIssueId, privacyParentIssueId) => { + const restore = installPrivacyApi(createPrivacyState({})); + try { + const response = await fetch(`/api/companies/${privacyCompanyId}/issues`, { method: "POST", body: JSON.stringify(data) }); + const task = await response.json(); + expect(task).toMatchObject({ visibility, privacyRootIssueId, privacyParentIssueId }); + } finally { restore(); } + }); +}); + +describe("task privacy moves in the preview", () => { + it.each([ + [{ projectId: "project-private" }, "privacy-root", "privacy-root", null], + [{ parentId: "privacy-child" }, "privacy-sibling", "privacy-root", "privacy-child"], + ])("inherits privacy when moving an open task into %j", async (data, id, root, parent) => { + const restore = installPrivacyApi(createPrivacyState({ visibility: "open" })); + try { + const response = await fetch(`/api/issues/${id}`, { method: "PATCH", body: JSON.stringify(data) }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ visibility: "private", privacyRootIssueId: root, privacyParentIssueId: parent }); + } finally { restore(); } + }); + it("inherits a parent's private project even if the parent is open", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true }); + const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-sibling", { method: "PATCH", body: JSON.stringify({ parentId: "privacy-root" }) }); + expect(await response.json()).toMatchObject({ visibility: "private", privacyRootIssueId: "privacy-root", privacyParentIssueId: "privacy-root" }); + } finally { restore(); } + }); + it("rejects publishing under a private project without mutating its scope", async () => { + const state = createPrivacyState({ taskProject: true }); const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(response.status).toBe(422); + expect(state.tasks[0]).toMatchObject({ visibility: "private", projectId: "project-private" }); + } finally { restore(); } + }); + it("leaves a personal project when making its task public", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, personal: true })); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(await response.json()).toMatchObject({ visibility: "open", projectId: null, project: null }); + } finally { restore(); } + }); +}); + + +describe("preview descendants and effective access", () => { + it.each([ + { projectId: "project-private" }, + { parentId: "private-peer" }, + { visibility: "private" }, + ])("protects the whole task subtree for %j", async (data) => { + const state = createPrivacyState({ visibility: "open", grants: "empty" }); + for (const task of state.tasks) { + task.visibility = "open"; + task.privacyRootIssueId = null; + task.privacyParentIssueId = null; + } + // Include a provenance-only descendant, as well as ordinary child links. + state.tasks[2]!.parentId = null; + state.tasks[2]!.privacyParentIssueId = "privacy-child"; + state.tasks.push(privacyTask({ id: "private-peer", privacyRootIssueId: "private-peer" })); + state.tasks.push(privacyTask({ id: "unrelated", visibility: "open", privacyRootIssueId: null })); + const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify(data) }); + const root = await response.json(); + expect(response.status).toBe(200); + for (const id of ["privacy-child", "privacy-grandchild", "privacy-sibling"]) { + const child = await fetch(`/api/issues/${id}`).then(response => response.json()); + expect(child).toMatchObject({ visibility: "private", privacyRootIssueId: root.privacyRootIssueId }); + expect(child.privacyParentIssueId).not.toBeNull(); + } + expect(state.tasks.find(task => task.id === "unrelated")?.visibility).toBe("open"); + } finally { restore(); } + }); + it("drops former personal-project members after publishing and making the task private again", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, personal: true, grants: "empty" })); + try { + const grants = () => fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(await grants()).toContainEqual(expect.objectContaining({ subjectId: "user-product", source: "project" })); + const published = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }).then(response => response.json()); + expect(published).toMatchObject({ projectId: null, privacyRootIssueId: null }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + expect(await grants()).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("removes inherited project membership after detaching from its parent", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, grants: "empty" })); + try { + const grants = () => fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + expect(await grants()).toContainEqual(expect.objectContaining({ subjectId: "user-product", source: "project" })); + await fetch("/api/issues/privacy-child", { method: "PATCH", body: JSON.stringify({ parentId: null }) }); + expect(await grants()).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("shows a child share only on that child and its descendants", async () => { + const restore = installPrivacyApi(createPrivacyState({ grants: "empty" })); + try { + await fetch("/api/issues/privacy-child/access-grants", { method: "POST", body: JSON.stringify({ subjectType: "user", subjectId: "user-product" }) }); + for (const [id, expected] of [["privacy-root", false], ["privacy-child", true], ["privacy-grandchild", true], ["privacy-sibling", false]] as const) { + const grants = await fetch(`/api/issues/${id}/access-grants`).then(response => response.json()); + expect(grants.some((grant: { subjectId: string }) => grant.subjectId === "user-product")).toBe(expected); + if (id === "privacy-grandchild") expect(grants).toContainEqual(expect.objectContaining({ inherited: true, issueId: "privacy-child" })); + } + } finally { restore(); } + }); + it("protects descendants when a project becomes private and keeps tasks private after opening it", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true, grants: "empty" }); + state.projects[0]!.visibility = "open"; + for (const task of state.tasks) { task.visibility = "open"; task.privacyRootIssueId = null; } + const restore = installPrivacyApi(state); + try { + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + expect(state.tasks.every(task => task.visibility === "private" && task.privacyRootIssueId !== null)).toBe(true); + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(state.tasks.every(task => task.visibility === "private")).toBe(true); + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(grants).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("keeps existing private descendants private when their parent is published", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(state.tasks[0]).toMatchObject({ visibility: "open", privacyRootIssueId: null }); + expect(state.tasks.slice(1).every(task => task.visibility === "private")).toBe(true); + } finally { restore(); } + }); + it("does not revoke an ancestor grant through the child's endpoint", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-child/access-grants/grant-morgan/revoke", { method: "POST", body: "{}" }); + expect(response.status).toBe(404); + expect(state.grants.find(grant => grant.id === "grant-morgan")?.revokedAt).toBeNull(); + } finally { restore(); } + }); +}); + + +describe("preview owner and saved assignment access", () => { + it("keeps a child's own owner and assignment reasons after its parent becomes public", async () => { + const state = createPrivacyState(); + state.tasks[1]!.responsibleUserId = "user-product"; + state.tasks[1]!.createdByUserId = "user-finance"; + const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + const grants = await fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + for (const subjectId of ["user-product", "user-finance"]) expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId, source: "owner", inherited: false })); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId: "agent-dedicated", source: "assignment", inherited: false })); + expect(grants.every((grant: { issueId: string }) => grant.issueId === "privacy-child")).toBe(true); + } finally { restore(); } + }); + it("saves assignment access when a project becomes private and retains it after unassignment", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true }); + state.projects[0]!.visibility = "open"; + for (const task of state.tasks) { task.visibility = "open"; task.privacyRootIssueId = null; } + state.grants = []; + const restore = installPrivacyApi(state); + try { + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + await fetch("/api/issues/privacy-child", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: null }) }); + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + const grants = await fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId: "agent-dedicated", source: "assignment", inherited: false })); + expect(grants).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("does not add an assignment grant beside an existing independent direct grant", async () => { + const state = createPrivacyState({ grants: "empty" }); + state.grants.push(privacyGrant({ subjectType: "agent", subjectId: "agent-dedicated" })); + const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: "agent-dedicated" }) }); + expect(state.grants.filter(grant => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated")).toHaveLength(1); + } finally { restore(); } + }); + it.each([null, "agent-dedicated"])("uses the new task's actual assignment %s", async (assigneeAgentId) => { + const restore = installPrivacyApi(createPrivacyState({ grants: "empty" })); + try { + await fetch(`/api/companies/${privacyCompanyId}/issues`, { method: "POST", body: JSON.stringify({ title: "Created private task", visibility: "private", assigneeAgentId }) }); + const grants = await fetch("/api/issues/privacy-created/access-grants").then(response => response.json()); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-created", subjectId: "user-board", source: "owner", inherited: false })); + expect(grants.filter((grant: { source: string }) => grant.source === "assignment")).toHaveLength(assigneeAgentId ? 1 : 0); + } finally { restore(); } + }); +}); + + +describe("revoked assignment access in previews", () => { + it("does not restore a revoked assignment grant after unrelated edits", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root/access-grants/grant-assignment/revoke", { method: "POST", body: "{}" }); + for (const data of [{ title: "Edited after revocation" }, { status: "done" }]) { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify(data) }); + } + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(grants.filter((grant: { issueId: string; subjectId: string; revokedAt: string | null }) => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated" && grant.revokedAt === null)).toHaveLength(0); + expect(state.tasks[0]!.assigneeAgentId).toBe("agent-dedicated"); + } finally { restore(); } + }); + it("saves a fresh assignment grant after an actual reassignment", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root/access-grants/grant-assignment/revoke", { method: "POST", body: "{}" }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: null }) }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: "agent-dedicated" }) }); + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + const active = grants.filter((grant: { issueId: string; subjectId: string; revokedAt: string | null }) => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated" && grant.revokedAt === null); + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ source: "assignment", inherited: false }); + expect(active[0].id).not.toBe("grant-assignment"); + } finally { restore(); } + }); +}); diff --git a/ui/storybook/fixtures/privateTasks.ts b/ui/storybook/fixtures/privateTasks.ts new file mode 100644 index 00000000000..4d330a0ebe5 --- /dev/null +++ b/ui/storybook/fixtures/privateTasks.ts @@ -0,0 +1,780 @@ +import type { + Agent, + Issue, + IssueAccessGrant, + Project, + ProjectAccessMember, +} from "@paperclipai/shared"; +import type { CurrentBoardAccess } from "@/api/access"; +import { + storybookAgents, + storybookAuthSession, + storybookCompanies, + storybookIssues, + storybookProjects, + storybookSidebarBadges, + storybookDashboardSummary, +} from "./paperclipData"; + +export const privacyCompanyId = "company-storybook"; +export const privacyOwnerId = "user-board"; +export const privacyReaderId = "user-product"; +const createdAt = new Date("2026-09-30T09:00:00Z"); + +export type PrivacyScenario = { + role?: "owner" | "reader" | "admin"; + visibility?: "open" | "private"; + grants?: "all" | "empty" | "assignment" | "inherited" | "long"; + failure?: + | "parent" + | "grants" + | "directory" + | "add" + | "revoke" + | "visibility" + | "members" + | "create" + | "project-add" + | "project-remove"; + loading?: "parent" | "grants" | "members" | "mention"; + retryOnce?: boolean; + childOnly?: boolean; + personal?: boolean; + draft?: boolean; + classic?: boolean; + taskProject?: boolean; +}; + +export const privacyUsers = [ + { + id: privacyOwnerId, + name: "Avery Chen", + email: "avery@example.test", + image: null, + }, + { + id: privacyReaderId, + name: "Morgan Reed", + email: "morgan@example.test", + image: null, + }, + { + id: "user-finance", + name: "Sam Rivera", + email: "sam@example.test", + image: null, + }, + { + id: "user-admin", + name: "Jordan Ellis", + email: "jordan@example.test", + image: null, + }, +]; + +export const privacyAgents: Agent[] = [ + { + ...storybookAgents[0]!, + id: "agent-dedicated", + name: "Executive assistant", + urlKey: "executive-assistant", + status: "idle", + permissions: { + canCreateAgents: false, + authorizationPolicy: { agentVisibility: { mode: "private" } }, + }, + }, + { + ...storybookAgents[1]!, + id: "agent-shared", + name: "Research team agent", + urlKey: "research-team-agent", + status: "idle", + permissions: { + canCreateAgents: false, + authorizationPolicy: { agentVisibility: { mode: "discoverable" } }, + }, + }, + { + ...storybookAgents[0]!, + id: "agent-unspecified", + name: "Legacy shared agent", + urlKey: "legacy-shared-agent", + status: "idle", + permissions: { canCreateAgents: false }, + }, +]; + +export function privacyProject(overrides: Partial = {}): Project { + return { + ...storybookProjects[0]!, + id: "project-private", + name: "Executive planning", + urlKey: "executive-planning", + description: "Plans shared with the executive team.", + visibility: "private", + privacyOwnerUserId: privacyOwnerId, + personalOwnerUserId: null, + workspaces: [], + primaryWorkspace: null, + ...overrides, + }; +} + +export function privacyTask(overrides: Partial = {}): Issue { + return { + ...storybookIssues[0]!, + id: "privacy-root", + identifier: "PAP-410", + title: "Prepare my board briefing", + description: + "Draft a briefing from my personal inbox. Share only the research subtask with Morgan.", + visibility: "private", + privacyRootIssueId: "privacy-root", + privacyParentIssueId: null, + companyId: privacyCompanyId, + responsibleUserId: privacyOwnerId, + createdByUserId: privacyOwnerId, + createdByAgentId: null, + assigneeAgentId: "agent-dedicated", + assigneeUserId: null, + parentId: null, + projectId: null, + project: null, + status: "todo", + checkoutRunId: null, + executionRunId: null, + executionWorkspaceId: null, + currentExecutionWorkspace: null, + projectWorkspaceId: null, + startedAt: null, + executionLockedAt: null, + executionAgentNameKey: null, + executionWorkspaceSettings: null, + executionState: null, + executionPolicy: null, + blockedBy: [], + blocks: [], + ancestors: [], + labels: [], + labelIds: [], + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +export function privacyGrant( + overrides: Partial = {}, +): IssueAccessGrant { + return { + id: "grant-morgan", + issueId: "privacy-root", + subjectType: "user", + subjectId: privacyReaderId, + source: "explicit", + inherited: false, + grantedByUserId: privacyOwnerId, + grantedByAgentId: null, + createdAt, + revokedAt: null, + subjectDisplayName: "Morgan Reed", + subjectAvatarUrl: null, + subjectInitials: "MR", + agentVisibility: null, + ...overrides, + }; +} + +function privacySubjectMetadata(subjectType: IssueAccessGrant["subjectType"], subjectId: string): Pick { + const subject = (subjectType === "user" ? privacyUsers : privacyAgents).find(row => row.id === subjectId); + const name = subject?.name ?? "Unknown"; + return { + subjectDisplayName: name, + subjectInitials: name.split(" ").map(part => part[0]).join("").slice(0, 2), + agentVisibility: subjectType === "agent" ? subjectId === "agent-dedicated" ? "private" + : subjectId === "agent-shared" ? "discoverable" : null : null, + }; +} +function savePrivacyAssignments(tasks: Issue[], grants: IssueAccessGrant[]) { + for (const task of tasks) { + if (task.visibility !== "private") continue; + for (const [subjectType, subjectId] of [["agent", task.assigneeAgentId], ["user", task.assigneeUserId]] as const) { + if (!subjectId || grants.some(grant => grant.issueId === task.id && grant.subjectType === subjectType + && grant.subjectId === subjectId && grant.revokedAt === null && grant.source !== "owner" && grant.source !== "project")) continue; + grants.push(privacyGrant({ + ...privacySubjectMetadata(subjectType, subjectId), + id: `assignment-${task.id}-${subjectType}-${subjectId}-${grants.length}`, + issueId: task.id, subjectType, subjectId, source: "assignment", + })); + } + } +} +export function createPrivacyState(options: PrivacyScenario = {}) { + const role = options.role ?? "owner"; + const user = privacyUsers.find( + (item) => + item.id === + (role === "reader" + ? privacyReaderId + : role === "admin" + ? "user-admin" + : privacyOwnerId), + )!; + const project = privacyProject( + options.personal + ? { name: "My private tasks", personalOwnerUserId: privacyOwnerId } + : {}, + ); + const projects = [ + project, + privacyProject({ + id: "project-open", + name: "Company operations", + urlKey: "company-operations", + visibility: "open", + }), + ]; + const tasks = [ + privacyTask({ visibility: options.visibility ?? "private", ...(options.taskProject ? { projectId: project.id, project } : {}) }), + privacyTask({ + id: "privacy-child", + identifier: "PAP-411", + title: "Research market benchmarks", + description: + "Research publicly available market benchmarks and summarize the sources.", + parentId: "privacy-root", + privacyParentIssueId: "privacy-root", + }), + privacyTask({ + id: "privacy-grandchild", + identifier: "PAP-412", + title: "Summarize public market reports", + parentId: "privacy-child", + privacyParentIssueId: "privacy-child", + }), + privacyTask({ + id: "privacy-sibling", + identifier: "PAP-413", + title: "Review compensation notes", + parentId: "privacy-root", + privacyParentIssueId: "privacy-root", + }), + ]; + if (options.childOnly) + tasks[1]!.ancestors = [ + { id: "privacy-root", identifier: "PAP-410", locked: true }, + ] as unknown as Issue["ancestors"]; // Redacted wire shape; never invent a private title for the legacy ancestor type. + let grants = [ + privacyGrant(), + privacyGrant({ + id: "grant-assignment", + subjectType: "agent", + subjectId: "agent-dedicated", + subjectDisplayName: "Executive assistant", + subjectInitials: "EA", + source: "assignment", + agentVisibility: "private", + }), + ]; + if (options.grants === "empty") grants = []; + if (options.grants === "assignment") + grants = grants.filter((item) => item.source === "assignment"); + if (options.grants === "inherited") + grants = grants + .filter((item) => item.source !== "owner") + .map((item) => ({ ...item, inherited: true, issueId: "privacy-root" })); + if (options.grants === "long") + grants = [ + privacyGrant({ + subjectDisplayName: + "Morgan Reed — International market strategy and research", + subjectInitials: "MR", + }), + ]; + if (options.grants === "empty") { + for (const task of tasks) { task.assigneeAgentId = null; task.assigneeUserId = null; } + } + savePrivacyAssignments(tasks, grants); + const members: ProjectAccessMember[] = privacyUsers + .slice(0, 2) + .map((item) => ({ + id: "member-" + item.id, + projectId: project.id, + companyId: privacyCompanyId, + subjectType: "user", + subjectId: item.id, + subjectDisplayName: item.name, + subjectAvatarUrl: null, + createdAt, + })); + const access: CurrentBoardAccess = { + user, + userId: user.id, + isInstanceAdmin: false, + companyIds: [privacyCompanyId], + memberships: [ + { + companyId: privacyCompanyId, + membershipRole: role === "admin" ? "admin" : "member", + status: "active", + }, + ], + source: "session", + keyId: null, + }; + return { + options, + user, + access, + session: { + ...storybookAuthSession, + user, + session: { ...storybookAuthSession.session!, userId: user.id }, + }, + projects, + tasks, + grants, + members, + operations: [] as string[], + }; +} +export type PrivacyState = ReturnType; + +/** Deterministic UX fixtures, not a second authorization implementation. All writes stay in this story. */ +export function installPrivacyApi(state: PrivacyState) { + const previous = window.fetch; + const failed = new Set(); + const oldDraft = localStorage.getItem("paperclip:issue-draft"); + localStorage.removeItem("paperclip:issue-draft"); + if (state.options.draft) + localStorage.setItem( + "paperclip:issue-draft", + JSON.stringify({ + title: "Continue my private briefing", + description: "Saved personal draft.", + isPrivate: true, + status: "todo", + priority: "medium", + companyId: privacyCompanyId, + }), + ); + const fail = (key: PrivacyScenario["failure"]) => { + if ( + state.options.failure !== key || + (state.options.retryOnce && failed.has(key!)) + ) + return null; + failed.add(key!); + return Response.json( + { error: "The request could not be completed. Try again." }, + { status: 503 }, + ); + }; + const pause = (signal?: AbortSignal | null) => + new Promise((_resolve, reject) => { + if (signal?.aborted) reject(new DOMException("Aborted", "AbortError")); + signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + const privateTaskScope = (task: Issue) => task.visibility === "private" + || state.projects.some(project => project.id === task.projectId && project.visibility === "private"); + const privacyAncestry = (task: Issue) => { + const ancestry = [task]; + const seen = new Set([task.id]); + let parent = state.tasks.find(row => row.id === task.privacyParentIssueId); + while (parent && !seen.has(parent.id) && privateTaskScope(parent)) { + ancestry.push(parent); + seen.add(parent.id); + parent = state.tasks.find(row => row.id === parent!.privacyParentIssueId); + } + return ancestry; + }; + const privacySubtree = (roots: string[]) => { + const seen = new Set(roots); + const pending = [...roots]; + while (pending.length) { + const parentId = pending.shift()!; + for (const task of state.tasks) { + if (!seen.has(task.id) && (task.parentId === parentId || task.privacyParentIssueId === parentId)) { + seen.add(task.id); + pending.push(task.id); + } + } + } + return state.tasks.filter(task => seen.has(task.id)); + }; + window.fetch = async (input, init) => { + const request = input instanceof Request ? input : null; + const url = new URL( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + location.origin, + ); + const path = url.pathname; + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + const data = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + const json = (value: unknown) => Response.json(value); + if (!path.startsWith("/api/")) return previous(input, init); + if (path.includes("/email/tasks/")) return json({ messages: [], publications: [] }); + if (path.endsWith("/budgets/overview")) return json({ policies: [] }); + if (path === "/api/auth/get-session") return json(state.session); + if (path === "/api/cli-auth/me") return json(state.access); + if (path === "/api/companies") return json(storybookCompanies); + if (path === "/api/health") + return json({ + status: "ok", + deploymentMode: "authenticated", + authReady: true, + bootstrapStatus: "ready", + }); + if (path.endsWith("/user-directory") || path.endsWith("/members")) + return ( + fail("directory") ?? + json({ + users: privacyUsers.map((user) => ({ + principalId: user.id, + status: "active", + user, + })), + agents: [], + }) + ); + if (path.endsWith("/resource-memberships/me")) + return json({ + projectMemberships: {}, + agentMemberships: {}, + starredProjectIds: ["project-private"], + starredAgentIds: [], + starredDocumentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, + documentStarredAt: {}, + updatedAt: null, + }); + if (path === "/api/companies/" + privacyCompanyId + "/agents") + return json(privacyAgents); + if (path === "/api/companies/" + privacyCompanyId + "/projects") + return json(state.projects); + if (path === "/api/companies/" + privacyCompanyId + "/issues") { + if (method === "POST") { + const error = fail("create"); + if (error) return error; + const parent = state.tasks.find(task => task.id === data.parentId); + const project = state.projects.find(project => project.id === data.projectId); + const privateParent = parent && (parent.visibility === "private" + || state.projects.some(project => project.id === parent.projectId && project.visibility === "private")); + const visibility = privateParent || project?.visibility === "private" ? "private" : data.visibility ?? "open"; + const issue = privacyTask({ + ...data, + visibility, + privacyParentIssueId: parent?.id ?? null, + privacyRootIssueId: visibility === "private" + ? privateParent ? parent.privacyRootIssueId ?? parent.id : "privacy-created" + : null, + id: "privacy-created", + identifier: "PAP-414", + assigneeAgentId: data.assigneeAgentId ?? null, + assigneeUserId: data.assigneeUserId ?? null, + project: project ?? null, + responsibleUserId: privacyOwnerId, + }); + state.tasks.push(issue); + savePrivacyAssignments([issue], state.grants); + state.operations.push( + "Created " + issue.identifier + " · " + issue.visibility, + ); + return json(issue); + } + let rows = state.options.childOnly + ? state.tasks.filter((item) => + ["privacy-child", "privacy-grandchild"].includes(item.id), + ) + : state.tasks; + const parent = + url.searchParams.get("parentId") ?? + url.searchParams.get("descendantOf"); + if (parent) rows = rows.filter((item) => item.parentId === parent); + const query = url.searchParams.get("q")?.toLowerCase(); + return json( + query + ? rows.filter((item) => + (item.title + item.identifier).toLowerCase().includes(query), + ) + : rows, + ); + } + const issueMatch = path.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/); + if (issueMatch) { + const item = state.tasks.find( + (row) => row.id === issueMatch[1] || row.identifier === issueMatch[1], + ); + const resource = issueMatch[2]; + if (issueMatch[1] === "PAP-499") + return state.options.loading === "mention" + ? pause(init?.signal) + : json( + privacyTask({ + identifier: "PAP-499", + title: "Readable task", + status: "in_progress", + }), + ); + if ( + !item || + (state.options.childOnly && + ["privacy-root", "privacy-sibling"].includes(item.id)) + ) + return jsonError404(); + if (!resource) { + if (method === "GET" && issueMatch[1] === "privacy-root") { + if (state.options.loading === "parent") return pause(init?.signal); + const error = fail("parent"); + if (error) return error; + } + if (method === "PATCH") { + const error = fail("visibility"); + if (error) return error; + const next = { ...item, ...data }; + const changesPrivacy = data.visibility !== undefined || data.parentId !== undefined || data.projectId !== undefined; + if (data.visibility === "open") next.privacyRootIssueId = null; + if (changesPrivacy) { + const project = state.projects.find(project => project.id === next.projectId); + if (project?.visibility === "private") { + if (data.visibility === "open") { + if (!project.personalOwnerUserId) return Response.json({ error: "Move this task out of its private project before making it public" }, { status: 422 }); + next.projectId = null; + next.project = null; + } else { + next.visibility = "private"; + next.privacyRootIssueId = item.privacyRootIssueId ?? item.id; + } + } + const parentId = data.parentId !== undefined ? data.parentId : item.privacyParentIssueId; + const parent = state.tasks.find(task => task.id === parentId); + const parentProject = state.projects.find(project => project.id === parent?.projectId); + next.privacyParentIssueId = parent?.id ?? null; + if (parent && (parent.visibility === "private" || parentProject?.visibility === "private")) { + if (data.visibility === "open") return Response.json({ error: "A task cannot be made public while it inherits private access" }, { status: 422 }); + next.visibility = "private"; + next.privacyRootIssueId = parent.privacyRootIssueId ?? parent.id; + } else if (data.visibility === "private") next.privacyRootIssueId = item.id; + if (data.parentId !== undefined) next.ancestors = parent ? [{ ...parent, + project: parent.project ? { ...parent.project, workspaces: parent.project.workspaces ?? [], primaryWorkspace: parent.project.primaryWorkspace ?? null } : null, + goal: parent.goal ?? null, + }] : []; + } + if (data.projectId !== undefined && next.projectId) next.project = state.projects.find(project => project.id === next.projectId) ?? null; + if (!next.projectId) next.project = null; + const assignmentChanged = item.visibility !== "private" || item.assigneeAgentId !== next.assigneeAgentId || item.assigneeUserId !== next.assigneeUserId; + Object.assign(item, next); + if (changesPrivacy && item.visibility === "private") { + for (const descendant of privacySubtree([item.id])) { + if (descendant.id === item.id) continue; + descendant.visibility = "private"; + descendant.privacyRootIssueId = item.privacyRootIssueId ?? item.id; + descendant.privacyParentIssueId ??= descendant.parentId; + } + savePrivacyAssignments(privacySubtree([item.id]).filter(task => task.id !== item.id), state.grants); + } + if (assignmentChanged) savePrivacyAssignments([item], state.grants); + state.operations.push("Task audience: " + item.visibility); + } + return json(item); + } + if (resource === "privacy-constraints") { + if (state.options.loading === "parent") return pause(init?.signal); + const error = fail("parent"); if (error) return error; + const project = state.projects.find(project => project.id === item.projectId); + const parent = state.tasks.find(task => task.id === item.privacyParentIssueId); + const parentProject = state.projects.find(project => project.id === parent?.projectId); + return json({ + publicBlockedBy: project?.visibility === "private" && !project.personalOwnerUserId ? "project" + : parent?.visibility === "private" || parentProject?.visibility === "private" ? "parent" : null, + leavesPersonalProject: project?.visibility === "private" && Boolean(project.personalOwnerUserId), + }); + } + if (resource === "access-grants") { + if (method === "GET") { + if (state.options.loading === "grants") return pause(init?.signal); + const error = fail("grants"); if (error) return error; + const ancestry = privacyAncestry(item); + const taskIds = new Set(ancestry.map(task => task.id)); + const projectIds = new Set(ancestry.flatMap(task => state.projects + .filter(project => project.id === task.projectId && project.visibility === "private").map(project => project.id))); + return json([ + ...state.grants.filter(grant => grant.source !== "project" && grant.source !== "owner" && taskIds.has(grant.issueId)) + .map(grant => ({ ...grant, inherited: grant.issueId !== item.id })), + ...ancestry.flatMap(task => Array.from(new Set([task.responsibleUserId, task.createdByUserId] + .filter((id): id is string => Boolean(id)))).map(subjectId => { + return privacyGrant({ + ...privacySubjectMetadata("user", subjectId), + id: `owner:${task.id}:${subjectId}`, issueId: task.id, subjectId, source: "owner", + inherited: task.id !== item.id, + }); + })), + ...state.members.filter(member => projectIds.has(member.projectId)) + .map(member => privacyGrant({ + ...privacySubjectMetadata(member.subjectType, member.subjectId), + id: "project-" + member.id, issueId: item.id, subjectType: member.subjectType, + subjectId: member.subjectId, subjectDisplayName: member.subjectDisplayName, + source: "project", inherited: true, + })), + ]); + } + const error = fail("add"); + if (error) return error; + const subject = [...privacyUsers, ...privacyAgents].find( + (row) => row.id === data.subjectId, + ); + const grant = privacyGrant({ + ...data, + ...privacySubjectMetadata(data.subjectType, data.subjectId), + id: "grant-added-" + data.subjectId, + issueId: item.id, + subjectDisplayName: subject?.name ?? "Unknown", + }); + state.grants.push(grant); + state.operations.push( + "Shared " + item.identifier + " with " + subject?.name, + ); + return json(grant); + } + if (resource.match(/^access-grants\/.+\/revoke$/)) { + const error = fail("revoke"); + if (error) return error; + const grant = state.grants.find( + (row) => row.id === resource.split("/")[1] && row.issueId === item.id && row.revokedAt === null, + ); + if (!grant) return jsonError404(); + grant.revokedAt = new Date(); + state.operations.push("Removed a saved task grant"); + return json(grant); + } + if ( + ["active-run", "runner-goal", "execution-workspace"].includes(resource) + ) + return json(null); + if (resource === "queued-comments") + return json({ queues: [], items: [], revision: "privacy-story" }); + if (resource === "cost-summary") + return json({ + costCents: 0, + inputTokens: 0, + outputTokens: 0, + runCount: 0, + }); + if (resource.startsWith("documents/")) return jsonError404(); + if (resource === "comments") + return json([ + { + id: "privacy-comment", + issueId: item.id, + companyId: privacyCompanyId, + authorUserId: privacyOwnerId, + authorAgentId: null, + body: "Use the research in [PAP-411](/PAP/issues/PAP-411). Keep the briefing and compensation notes private.", + createdAt, + updatedAt: createdAt, + }, + ]); + return json([]); + } + const projectMatch = path.match(/^\/api\/projects\/([^/]+)(?:\/(.*))?$/); + if (projectMatch) { + const project = state.projects.find( + (row) => row.id === projectMatch[1] || row.urlKey === projectMatch[1], + ); + if (!project) return jsonError404(); + if (!projectMatch[2]) { + if (method === "PATCH") { + Object.assign(project, data); + if (data.visibility === "private") { + for (const task of privacySubtree(state.tasks.filter(task => task.projectId === project.id).map(task => task.id))) { + task.visibility = "private"; + task.privacyRootIssueId ??= task.id; + task.privacyParentIssueId ??= task.parentId; + } + savePrivacyAssignments(privacySubtree(state.tasks.filter(task => task.projectId === project.id).map(task => task.id)), state.grants); + } + } + return json(project); + } + if (projectMatch[2] === "access-members") { + if (method === "GET") + return state.options.loading === "members" + ? pause(init?.signal) + : (fail("members") ?? json(state.members.filter(member => member.projectId === project.id))); + const error = fail("project-add"); + if (error) return error; + const subject = [...privacyUsers, ...privacyAgents].find( + (row) => row.id === data.subjectId, + ); + const member: ProjectAccessMember = { + id: "member-added-" + data.subjectId, + companyId: privacyCompanyId, + projectId: project.id, + createdAt, + subjectAvatarUrl: null, + subjectDisplayName: subject?.name ?? "Unknown", + ...data, + }; + state.members.push(member); + return json(member); + } + if (projectMatch[2].startsWith("access-members/")) { + const error = fail("project-remove"); + if (error) return error; + const id = projectMatch[2].split("/")[1]; + state.members = state.members.filter((row) => row.id !== id || row.projectId !== project.id); + return json({ id }); + } + return json([]); + } + if (path.startsWith("/api/agents/")) + return json( + path.split("/").length > 4 + ? [] + : (privacyAgents.find((agent) => path.split("/")[3] === agent.id) ?? + privacyAgents[0]), + ); + if (path === "/api/instance/settings/experimental") + return json({ + enableClassicTaskInterface: state.options.classic === true, + enableIsolatedWorkspaces: false, + enableManagedSandboxOnly: false, + }); + if (path.includes("/adapters") || path === "/api/adapters") + return previous(input, init); + if (path.endsWith("/dashboard")) return json(storybookDashboardSummary); + if (path.endsWith("/sidebar-badges")) return json(storybookSidebarBadges); + if (path.endsWith("/budgets/overview")) + return json({ + companyId: privacyCompanyId, + policies: [], + activeIncidents: [], + pausedAgentCount: 0, + pausedProjectCount: 0, + pendingApprovalCount: 0, + }); + if (path.endsWith("/budget-summary")) return json({}); + if (path.includes("/settings")) return json({}); + // These stories never contact a real company, connector, or backend. + return json([]); + }; + return () => { + window.fetch = previous; + if (oldDraft === null) localStorage.removeItem("paperclip:issue-draft"); + else localStorage.setItem("paperclip:issue-draft", oldDraft); + }; +} + +function jsonError404() { + return Response.json({ error: "Not found" }, { status: 404 }); +} diff --git a/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx b/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx new file mode 100644 index 00000000000..2853525d174 --- /dev/null +++ b/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx @@ -0,0 +1,272 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const surfaces = [ + ["ComposerAddMenu · ComposerPrivacyChip · TaskChatComposer", "Changed", "01 Creation", "mobile-plus-menu", "Private task under +; a mobile menu bounded by the visible viewport; selected lock chip and removal; inherited parent/project explanations on hover, focus, and tap; small-phone, short-viewport, and landscape layouts."], + ["Identity · AccessSelectIdentity", "Added", "02 Sharing", "search-people-and-agents", "Shared rendering of human pictures or initials and agent character avatars in task and project sharing results and selected values."], + ["SidebarProjects · SidebarStarredProjects · production variant · Projects", "Changed", "06 Full product pages", "private-projects-in-navigation", "Right-side locks in actual navigation and project rows, current and classic shells, mobile."], + + [ + "NewIssueDialog", + "Changed", + "01 Creation", + "open-by-default", + "Open and private creation; unresolved parent access; lookup failure and retry; inherited child; private/personal project; restored draft; submit failure; mobile/light.", + ], + [ + "IssueShareSheet", + "Added", + "02 Sharing", + "all-access-sources", + "All four grant sources; implicit roles; read-only; inherited and independent direct child grants; retained assignment grants; add/revoke; confirmations; agent cautions; loading/error/retry; long names; mobile/light.", + ], + [ + "IssuePrivacyActions", + "Added", + "03 Task actions", + "private-owner", + "Private/open owner; inherited parent/project restrictions; personal-project escape; access retry; reader/admin; public confirmation and failed save; mobile.", + ], + [ + "LockedIssueChip", + "Added", + "04 References and blockers", + "locked-chip-variants", + "Identifier, missing identifier, neutral unavailable state, and no link or quicklook.", + ], + [ + "IssueReferencePill", + "Changed", + "04 References and blockers", + "reference-pill-variants", + "Readable and locked references, mention and property variants, light theme.", + ], + [ + "MarkdownBody", + "Changed", + "04 References and blockers", + "markdown-mentions", + "Readable and unavailable links/bare mentions; pending lookup without premature navigation.", + ], + [ + "IssueBlockedNotice", + "Changed", + "04 References and blockers", + "mixed-blockers", + "Private blocker alone and mixed with readable blockers.", + ], + [ + "RemovableIssueReferencePill (relation-controls)", + "Changed", + "04 References and blockers", + "remove-blocker-confirmation", + "Desktop detach; mobile action menu without Visit task; both confirmation layouts; successful removal.", + ], + [ + "ProjectProperties", + "Changed", + "05 Projects", + "private-project-settings", + "Open/private/personal project; audience copy; access-loss confirmation; owner/reader/admin gates; full settings context; light/mobile.", + ], + [ + "ProjectAccessMembers", + "Added", + "05 Projects", + "member-list", + "Owner protection; reader; shared-agent caution; loading; directory/member/add/remove failures; successful add; mobile.", + ], + [ + "IssueDetail", + "Changed", + "06 Full product pages", + "owner-sharing-from-menu", + "Real page and task menu; sharing survives menu dismissal; owner/reader/admin; shared child with locked ancestor; mobile/light.", + ], + [ + "IssueProperties", + "Changed", + "06 Full product pages", + "shared-child-reader", + "Private parent remains a locked, non-interactive chip in task properties.", + ], + [ + "RelationNavigationList (TaskDetailRelationsPanel)", + "Changed", + "04 References and blockers", + "ancestor-navigation", + "Mixed readable and locked ancestors; also shown in the full task's Tasks tab.", + ], + [ + "DesignGuide", + "Changed", + "06 Full product pages", + "design-guide-locked-references", + "Actual design-guide page, scrolled to the new locked-reference section.", + ], +]; +function storyLink(group: string, story: string) { + return `./?path=/story/private-tasks-${group.toLowerCase().replaceAll(" ", "-")}--${story}`; +} +const journeys = [ + ["Owner · Keep child access independent", "owner-keeps-child-access-independent", "Add a direct child grant before removing an inherited source of access."], + ["Project owner · Remove a member", "owner-removes-project-only-task-access", "Remove project-only task access and inspect the refreshed task audience."], + ["Project owner · Open the project", "owner-opens-project-with-private-tasks", "Review the loss of project-only access before opening the project."], + ["Owner · Move out of a private parent", "owner-moves-child-then-reviews-publishing", "Use the actual task properties picker, then review publishing without reloading."], + ["Owner · Move out of a private project", "owner-moves-task-then-reviews-publishing", "Remove a private project through the actual task page and see publishing become available."], + [ + "CEO · Create a private task", + "ceo-creates-private-task", + "Choose Private task before saving a personal briefing.", + ], + ["CEO · Create a private task on mobile", "mobile-ceo-creates-private-task", "Choose privacy from the mobile Add menu, review the lock chip, and create the briefing."], + [ + "CEO · Share only a child", + "ceo-shares-only-child", + "Invite Morgan to research; the parent briefing and sibling compensation task remain outside the grant.", + ], + [ + "Owner · Revoke a grant", + "owner-revokes-an-explicit-grant", + "Review the warning, revoke Morgan's saved grant, and see the list update.", + ], + [ + "Owner · Assignment remains", + "current-assignment-still-grants-access", + "Remove a saved agent grant while the current assignment still supplies access.", + ], + [ + "Owner · Make public", + "owner-makes-task-public", + "Confirm disclosure; private children retain their restrictions.", + ], + [ + "Reader · Detach a blocker", + "reader-detaches-private-blocker", + "Remove a visible relationship without learning private content.", + ], + [ + "Project owner · Add a member", + "owner-adds-project-member", + "Find Sam in the company directory and grant project access.", + ], +]; +function Overview() { + return ( +
+
+
+

+ Private tasks · Review guide +

+

+ Every changed surface, in context +

+

+ This review covers all changed components and pages changed by + the private-task feature. + Each canvas uses the production component with fictional, isolated + API fixtures. +

+

+ Privacy flows down the task tree. Sharing a child grants access to + that child and its descendants. Ancestors and siblings need a + separate grant. +

+
+
+

Start with the user stories

+

+ These {journeys.length} journeys run their interactions automatically and leave + the resulting screen visible. Use the Storybook interactions panel + to inspect each named step, or reload a component story to explore + manually. +

+
+ {journeys.map(([name, id, description]) => ( + +

{name}

+

{description}

+
+ ))} +
+
+
+

+ Complete component inventory +

+
+ + + + + + + + + + {surfaces.map(([name, change, group, id, coverage]) => ( + + + + + + ))} + +
Production surfaceChangeReview coverage
+ + {name} + + + {change} + {coverage}
+
+
+
+

+ How to review +

+
    +
  1. + Walk the {journeys.length} user stories, then use the inventory to inspect + every component's individual states. +
  2. +
  3. + Use the theme and viewport toolbar for any canvas. Explicit + mobile/light stories preserve important review views. +
  4. +
  5. + Inspect the full task pages to verify menu integration, permission + gates, locked ancestry, and layout. +
  6. +
  7. + Reload a story to reset its in-memory changes. No story connects + to Gmail or changes a real company's data. +
  8. +
+

+ Server-only changes—authorization, search filtering, notification + delivery, run/workspace history, connector credentials, and + descendant propagation—do not add independent UI components. The + displayed responses model those outcomes; a Storybook interaction is + not evidence of backend security enforcement. +

+
+
+
+ ); +} +export default { + title: "Private tasks/00 Review guide", + parameters: { layout: "fullscreen", docs: { story: { inline: false } } }, + render: () => , +} satisfies Meta; +export const CoverageAndUserStories: StoryObj = {}; diff --git a/ui/storybook/stories/private-tasks/01-creation.stories.tsx b/ui/storybook/stories/private-tasks/01-creation.stories.tsx new file mode 100644 index 00000000000..ad44dd8ddf7 --- /dev/null +++ b/ui/storybook/stories/private-tasks/01-creation.stories.tsx @@ -0,0 +1,257 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { PrivacyPage } from "./06-full-product-pages.stories"; +import { useDialog } from "@/context/DialogContext"; +import { Button } from "@/components/ui/button"; +import { + mobile, + choosePrivateTask, + privacyDecorator, + privacyParameters, + StoryFrame, +} from "./PrivacyStory"; + +function Creation({ + parent = false, + project = false, + draft = false, + taskTitle = "Prepare my board briefing", +}: { + parent?: boolean; + project?: boolean; + draft?: boolean; + taskTitle?: string; +}) { + const { openNewIssue } = useDialog(); + const opened = useRef(false); + const open = () => + openNewIssue({ + ...(draft + ? {} + : { + title: parent + ? "Research market benchmarks" + : taskTitle, + }), + ...(parent + ? { + parentId: "privacy-root", + parentIdentifier: "PAP-410", + parentTitle: taskTitle, + } + : {}), + ...(project ? { projectId: "project-private" } : {}), + }); + useEffect(() => { + open(); + }, [parent, project, draft, taskTitle]); + return ; +} +const meta = { + title: "Private tasks/01 Creation", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true, docs: { description: { component: "Route /PAP/issues with the new-task composer. Choose Private task from +; the lock chip keeps the selection visible. Hover, focus, or tap an inherited chip to see the parent task or private project that requires privacy. Mobile stories use the real shell and check that menu actions and composer controls stay inside the viewport." } } }, + args: { taskTitle: "Prepare my board briefing" }, + argTypes: { taskTitle: { control: "text" } }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const OpenByDefault: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await page.findByRole("button", { name: "Add to composer" }); + await waitFor(() => expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument()); + }, +}; +export const PrivacyInPlusMenu: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await expect(await page.findByTestId("composer-add-private")).toBeVisible(); + }, +}; +export const PrivateBeforeSaving: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await choosePrivateTask(page); + await expect(await page.findByRole("button", { name: "Remove private task" })).toBeVisible(); + }, +}; +export const RemovePrivateChoice: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await choosePrivateTask(page); + await userEvent.click(await page.findByRole("button", { name: "Remove private task" })); + await waitFor(() => expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument()); + }, +}; +export const ChildInheritsPrivacy: Story = { + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await expect(chip).toBeDisabled(); + await userEvent.hover(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("Subtask of private task Prepare my board briefing"); + }, +}; +export const MobilePrivateChoice: Story = { ...PrivateBeforeSaving, globals: mobile }; +async function expectInsideViewport(element: HTMLElement) { + await waitFor(() => { + const rect = element.getBoundingClientRect(); + const view = element.ownerDocument.defaultView!; + expect(rect.width).toBeGreaterThan(0); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual(0); + expect(rect.right).toBeLessThanOrEqual(view.innerWidth); + expect(rect.bottom).toBeLessThanOrEqual(view.innerHeight); + }); +} +async function openMobilePlusMenu(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + const menu = await page.findByRole("dialog", { name: "Add" }); + await expectInsideViewport(menu); + await expectInsideViewport(within(menu).getByRole("button", { name: "Close Add menu" })); + for (const action of within(menu).getAllByRole("button")) await expectInsideViewport(action); + return page; +} +export const MobilePlusMenu: Story = { + globals: mobile, + parameters: { docs: { description: { story: "390 × 844. The Add menu stays below the safe-area inset; its header, close action, and every option remain on screen." } } }, + play: async ({ canvasElement }) => { await openMobilePlusMenu(canvasElement); }, +}; +export const SmallPhonePlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "smallPhone", isRotated: false } }, + parameters: { viewport: { options: { smallPhone: { name: "Small phone", styles: { width: "320px", height: "568px" } } } }, docs: { description: { story: "320 × 568. All menu actions remain reachable on the smallest supported phone layout." } } }, +}; +export const ShortViewportPlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "shortPhone", isRotated: false } }, + parameters: { viewport: { options: { shortPhone: { name: "Short phone viewport", styles: { width: "390px", height: "360px" } } } }, docs: { description: { story: "390 × 360. A short visible viewport exercises the menu's height limit and internal scrolling. This is a layout check, not a simulation of a native software keyboard." } } }, +}; +export const LandscapePlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "landscapePhone", isRotated: false } }, + parameters: { viewport: { options: { landscapePhone: { name: "Landscape phone", styles: { width: "667px", height: "375px" } } } }, docs: { description: { story: "667 × 375. The same mobile menu fits when the phone is rotated." } } }, +}; +export const PrivateProject: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await expect(chip).toBeDisabled(); + await userEvent.hover(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("In private project Executive planning"); + }, +}; +export const PersonalProject: Story = { + parameters: { privacy: { personal: true } }, + render: () => , +}; +export const RestoredPrivateDraft: Story = { + parameters: { privacy: { draft: true } }, + render: () => , +}; +export const CreationFailure: Story = { + parameters: { privacy: { failure: "create", draft: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Create task" }), + ); + await expect( + await page.findByText("The request could not be completed. Try again."), + ).toBeVisible(); + }, +}; +export const MobilePrivateChild: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await userEvent.click(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("Subtask of private task Prepare my board briefing"); + }, +}; +export const MobilePrivateProject: Story = { + ...PrivateProject, + globals: mobile, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await userEvent.click(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("In private project Executive planning"); + }, +}; +export const SmallPhoneComposer: Story = { + globals: SmallPhonePlusMenu.globals, + parameters: SmallPhonePlusMenu.parameters, + play: async ({ canvasElement }) => { + const page = await openMobilePlusMenu(canvasElement); + await userEvent.click(page.getByTestId("composer-add-private")); + await userEvent.type(page.getByRole("textbox", { name: "Task title" }), " with a longer title"); + await expectInsideViewport(page.getByTestId("composer-private-chip")); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + await userEvent.click(page.getByRole("button", { name: "Add to composer" })); + await userEvent.click(page.getByTestId("composer-add-plan")); + await expectInsideViewport(page.getByTestId("task-chat-composer-mode")); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + await userEvent.click(page.getByRole("button", { name: "Remove private task" })); + await expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument(); + await expect(page.getByRole("textbox", { name: "Task title" })).toHaveValue("Prepare my board briefing with a longer title"); + await choosePrivateTask(page); + await expectInsideViewport(page.getByTestId("composer-private-chip")); + await waitFor(() => expect(page.getByTestId("task-chat-composer-mode").getBoundingClientRect().right) + .toBeLessThanOrEqual(page.getByTestId("task-chat-composer-selection").getBoundingClientRect().left)); + await userEvent.click(page.getByRole("button", { name: "Select assignee" })); + await expectInsideViewport(await page.findByRole("dialog", { name: "Select assignee" })); + await userEvent.click(page.getByRole("button", { name: "Close picker" })); + await userEvent.click(page.getByRole("button", { name: "Select model and effort" })); + await expectInsideViewport(await page.findByRole("dialog", { name: "Select model and effort" })); + await userEvent.click(page.getByRole("button", { name: "Close picker" })); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + }, +}; +export const LightPrivateDraft: Story = { + globals: { theme: "light" }, + parameters: { privacy: { draft: true } }, + render: () => , +}; + +export const ParentAccessLoading: Story = { + parameters: { privacy: { loading: "parent" } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByText("Checking parent access…")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeDisabled(); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await expect(page.queryByTestId("composer-add-private")).not.toBeInTheDocument(); + }, +}; +export const ParentAccessUnavailable: Story = { + parameters: { privacy: { failure: "parent" } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByText("Couldn't check parent access.")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeDisabled(); + }, +}; +export const RetryParentAccess: Story = { + parameters: { privacy: { failure: "parent", retryOnce: true } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry" })); + await expect(await page.findByRole("button", { name: "Private task" })).toBeDisabled(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeEnabled(); + }, +}; +export const MobileParentAccessUnavailable: Story = { ...ParentAccessUnavailable, globals: mobile }; diff --git a/ui/storybook/stories/private-tasks/02-sharing.stories.tsx b/ui/storybook/stories/private-tasks/02-sharing.stories.tsx new file mode 100644 index 00000000000..c20a70273d2 --- /dev/null +++ b/ui/storybook/stories/private-tasks/02-sharing.stories.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { IssueShareSheet } from "@/components/IssueShareSheet"; +import { Button } from "@/components/ui/button"; +import { privacyCompanyId } from "../../fixtures/privateTasks"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function Sharing({ + add = "", + implicit = false, + child = false, +}: { + add?: string; + implicit?: boolean; + child?: boolean; +}) { + const state = usePrivacyStory(); + const [open, setOpen] = useState(true); + return ( + + + + + ); +} +const meta = { + title: "Private tasks/02 Sharing", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task menu → Share → Add someone. Inspect avatars in search and selected values, inherited grants, reader access, confirmations, empty/loading/error states and mobile. The sticky-access paragraph is removed." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const AllAccessSources: Story = { parameters: { privacy: { taskProject: true } } }; +export const OwnerAndCurrentAssignee: Story = { + render: () => , +}; +export const OnlyYou: Story = { parameters: { privacy: { grants: "empty" } } }; +export const ReadOnlyMember: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await page.findByText("Morgan Reed"); + await expect( + page.queryByRole("button", { name: "Add someone" }), + ).not.toBeInTheDocument(); + await expect( + page.queryByRole("button", { name: "Revoke" }), + ).not.toBeInTheDocument(); + }, +}; +export const InheritedAccess: Story = { + parameters: { privacy: { grants: "inherited" } }, + render: () => , +}; +export const StickyAssignmentAfterUnassignment: Story = { + parameters: { privacy: { grants: "assignment" } }, +}; +export const RemoveCurrentAssigneesSavedGrant: Story = { + parameters: { privacy: { grants: "assignment" } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Remove saved grant" }), + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "current assignment", + ); + }, +}; +export const RevokeConfirmation: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "descendants", + ); + }, +}; +export const RevokeFailure: Story = { + parameters: { privacy: { failure: "revoke" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await userEvent.click( + await page.findByRole("button", { name: "Remove grant" }), + ); + await expect(await page.findByRole("alertdialog")).toBeVisible(); + }, +}; +export const ChoosePersonOrAgent: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const ShareChildWithPerson: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const DedicatedAgent: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const SharedAgentCaution: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , + play: async ({ canvasElement }) => { + await expect( + await within(canvasElement.ownerDocument.body).findByRole("note", { + name: "Shared agent caution", + }), + ).toHaveTextContent("residual private context"); + }, +}; +export const LegacyAgentCaution: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const AddFailure: Story = { + parameters: { privacy: { grants: "empty", failure: "add" } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.keyboard("{Escape}"); + await userEvent.click(await page.findByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Couldn't add access")).toBeVisible(), + ); + }, +}; +export const Loading: Story = { + parameters: { privacy: { loading: "grants" } }, +}; +export const LoadFailure: Story = { + parameters: { privacy: { failure: "grants" } }, +}; +export const RetryRecovers: Story = { + parameters: { privacy: { failure: "grants", retryOnce: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry" })); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + }, +}; +export const LongNamesMobile: Story = { + globals: mobile, + parameters: { privacy: { grants: "long" } }, +}; +export const MobileSharing: Story = { globals: mobile }; +export const LightSharing: Story = { globals: { theme: "light" } }; + +export const SearchPeopleAndAgents: Story = { + render: () => , + parameters: { privacy: { grants: "empty" }, docs: { description: { story: "Task sharing search: human avatar or initials and agent character avatars stay visible in results and after selection." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await expect(await page.findByRole("option", { name: "Executive assistant" })).toBeVisible(); + }, +}; +export const MobileSearchPeopleAndAgents: Story = { ...SearchPeopleAndAgents, globals: mobile }; + +export const AddDirectGrantAlongsideInheritedAccess: Story = { + parameters: { privacy: { grants: "inherited" }, docs: { description: { story: "Give Morgan an independent grant to this child before removing parent access. The dropdown includes people whose current access is inherited." } } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await userEvent.click(await page.findByRole("option", { name: "Morgan Reed" })); + await userEvent.click(page.getByRole("button", { name: "Add" })); + await page.findByText("Access granted"); + await waitFor(() => expect(page.getAllByText("Morgan Reed")).toHaveLength(2)); + await expect((await page.findAllByRole("button", { name: "Revoke" }))[0]!).toBeEnabled(); + }, +}; +export const MobileIndependentChildGrant: Story = { ...AddDirectGrantAlongsideInheritedAccess, globals: mobile }; + +export const CurrentAssigneeAlreadyHasSavedAccess: Story = { + parameters: { privacy: { grants: "assignment" }, docs: { description: { story: "A direct assignment grant is already saved on this task. The API deduplicates subjects, so the add picker does not promise a second independent grant for the same assignee." } } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await page.findByRole("option", { name: "Morgan Reed" }); + await expect(page.queryByRole("option", { name: "Executive assistant" })).not.toBeInTheDocument(); + }, +}; diff --git a/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx b/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx new file mode 100644 index 00000000000..cbeb5fd9750 --- /dev/null +++ b/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx @@ -0,0 +1,152 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { IssuePrivacyActions } from "@/components/IssuePrivacyActions"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { privacyCompanyId } from "../../fixtures/privateTasks"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function PrivacyActionsStory({ child = false }: { child?: boolean }) { + const state = usePrivacyStory(); + const { data: issue } = useQuery({ + queryKey: queryKeys.issues.detail(child ? "privacy-child" : "privacy-root"), + queryFn: () => issuesApi.get(child ? "privacy-child" : "privacy-root"), + }); + return ( + + {issue && ( +
+

+ {issue.identifier} ·{" "} + {issue.visibility === "private" ? "Private" : "Open to company"} +

+ {}} + > + {(items) => ( +
+ {items} +
+ )} +
+
+ )} +
+ ); +} +const meta = { + title: "Private tasks/03 Task actions", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task menu privacy actions, permission gates and disclosure confirmation." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const PrivateOwner: Story = {}; +export const OpenOwner: Story = { + parameters: { privacy: { visibility: "open" } }, +}; +export const SharedReader: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Make public" }), + ).toBeDisabled(); + }, +}; +export const CompanyAdmin: Story = { + parameters: { privacy: { role: "admin" } }, +}; +export const MakePublicConfirmation: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "Existing private subtasks keep their privacy", + ); + }, +}; +export const MakePublicFailure: Story = { + parameters: { privacy: { failure: "visibility" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Make public" }), + ); + await waitFor(() => + expect(page.getByText("Couldn't change visibility")).toBeVisible(), + ); + await expect(dialog).toBeVisible(); + }, +}; +export const MobileConfirmation: Story = { + ...MakePublicConfirmation, + globals: mobile, +}; + +export const InheritedPrivateParent: Story = { + render: () => , + parameters: { docs: { description: { story: "Make public is disabled while a private parent controls this task. Hover or focus explains that the task must be moved first." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const button = await page.findByRole("button", { name: "Make public" }); + await expect(button).toBeDisabled(); + await userEvent.hover(button.parentElement!); + await waitFor(async () => expect(await page.findByRole("tooltip")).toHaveTextContent("private parent")); + }, +}; +export const PrivateProjectRestriction: Story = { + parameters: { privacy: { taskProject: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const button = await page.findByRole("button", { name: "Make public" }); + await waitFor(() => expect(button).toBeDisabled()); + await userEvent.hover(button.parentElement!); + await waitFor(async () => expect(await page.findByRole("tooltip")).toHaveTextContent("private project")); + }, +}; +export const PersonalProjectCanMakePublic: Story = { + parameters: { privacy: { taskProject: true, personal: true } }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(within(canvasElement).getByRole("button", { name: "Make public" })).toBeEnabled()); + }, +}; +export const RetryInheritedAccessCheck: Story = { + render: () => , + parameters: { privacy: { failure: "parent", retryOnce: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry access check" })); + await waitFor(() => expect(page.queryByRole("button", { name: "Retry access check" })).not.toBeInTheDocument()); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + }, +}; diff --git a/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx b/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx new file mode 100644 index 00000000000..22a618518d4 --- /dev/null +++ b/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx @@ -0,0 +1,219 @@ +import type { IssueRelationIssueSummary } from "@paperclipai/shared"; +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { RelationNavigationList } from "@/components/task-detail/TaskDetailRelationsPanel"; +import { LockedIssueChip } from "@/components/LockedIssueChip"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { RemovableIssueReferencePill } from "@/components/issue-properties/relation-controls"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, +} from "./PrivacyStory"; + +const locked = { + id: "privacy-root", + identifier: "PAP-410", + locked: true as const, +}; +// Runtime redaction deliberately omits private title/status fields. +const lockedBlocker = locked as unknown as IssueRelationIssueSummary; +const readable: IssueRelationIssueSummary = { + id: "privacy-child", + identifier: "PAP-411", + title: "Research market benchmarks", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, +}; +function References({ + mode = "chips", + mobileControl = false, +}: { + mode?: "chips" | "pills" | "markdown" | "blocker" | "mixed" | "remove"; + mobileControl?: boolean; +}) { + const [removed, setRemoved] = useState(false); + return ( + +
+ {mode === "chips" && ( + <> +
+ +
+
+ +
+
+ +
+ + )} + {mode === "pills" && ( + <> +

+ Readable +

+

+ Private +

+

+ Property variant{" "} + +

+ + )} + {mode === "markdown" && ( + + { + "Read the public research in [PAP-499](/PAP/issues/PAP-499). The original briefing [PAP-498](/PAP/issues/PAP-498) is unavailable to this reader.\n\nBare references are also checked: PAP-499 and PAP-498." + } + + )} + {(mode === "blocker" || mode === "mixed") && ( + + )} + {mode === "remove" && + (removed ? ( +

+ Blocker removed. The private task itself is unchanged. +

+ ) : ( + setRemoved(true)} + /> + ))} +
+
+ ); +} +const meta = { + title: "Private tasks/04 References and blockers", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task references, parent navigation and blockers redact unreadable task content. Inspect all existing reference variants and mobile." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const LockedChipVariants: Story = { + play: async ({ canvasElement }) => { + await expect( + within(canvasElement).queryByRole("link"), + ).not.toBeInTheDocument(); + }, +}; +export const ReferencePillVariants: Story = { + render: () => , +}; +export const MarkdownMentions: Story = { + render: () => , +}; +export const PendingMention: Story = { + parameters: { privacy: { loading: "mention" } }, + render: () => , +}; +export const PrivateBlocker: Story = { + render: () => , +}; +export const MixedBlockers: Story = { + render: () => , +}; +export const RemovablePrivateBlocker: Story = { + render: () => , +}; +export const RemoveBlockerConfirmation: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Remove PAP-410 as blocker" }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, +}; +export const MobileBlockerActions: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Actions for blocker PAP-410" }), + ); + await expect( + await page.findByRole("menuitem", { name: "Remove blocker" }), + ).toBeVisible(); + await expect( + page.queryByRole("menuitem", { name: "Visit task" }), + ).not.toBeInTheDocument(); + }, +}; +export const MobileBlockerConfirmation: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Actions for blocker PAP-410" }), + ); + await userEvent.click( + await page.findByRole("menuitem", { name: "Remove blocker" }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, +}; +export const LightReferences: Story = { + globals: { theme: "light" }, + render: () => , +}; + +export const AncestorNavigation: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect(page.getByTestId("locked-issue-chip")).toHaveTextContent( + "PAP-410", + ); + await expect(page.getAllByRole("link")).toHaveLength(1); + }, +}; diff --git a/ui/storybook/stories/private-tasks/05-projects.stories.tsx b/ui/storybook/stories/private-tasks/05-projects.stories.tsx new file mode 100644 index 00000000000..85d32a36829 --- /dev/null +++ b/ui/storybook/stories/private-tasks/05-projects.stories.tsx @@ -0,0 +1,244 @@ +import type { Project } from "@paperclipai/shared"; +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { ProjectProperties } from "@/components/ProjectProperties"; +import { IssueShareSheet } from "@/components/IssueShareSheet"; +import { Button } from "@/components/ui/button"; +import { ProjectAccessMembers } from "@/components/ProjectAccessMembers"; +import { projectsApi } from "@/api/projects"; +import { + mobile, + openProjectMemberPicker, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function ProjectStory({ + settings = false, + open = false, + projectName = "Executive planning", +}: { + settings?: boolean; + open?: boolean; + projectName?: string; +}) { + const state = usePrivacyStory(); + const [project, setProject] = useState(() => ({ + ...state.projects[0]!, + name: projectName, + visibility: open ? ("open" as const) : ("private" as const), + })); + useEffect(() => setProject(value => ({ ...value, name: projectName })), [projectName]); + return ( + + {settings ? ( + { + const updated = await projectsApi.update( + project.id, + data, + project.companyId, + ); + setProject(updated); + }} + /> + ) : ( + + )} + + ); +} +const openMembers = async (canvasElement: HTMLElement) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: /^(Manage|View) access$/ }), + ); + await page.findByRole("dialog", { name: "Private project access" }); + return page; +}; +const meta = { + title: "Private tasks/05 Projects", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Project settings → Manage access. Human and agent identities appear in the search dropdown and selected value. Owner, reader, error, loading and mobile states use production components." } } }, + args: { projectName: "Executive planning" }, + argTypes: { projectName: { control: "text" } }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const PrivateProjectSettings: Story = { + render: (args) => , +}; +export const OpenProjectSettings: Story = { + render: (args) => , +}; +export const PersonalProjectSettings: Story = { + parameters: { privacy: { personal: true } }, + render: (args) => , +}; +export const ReaderProjectSettings: Story = { + parameters: { privacy: { role: "reader" } }, + render: (args) => , +}; +export const AdminProjectSettings: Story = { + parameters: { privacy: { role: "admin" } }, + render: (args) => , +}; +export const MemberList: Story = { + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + await expect( + page.queryByRole("button", { name: "Remove Avery Chen" }), + ).not.toBeInTheDocument(); + }, +}; +export const ReaderMemberList: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByRole("button", { name: "Remove Morgan Reed" }), + ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Add" })).toBeDisabled(); + }, +}; +export const SharedAgentSelected: Story = { + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: "Research team agent" }), + ); + await expect( + await page.findByText(/This agent can retain private project context/), + ).toBeVisible(); + }, +}; +export const LoadingMembers: Story = { + parameters: { privacy: { loading: "members" } }, + play: async ({ canvasElement }) => { + await openMembers(canvasElement); + }, +}; +export const MemberLoadFailure: Story = { + parameters: { privacy: { failure: "members" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByText("Couldn't load project access members."), + ).toBeVisible(); + }, +}; +export const DirectoryFailure: Story = { + parameters: { privacy: { failure: "directory" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByText("Couldn't load the company access directory."), + ).toBeVisible(); + }, +}; +export const RemoveFailure: Story = { + parameters: { privacy: { failure: "project-remove" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await userEvent.click( + await page.findByRole("button", { name: "Remove Morgan Reed" }), + ); + await waitFor(() => + expect(page.getByText("Couldn't remove project access")).toBeVisible(), + ); + }, +}; +export const AddFailure: Story = { + parameters: { privacy: { failure: "project-add" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: /Sam Rivera/ }), + ); + await userEvent.click(page.getByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Couldn't add project access")).toBeVisible(), + ); + }, +}; +export const MobileMembers: Story = { + globals: mobile, + play: async ({ canvasElement }) => { + await openMembers(canvasElement); + }, +}; +export const LightProjectSettings: Story = { + globals: { theme: "light" }, + render: (args) => , +}; + +export const OpenProjectConfirmation: Story = { + render: (args) => , + parameters: { docs: { description: { story: "Before opening a private project, the owner sees that project-only access to private tasks will end and can share tasks directly first." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("will lose access"); + }, +}; +export const OpenProjectAudienceCopy: Story = { + ...OpenProjectConfirmation, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Open to company" })); + await expect(await page.findByText(/Everyone in the company can discover this project/)).toBeVisible(); + await expect(page.queryByRole("button", { name: "Manage access" })).not.toBeInTheDocument(); + }, +}; +export const MobileOpenProjectConfirmation: Story = { ...OpenProjectConfirmation, globals: mobile }; +function ProjectAudienceJourney() { + const state = usePrivacyStory(); + const [open, setOpen] = useState(false); + return + + + + ; +} +export const MembershipUpdatesTaskAudience: Story = { + render: () => , + parameters: { privacy: { taskProject: true, grants: "empty" }, docs: { description: { story: "Open the task audience, remove Morgan from the project, then reopen that cached share sheet. Project-only access disappears without reloading the page." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "View task audience" })); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await userEvent.click(page.getByRole("button", { name: "Manage access" })); + await userEvent.click(await page.findByRole("button", { name: "Remove Morgan Reed" })); + await page.findByText("Project access removed"); + await userEvent.keyboard("{Escape}"); + await userEvent.click(page.getByRole("button", { name: "View task audience" })); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + await waitFor(() => expect(within(dialog).queryByText("Morgan Reed")).not.toBeInTheDocument()); + }, +}; diff --git a/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx b/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx new file mode 100644 index 00000000000..c0550d77410 --- /dev/null +++ b/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { Route, Routes, useNavigate } from "@/lib/router"; +import { Layout } from "@/components/Layout"; +import { IssueDetail } from "@/pages/IssueDetail"; +import { Issues } from "@/pages/Issues"; +import { ProjectDetail } from "@/pages/ProjectDetail"; +import { Projects } from "@/pages/Projects"; +import { DesignGuide } from "@/pages/DesignGuide"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { mobile, privacyDecorator, privacyParameters } from "./PrivacyStory"; + +export function PrivacyPage({ + child = false, + guide = false, + projects = false, + tasks = false, + projectSettings = false, +}: { + child?: boolean; + guide?: boolean; + projects?: boolean; + tasks?: boolean; + projectSettings?: boolean; +}) { + const navigate = useNavigate(); + const [ready, setReady] = useState(false); + useEffect(() => { + navigate( + projectSettings ? "/PAP/projects/project-private/configuration" : tasks ? "/PAP/issues" : projects ? "/PAP/projects" : guide + ? "/PAP/design-guide" + : `/PAP/issues/${child ? "PAP-411" : "PAP-410"}`, + { replace: true }, + ); + setReady(true); + }, [navigate, child, guide, projects, tasks, projectSettings]); + if (!ready) return null; + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + + + + ); +} +const meta = { + title: "Private tasks/06 Full product pages", + excludeStories: ["PrivacyPage"], + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +async function openTaskMenu(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await page.findByRole( + "button", + { name: "More task actions" }, + { timeout: 15000 }, + ); + // Navigation focus and async page queries can replace or dismiss the first + // trigger. Reacquire it, and only open when closed; never toggle an open menu. + await waitFor( + async () => { + const trigger = page.getByRole("button", { name: "More task actions" }); + if (trigger.getAttribute("aria-expanded") !== "true") { + await userEvent.click(trigger); + } + await expect(page.getByRole("button", { name: "Share…" })).toBeVisible(); + }, + { timeout: 15000 }, + ); + return page; +} +export const OwnerTask: Story = {}; +export const OwnerTaskMenu: Story = { + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeEnabled(); + }, +}; +export const OwnerSharingFromMenu: Story = { + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await userEvent.click(await page.findByRole("button", { name: "Share…" })); + await expect( + await page.findByRole("dialog", { name: "Who can access this task" }), + ).toBeVisible(); + }, +}; +export const SharedChildReader: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect( + await page.findByTestId("locked-issue-chip", {}, { timeout: 15000 }), + ).toHaveTextContent("PAP-410"); + await expect( + page.queryByRole("link", { name: /Task PAP-410/ }), + ).not.toBeInTheDocument(); + await expect( + page.queryByText("Prepare my board briefing"), + ).not.toBeInTheDocument(); + }, + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , +}; +export const SharedReaderMenu: Story = { + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeDisabled(); + }, +}; +export const AdminTask: Story = { parameters: { privacy: { role: "admin" } } }; +export const MobileSharedChild: Story = { + globals: mobile, + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , +}; +export const LightTask: Story = { globals: { theme: "light" } }; +export const DesignGuideLockedReferences: Story = { + render: () => , + play: async ({ canvasElement }) => { + const section = await within(canvasElement).findByText( + "LockedIssueChip", + {}, + { timeout: 15000 }, + ); + section.scrollIntoView({ block: "center" }); + await expect(section).toBeVisible(); + }, +}; + +export const SharedChildTasksPanel: Story = { + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("tab", { name: "Tasks" }, { timeout: 15000 }), + ); + const ancestors = await page.findByRole("list", { + name: "Ancestor tasks, root to parent", + }); + await expect( + within(ancestors).getByTestId("locked-issue-chip"), + ).toHaveTextContent("PAP-410"); + await expect(within(ancestors).queryByRole("link")).not.toBeInTheDocument(); + }, +}; + +export const ClassicSharedChild: Story = { + parameters: { privacy: { role: "reader", childOnly: true, classic: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement); + const chips = await page.findAllByTestId( + "locked-issue-chip", + {}, + { timeout: 15000 }, + ); + await expect(chips.length).toBeGreaterThanOrEqual(1); + await expect( + page.queryByRole("link", { name: /PAP-410/ }), + ).not.toBeInTheDocument(); + }, +}; + +export const PrivateProjectsInNavigation: Story = { parameters: { docs: { description: { story: "Route /PAP/projects. Private project locks sit on the right in the list and starred navigation." } } }, render: () => }; +export const ClassicPrivateProjects: Story = { parameters: { privacy: { classic: true } }, render: () => }; +export const MobilePrivateProjects: Story = { globals: mobile, render: () => }; + +export const OwnerInheritedTaskMenu: Story = { + render: () => , + parameters: { docs: { description: { story: "Actual /PAP/issues/PAP-411 page: sharing remains available, while Make public explains the private parent restriction." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Share…" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + }, +}; +export const MobileInheritedTaskMenu: Story = { ...OwnerInheritedTaskMenu, globals: mobile }; + +export const ProjectVisibilityThroughAutosave: Story = { + render: () => , + parameters: { docs: { description: { story: "Actual project Configuration page. Open the project through its field-autosave control and review the private-task access consequence before saving." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" }, { timeout: 15000 })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("will lose access"); + }, +}; +export const ProjectVisibilityAutosaveCompletes: Story = { + ...ProjectVisibilityThroughAutosave, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" }, { timeout: 15000 })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Open to company" })); + await expect(await page.findByText(/Everyone in the company can discover this project/)).toBeVisible(); + }, +}; +export const MobileProjectVisibilityThroughAutosave: Story = { ...ProjectVisibilityThroughAutosave, globals: mobile }; + +export const OwnerMovesOutOfPrivateParent: Story = { + render: () => , + parameters: { docs: { description: { story: "The owner removes a private parent through the production properties picker, then reviews Make public on the same task. Privacy hints refresh without reloading the page." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + await userEvent.click(page.getByRole("button", { name: "More task actions" })); + await userEvent.click(await page.findByRole("button", { name: "Edit parent" })); + await userEvent.click(await page.findByRole("button", { name: "No parent" })); + await openTaskMenu(canvasElement); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("Existing private subtasks keep their privacy"); + }, +}; +export const OwnerMovesOutOfPrivateProject: Story = { + render: () => , + parameters: { privacy: { taskProject: true }, docs: { description: { story: "The owner removes the private project through the production properties picker. The task keeps its private visibility, and Make public becomes available without a page reload." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + await userEvent.click(page.getByRole("button", { name: "More task actions" })); + await userEvent.click(await page.findByRole("button", { name: "Executive planning" })); + await userEvent.click(await page.findByRole("button", { name: "No project" })); + await openTaskMenu(canvasElement); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("Make this task public?"); + }, +}; diff --git a/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx b/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx new file mode 100644 index 00000000000..746661637fc --- /dev/null +++ b/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx @@ -0,0 +1,210 @@ +import { OwnerMovesOutOfPrivateParent, OwnerMovesOutOfPrivateProject } from "./06-full-product-pages.stories"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import creation from "./01-creation.stories"; +import sharing, { + ShareChildWithPerson, + RemoveCurrentAssigneesSavedGrant, + AddDirectGrantAlongsideInheritedAccess, +} from "./02-sharing.stories"; +import actions from "./03-task-actions.stories"; +import { RemovablePrivateBlocker } from "./04-references-and-blockers.stories"; +import projects, { MembershipUpdatesTaskAudience, OpenProjectAudienceCopy } from "./05-projects.stories"; +import { + openProjectMemberPicker, + choosePrivateTask, + privacyDecorator, + privacyParameters, + mobile, +} from "./PrivacyStory"; + +// Reuse the same real-component canvases. Each play is a complete user journey, +// while the component stories deliberately stop at individual review states. +const meta = { + title: "Private tasks/07 User stories", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const CeoCreatesPrivateTask: Story = { + name: "CEO · Create a private task", + render: creation.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Choose privacy before saving", async () => { + await choosePrivateTask(page); + await expect(await page.findByRole("button", { name: "Remove private task" })).toBeVisible(); + }); + await step("Create the private briefing", async () => { + await userEvent.click(page.getByRole("button", { name: "Create task" })); + await waitFor(() => expect(page.getByText("Created PAP-414")).toBeVisible()); + await expect( + page.queryByTestId("composer-private-chip"), + ).not.toBeInTheDocument(); + }); + }, +}; +export const MobileCeoCreatesPrivateTask: Story = { + ...CeoCreatesPrivateTask, + name: "CEO · Create a private task on mobile", + globals: mobile, + parameters: { docs: { description: { story: "Use the actual new-task composer in the mobile shell: choose Private task from +, review the lock chip, and save the private briefing." } } }, +}; +export const CeoSharesOnlyChild: Story = { + name: "CEO · Share only a child", + render: ShareChildWithPerson.render, + parameters: { privacy: { grants: "empty" } }, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Review the selected person", async () => { + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + await waitFor(() => expect(dialog).toHaveTextContent("Morgan Reed")); + }); + await step("Share the research child with Morgan", async () => { + await userEvent.keyboard("{Escape}"); + await userEvent.click(await page.findByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Access granted")).toBeVisible(), + ); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + }); + }, +}; +export const OwnerRevokesAnExplicitGrant: Story = { + name: "Owner · Revoke a saved grant", + render: sharing.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Inspect the grant removal explanation", async () => { + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "another grant", + ); + }); + await step("Confirm removal", async () => { + await userEvent.click(page.getByRole("button", { name: "Remove grant" })); + await waitFor(() => + expect(page.getByText("Access revoked")).toBeVisible(), + ); + await expect(page.queryByText("Morgan Reed")).not.toBeInTheDocument(); + }); + }, +}; +export const CurrentAssignmentStillGrantsAccess: Story = { + name: "Owner · Remove a grant while assignment remains", + render: RemoveCurrentAssigneesSavedGrant.render, + parameters: { privacy: { grants: "assignment" } }, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Remove the assignee's saved grant", async () => { + await userEvent.click( + await page.findByRole("button", { name: "Remove saved grant" }), + ); + await userEvent.click( + await page.findByRole("button", { name: "Remove grant" }), + ); + await page.findByText("Access revoked"); + }); + await step( + "Current assignment remains visible as an independent reason", + async () => { + await expect( + await page.findByText("Executive assistant"), + ).toBeVisible(); + await expect(page.getByText("Current assignee")).toBeVisible(); + }, + ); + }, +}; +export const OwnerMakesTaskPublic: Story = { + name: "Owner · Make a task public", + render: actions.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Review disclosure before changing the audience", async () => { + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "Existing private subtasks keep their privacy", + ); + }); + await step("Confirm company-wide access", async () => { + const dialog = page.getByRole("alertdialog"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Make public" }), + ); + await expect( + await page.findByRole("button", { name: "Make private" }), + ).toBeEnabled(); + }); + }, +}; +export const ReaderDetachesPrivateBlocker: Story = { + name: "Reader · Detach a private blocker", + render: RemovablePrivateBlocker.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step( + "Remove a relationship without opening the private task", + async () => { + await userEvent.click( + await page.findByRole("button", { + name: "Remove PAP-410 as blocker", + }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, + ); + await step("Confirm the relationship change", async () => { + await userEvent.click( + page.getByRole("button", { name: "Remove blocker" }), + ); + await expect(await page.findByRole("status")).toHaveTextContent( + "Blocker removed", + ); + }); + }, +}; +export const OwnerAddsProjectMember: Story = { + name: "Project owner · Add a member", + render: projects.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Choose a company member", async () => { + await userEvent.click( + await page.findByRole("button", { name: "Manage access" }), + ); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: /Sam Rivera/ }), + ); + }); + await step("Grant access to the project", async () => { + await userEvent.click(page.getByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Project access added")).toBeVisible(), + ); + await expect( + await page.findByRole("button", { name: "Remove Sam Rivera" }), + ).toBeEnabled(); + }); + }, +}; + +export const OwnerKeepsChildAccessIndependent: Story = { ...AddDirectGrantAlongsideInheritedAccess, name: "Owner · Add a child grant before removing parent access" }; +export const OwnerRemovesProjectOnlyTaskAccess: Story = { ...MembershipUpdatesTaskAudience, name: "Project owner · Remove a member and check the task audience" }; +export const OwnerOpensProjectWithPrivateTasks: Story = { ...OpenProjectAudienceCopy, name: "Project owner · Review access loss before opening the project" }; + +export const OwnerMovesChildThenReviewsPublishing: Story = { + ...OwnerMovesOutOfPrivateParent, + name: "Owner · Move out of a private parent and review publishing", +}; +export const OwnerMovesTaskThenReviewsPublishing: Story = { + ...OwnerMovesOutOfPrivateProject, + name: "Owner · Move out of a private project and review publishing", +}; diff --git a/ui/storybook/stories/private-tasks/PrivacyStory.tsx b/ui/storybook/stories/private-tasks/PrivacyStory.tsx new file mode 100644 index 00000000000..979e55649fd --- /dev/null +++ b/ui/storybook/stories/private-tasks/PrivacyStory.tsx @@ -0,0 +1,94 @@ +import { userEvent, within } from "storybook/test"; +import { + createContext, + useContext, + useLayoutEffect, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Decorator } from "@storybook/react-vite"; +import { useCompany } from "@/context/CompanyContext"; +import { ToastViewport } from "@/components/ToastViewport"; +import { queryKeys } from "@/lib/queryKeys"; +import { + createPrivacyState, + installPrivacyApi, + privacyAgents, + privacyCompanyId, + type PrivacyScenario, + type PrivacyState, +} from "../../fixtures/privateTasks"; + +const PrivacyContext = createContext(null); +export function usePrivacyStory() { + const state = useContext(PrivacyContext); + if (!state) throw new Error("Private task stories require PrivacySandbox"); + return state; +} + +function PrivacySandbox({ + options, + children, +}: { + options: PrivacyScenario; + children: ReactNode; +}) { + const [state] = useState(() => createPrivacyState(options)); + const [ready, setReady] = useState(false); + const queryClient = useQueryClient(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useLayoutEffect(() => { + const restore = installPrivacyApi(state); + queryClient.setQueryData(queryKeys.auth.session, state.session); + queryClient.setQueryData(queryKeys.access.currentBoardAccess, state.access); + queryClient.setQueryData( + queryKeys.agents.list(privacyCompanyId), + privacyAgents, + ); + queryClient.setQueryData( + queryKeys.projects.list(privacyCompanyId), + state.projects, + ); + setSelectedCompanyId(privacyCompanyId); + setReady(true); + return restore; + }, [state, queryClient, setSelectedCompanyId]); + return ready && selectedCompanyId === privacyCompanyId ? ( + {children} + ) : null; +} + +export const privacyDecorator: Decorator = (Story, context) => ( + + + +); + +export const privacyParameters = { + layout: "fullscreen", + // Each canvas gets an iframe: several open dialogs and scoped fetch fixtures + // must never share one document on the autodocs page. + docs: { story: { inline: false, height: "760px" } }, +}; +export const mobile = { viewport: { value: "mobile", isRotated: false } }; + +export function StoryFrame({ children }: { title: string; story: string; checks: string[]; children: ReactNode }) { + return
{children}
; +} + +export async function choosePrivateTask(page: ReturnType) { + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await userEvent.click(await page.findByTestId("composer-add-private")); +} + +export async function openProjectMemberPicker(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { + name: "Private project access", + }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") + await userEvent.click(trigger); + await page.findByRole("dialog", { name: "Add a person or agent" }); +} From 8cfedd7df844d4e57be91c43a44b8266f1e63f07 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:41:24 -0500 Subject: [PATCH 05/55] fix(runner): restore task monitors and durable timed waits (#15446) ## Thinking Path > - Paperclip manages AI agents and their task execution. > - Agents need a durable way to return to work after a delayed check. > - The issue monitor scheduler already provides a one-shot wake for an assignee. > - Native runners reject generic execution-policy writes and had no bound monitor tool. > - Scheduling alone is insufficient because native completion also needs to accept a timed wait. > - This pull request adds an authorized monitor tool and connects it to completion and the existing scheduler. > - An agent can now schedule its next check, end the run, and resume on the same task. ## Linked Issues or Issue Description **What happened?** A native runner could not set its own task monitor. `call_api` correctly rejected execution-policy writes, while `schedule_wake` had no production binding. `paperclip_finish` also rejected monitor waits. **Expected behavior** A standard native run can set a one-shot monitor on its current task or another accessible task assigned to the same agent. After a confirmed schedule on the current task, it can yield. The scheduler later delivers `issue_monitor_due`. **Steps to reproduce** 1. Start a standard native task. 2. Ask the agent to check the task again later and end its current run. 3. Inspect available tools and try the generic issue execution-policy update. 4. Observe the missing native tool and the lifecycle-write denial. Related PRs: #14680 concerns monitor notes in the shared wake prompt. #11919 changes attempt-limit scope. This PR adds native scheduling and completion authority and retains the existing cumulative attempt bounds. It does not depend on either PR. ## What Changed - Add provider-neutral `set_task_monitor` with a default current-task target, future timestamp, required notes, existing bounds, and explicit clearing. - Check company, task visibility, ownership, runtime permissions, work mode, and active-run authority. Preserve review-only restrictions. Reject the reserved server-owned quota-recovery name before saving or accepting a native wait. - Commit the monitor, audit event, and retry receipt together. Retry receipts survive a successor run without re-arming cleared or consumed timers. - Permit `paperclip_finish` to yield to a persisted monitor. Recheck ownership and the schedule when committing final disposition. Release execution without an immediate continuation. - Preserve due monitors during native execution. Fence wake admission and consumption against replacement, clearing, reassignment, and completion. Preserve unrelated review policy. - Expose scheduled and consumed monitor instructions in task context. Update provider schemas, Rust validation, generated contracts, and execution documentation. - Add an opt-in live Codex smoke script with isolated data and explicit run/session/runner/process evidence. ## Verification - Repository `pnpm -r typecheck` and `pnpm build` passed after rebase. Server typecheck passed again after review fixes. All CI test shards pass on `3def77b1b`, including runner TypeScript/Rust, server, serialized server, workspace, and browser tests. All CI gates are green, including the canary dry run. Greptile is 5/5 on the same commit with zero unresolved threads. - The local monolithic `pnpm test:run`, started before the rebase, was interrupted after current-head CI test coverage passed. It is not counted as a standalone full-suite pass; the focused local regression suites passed. - Targeted server tests cover scheduling, replacement, clearing, policy preservation, cumulative bounds, cross-run retries, permissions, provider-neutral discovery, review restrictions, completion authority, and scheduler/finalizer races. - Runner contract/catalog/semantic tests and Rust terminal-tool tests cover the new operation and monitor completion. - Live Codex test passed twice (latest live run on `e0bcd63e6`) in a temporary database and workspace, with a 300,000 ms warm window. First run `67bd7709-c089-4d4a-9d2b-0d6b618a34b0` yielded at `2026-10-07T13:15:03.274Z`. Second run `97c323f9-595a-4cc5-a007-db5a2fbb937c` started at `13:15:30.952Z`, received `issue_monitor_due`, and completed the same task. Exactly one monitor wake was recorded. - Both live runs used native session `7b1dd753-1c9b-4e7a-b22f-a125dbc3748c`, runner `e90d9a1b-3502-4ee3-b15e-edc024c555d4`, provider session `01a11680-6d01-70c0-9a55-db7246ed66c3`, and PID `64218` with the same process start time. This proves warm reuse for that local Codex test, not only successful scheduling. - Reproduce the paid live test with `node --import ./server/node_modules/tsx/dist/loader.mjs server/scripts/smoke-native-task-monitor.ts --run`, with the installed Codex binary on `PATH` and a valid local login. ## Risks - The scheduler now defers monitor dispatch while the task has an active native run. A stuck run still depends on the existing recovery lifecycle. - Idempotency uses the existing run ledger; no table or migration is added. - Other providers share the tested tool and completion contracts. Only Codex received a live model test. - Existing `call_api` lifecycle restrictions remain enforced. Monitor waits do not bypass task blockers, reviews, or approvals. ## Model Used OpenAI Codex, GPT-6 family, with tool use, code execution, and TypeScript/Rust editing. The session does not expose the exact deployed model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 15 ++ doc/runner-api-tools.md | 5 +- .../capability/semantic-tool-contracts.json | 2 +- .../generated/semantic-action-catalog.json | 114 ++++++++++ .../evals/native-execution-seeded.json | 2 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../runner-core/src/provider_backend.rs | 36 +++- .../crates/runner-core/src/provider_bridge.rs | 2 +- .../spec/capability/protocol-coverage.json | 21 +- .../spec/operation-groups/source.json | 6 +- .../spec/paperclip-agent-operation-groups.md | 15 +- .../src/backends/runtime-context.test.ts | 3 +- .../src/catalog/reconciliation.test.ts | 11 +- .../catalog/semantic-action-catalog.test.ts | 3 +- .../src/catalog/semantic-action-catalog.ts | 7 + .../src/catalog/semantic-action-types.ts | 1 + .../src/contracts/completion-result.test.ts | 11 + .../src/contracts/completion-result.ts | 14 +- .../src/contracts/runtime-context.ts | 4 +- .../src/drivers/codex/codex-boundaries.ts | 3 +- .../codex/codex-session-server-requests.ts | 2 +- .../src/eval/workflow-evals.test.ts | 6 +- .../src/mock-core/codex-runner.ts | 4 +- .../src/protocol-actions/index.ts | 2 + .../src/protocol-actions/set-task-monitor.ts | 55 +++++ .../src/semantic-tools/discovery.ts | 1 + .../src/semantic-tools/paperclip-discovery.ts | 1 + .../src/semantic-tools/semantic-tools.test.ts | 3 +- .../src/semantic-tools/types.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/validators/index.ts | 1 + server/scripts/smoke-native-task-monitor.ts | 98 +++++++++ .../__tests__/issue-monitor-scheduler.test.ts | 87 ++++++++ .../native-status-arbiter-corpus.test.ts | 33 +++ server/src/routes/issues.ts | 101 +-------- server/src/services/heartbeat.ts | 156 +++++++++----- server/src/services/issue-monitors.ts | 166 ++++++++++++++ .../native-completion-feedback.ts | 11 + .../native-runtime/native-run-finalizer.ts | 5 + .../paperclip-runner-tool-authority.test.ts | 3 +- .../paperclip-runner-tool-authority.ts | 87 +++++++- .../native-runtime/status-arbiter.test.ts | 18 +- .../services/native-runtime/status-arbiter.ts | 24 ++- .../status-decision-committer.ts | 17 +- .../task-monitor.integration.test.ts | 204 ++++++++++++++++++ 45 files changed, 1157 insertions(+), 207 deletions(-) create mode 100644 packages/paperclip-runner/src/protocol-actions/set-task-monitor.ts create mode 100644 server/scripts/smoke-native-task-monitor.ts create mode 100644 server/src/services/issue-monitors.ts create mode 100644 server/src/services/native-runtime/task-monitor.integration.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index fe946031175..aac00b02447 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -656,6 +656,21 @@ An issue monitor is a one-shot deferred action path for agent-owned issues in `i Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next. +Native runners in standard execution use `set_task_monitor({ taskId?, idempotencyKey, monitor })`. Omit `taskId` for the current task. An explicit target must be accessible, in the same company, assigned to the caller with no human assignee, and `in_progress` or `in_review`. Runtime-management permission still applies. Ask, planning, and review-only runs cannot use this tool. Generic `call_api` lifecycle restrictions remain in force. + +To wait for a check: + +1. Set a future `monitor.nextCheckAt` and short `monitor.notes` describing the check, with optional service context and bounds. +2. Confirm the receipt's persisted task ID, monitor state, next-check time, and bounds. +3. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and `continuation: { kind: "monitor", summary, idempotencyKey }`. Report outstanding work honestly; the scheduled check may still block completion. +4. End the run. Paperclip keeps the task active, releases execution ownership, and the one-shot scheduler later wakes it with `issue_monitor_due`. This does not enqueue an immediate continuation. On resume, `get_task_context.activeTask.monitor` includes the consumed monitor’s notes and attempt count. + +Only a valid persisted monitor on the current task authorizes that finish; scheduling another owned task does not. Authority is checked again under the final disposition lock. A timer that becomes due during an active native execution remains scheduled until execution releases it. Dispatch checks the current schedule, claim, status, and assignee so an older dispatch cannot clear a replacement monitor. + +The service name `AI provider quota` is reserved for server-owned recovery of legacy runs. Native task monitors reject it; use ordinary service context and notes when scheduling a provider-usage check. + +Use a new idempotency key to replace the schedule or clear it with `monitor: null`. Retrying the original call returns current monitor state without re-arming a consumed, replaced, or cleared timer. Monitor changes, audit activity, and mutation receipts are committed together, and unrelated execution/review policy is preserved. Legacy agents continue to use the issue API. + Monitor policy lives under `executionPolicy.monitor` and includes: - `nextCheckAt`: when Paperclip should wake the assignee diff --git a/doc/runner-api-tools.md b/doc/runner-api-tools.md index 62451ba6b2c..a8dd849d49e 100644 --- a/doc/runner-api-tools.md +++ b/doc/runner-api-tools.md @@ -1,7 +1,10 @@ # Runner API escape hatch `search_api` and `call_api` extend the native runner when an available dedicated -operation cannot express the requested work. Existing tools remain preferred; +operation cannot express the requested work. Use `set_task_monitor` to schedule or clear a one-shot task check; after confirming +the receipt, finish with `yielded` and `continuation.kind: "monitor"` to await that +task’s timer. `call_api` still rejects monitor/execution-policy lifecycle writes. +Existing tools remain preferred; agents do not have to search before using them. Only two tool definitions are advertised. The API catalog is returned on demand, never injected into the initial prompt. diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index 1eb34eb5c55..22a57d2edda 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -1 +1 @@ -[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}}] +[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}}] diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json index cf853a0e411..641375e1fe1 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -2561,6 +2561,120 @@ "title": "Comment on approval", "version": 1 }, + { + "allowedModes": [ + "standard" + ], + "description": "Schedule, replace or clear a persisted one-shot monitor on an owned task.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "idempotencyKey": { + "description": "Reuse with identical arguments on retries.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "monitor": { + "additionalProperties": false, + "properties": { + "externalRef": { + "maxLength": 500, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "external_service", + null + ], + "type": [ + "string", + "null" + ] + }, + "maxAttempts": { + "description": "Optional cumulative attempt limit for this task's monitor.", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "nextCheckAt": { + "description": "Future UTC timestamp for the next check. This is one shot, not a recurring interval.", + "type": "string" + }, + "notes": { + "description": "What to check on the next run; do not include secrets.", + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "recoveryPolicy": { + "enum": [ + "wake_owner", + "create_recovery_issue", + "escalate_to_board", + null + ], + "type": [ + "string", + "null" + ] + }, + "serviceName": { + "maxLength": 120, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "timeoutAt": { + "description": "Optional deadline later than nextCheckAt.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "nextCheckAt", + "notes" + ], + "type": [ + "object", + "null" + ] + }, + "taskId": { + "description": "Owned task UUID. Omit for the active task.", + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "monitor" + ], + "type": "object" + }, + "operationId": "set_task_monitor", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Set task monitor", + "version": 1 + }, { "allowedModes": [ "standard", diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index 14e82754fa8..d1c2c1ea403 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -24,7 +24,7 @@ "prpVersion": 1, "nativeExecutionVersion": 1, "catalogVersion": 1, - "catalogSha256": "sha256:c1d5a0b1008f9eb582aa91acf64804ef0dbb272a3117ac1a58ad22c91b7a1cb4", + "catalogSha256": "sha256:4d29040d9eb7d983363340147ff8f57b48c9293d344023386fc3e2c90740847d", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 58c18614451..744a31a550c 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -160,7 +160,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "38a46fbb0d8721db2a8c10d58344d861d4e5970c7db165bfa409f5f7b07a058b", + "sha256": "9c86d80bf994ac888ea27346b4e074811349d97fa46842404fc7ca1391473f07", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 3992fecc00f..85b114b132d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -538,11 +538,13 @@ fn admit_terminal_tool_authority( "paperclip_finish" => { matches!(disposition.as_str(), "done" | "needs_review") || (disposition == "yielded" - && input - .get("continuation") - .and_then(|continuation| continuation.get("kind")) - .and_then(Value::as_str) - == Some("response_wake")) + && matches!( + input + .get("continuation") + .and_then(|continuation| continuation.get("kind")) + .and_then(Value::as_str), + Some("response_wake" | "monitor") + )) } "paperclip_block" => disposition == "blocked", _ => false, @@ -4900,8 +4902,9 @@ mod tests { assert_eq!(finish_result.result["error"]["code"], "invalid_tool_call"); assert_eq!(finish_result.result["error"]["retryable"], false); let finish_message = finish_result.result["error"]["message"].as_str().unwrap(); - assert!(finish_message - .contains("continuation must include kind=response_wake, summary, and idempotencyKey")); + assert!(finish_message.contains( + "continuation must include kind=response_wake or monitor, summary, and idempotencyKey" + )); assert!(finish_message.contains("/required (missing \"requiredField\")")); assert!(finish_message.contains("/additionalProperties")); assert!(!finish_message.contains("secretSubmittedValue")); @@ -5168,6 +5171,25 @@ mod tests { assert!(state.validate().is_ok()); } + #[test] + fn accepted_terminal_tool_preserves_an_explicit_monitor_wait() { + let mut state = opencode_result_state(); + let mut result = valid_opencode_result(); + result["reportedWorkDisposition"] = json!("yielded"); + result["continuation"] = json!({ + "kind": "monitor", + "summary": "Wait for the scheduled check.", + "idempotencyKey": "monitor-1" + }); + + admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap(); + let terminal = terminal_events(&state, "turn.completed", None); + + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0].payload["reportedWorkDisposition"], "yielded"); + assert!(state.validate().is_ok()); + } + #[test] fn terminal_tool_authority_rejects_an_unbound_yield() { let mut state = opencode_result_state(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs index aa6bdc3ee7d..cbb991f8a3b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs @@ -39,7 +39,7 @@ const MAX_SETTLED_CALL_IDS: usize = 65_536; const REPLAY_FILTER_WORDS: usize = 32_768; const ACTIVE_TURN_RECEIPT_LIMIT_MESSAGE: &str = "durable provider tool receipt limit reached for the active turn"; -const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake, summary, and idempotencyKey."; +const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake or monitor, summary, and idempotencyKey."; const QUESTION_INPUT_SCHEMA_HINT: &str = "Invalid request_human_input arguments. Required fields: idempotencyKey, interactionKind, title, prompt, and continuationPolicy. For questions, use payload.version=1 and a complete payload.questionSet matching the authorized schema. Preserve the supplied question and option IDs."; const BLOCK_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_block arguments. Required fields: reportedWorkDisposition=blocked, summary, completionClaim, evidence, verification, and blocker. blocker must include reasonCode, owner, unblockAction, and scope."; diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index 55e10f70871..e61e64a4b37 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -7,7 +7,7 @@ "src/scenarios/scenario-plan.ts" ], "counts": { - "actions": 56, + "actions": 57, "legacyRequirements": 106 }, "actions": [ @@ -30,6 +30,25 @@ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" ] }, + { + "id": "set_task_monitor", + "ownership": "optional_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:set_task_monitor", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:set_task_monitor" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, { "id": "submit_complaint", "ownership": "optional_agent_tool", diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json index c69990a5570..24cadcb043a 100644 --- a/packages/paperclip-runner/spec/operation-groups/source.json +++ b/packages/paperclip-runner/spec/operation-groups/source.json @@ -83,7 +83,7 @@ { "id": "wake_scheduling", "description": "Schedule a bounded continuation wake when the current task owns the future check.", - "operationIds": ["schedule_wake"] + "operationIds": ["schedule_wake", "set_task_monitor"] }, { "id": "routines", @@ -305,12 +305,12 @@ "legacyGroup": 16, "name": "Wake situations", "owner": "control plane + always context/history tools", - "operationIds": ["get_task_context", "get_task_history", "schedule_wake"], + "operationIds": ["get_task_context", "get_task_history", "schedule_wake", "set_task_monitor"], "controlPlaneOperationIds": ["select_work", "route_wake"], "realSurface": "wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services", "mockStateDomains": ["wake", "task", "comments", "interactions", "approvals", "blockers", "run"], "prpEvidence": "attention request routing/resolution plus resumed session/run causality", - "gap": "Production scheduling binding is unbound; control-plane routing remains non-callable." + "gap": "set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable." } ], "rules": { diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md index f08917eab59..e9096b92314 100644 --- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md +++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md @@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface. This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another. -The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **56 reconciled semantic operations** (18 always, 38 optional), and **106 scenarios in 16 behavior groups**. +The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **57 reconciled semantic operations** (18 always, 39 optional), and **106 scenarios in 16 behavior groups**. ## Axis 1: PRP v1 event and command families @@ -87,7 +87,7 @@ Placement has exactly three outcomes: `answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`. -### Optional operations (38) and grant groups (14) +### Optional operations (39) and grant groups (14) Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive. @@ -100,7 +100,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o | `governance` | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `governance:approvals:comment`
`governance:approvals:decide`
`governance:approvals:read`
`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. | | `cases` | `list_cases`
`upsert_case` | `cases:read`
`cases:write` | Read and update case summaries without reusing issue-document authority. | | `workspace_runtime` | `get_workspace_runtime`
`control_workspace_service` | `workspace:control`
`workspace:read` | Inspect and control the active issue workspace runtime. | -| `wake_scheduling` | `schedule_wake` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. | +| `wake_scheduling` | `schedule_wake`
`set_task_monitor` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. | | `routines` | `list_routines`
`manage_routine` | `routines:read`
`routines:write` | Inspect or manage company routines. | | `company_skills` | `create_skill`
`update_skill`
`list_company_skills`
`sync_company_skills` | `company_skills:read`
`company_skills:write` | Create, update, or inspect company skills and synchronize the current agent's skills. | | `secrets` | `list_secret_metadata`
`read_secret_value` | `secrets:metadata:read`
`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. | @@ -162,6 +162,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o | `search_api` | `optional_agent_tool` | `api:discover` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` | | `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`
`live_codex` | `issues.update.blockedByIssueIds`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` | +| `set_task_monitor` | `optional_agent_tool` | none | `standard` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `prepareIssueMonitorUpdate`
Transactional persisted issue monitor, audit activity and idempotent run receipt.
catalog PRP status: `bound` | | `set_task_title` | `optional_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `setIssueTitle`
Run-bound title update and transactional activity record.
catalog PRP status: `bound` | | `submit_complaint` | `optional_agent_tool` | none | `standard`
`ask`
`planning` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `submitAgentCommentary`
Run-bound local commentary and transactional activity record.
catalog PRP status: `bound` | | `submit_suggestion` | `optional_agent_tool` | none | `standard`
`ask`
`planning` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `submitAgentCommentary`
Run-bound local commentary and transactional activity record.
catalog PRP status: `bound` | @@ -194,7 +195,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t | [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`
`upsert_case`
`list_routines`
`manage_routine`
`create_skill`
`update_skill`
`list_company_skills`
`sync_company_skills`
`list_secret_metadata`
`read_secret_value`
`export_company`
`administer_company`
`generic_api_request`
`search_api`
`call_api`
`create_project`
`read_agent_instructions`
`update_agent_instructions`
`get_agent_instruction_history`
`restore_agent_instructions`
`submit_complaint`
`submit_suggestion` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services, and run-attributed agent commentary | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. Feedback actions are live-only: shared persistence, authority, API, and actual Codex smoke tests cover them; the legacy scenario count does not claim feedback coverage. | | [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`
`set_dependencies`
`request_human_input`
`request_approval`
`register_deliverable` | `route_wake`
`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`
`blockers`
`interactions`
`approvals`
`artifacts`
`wake`
`run`
`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. | | [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`
`read_secret_value`
`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`
`task`
`budget`
`secrets`
`audit`
`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. | -| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`
`get_task_history`
`schedule_wake` | `select_work`
`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`
`task`
`comments`
`interactions`
`approvals`
`blockers`
`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. | +| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`
`get_task_history`
`schedule_wake`
`set_task_monitor` | `select_work`
`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`
`task`
`comments`
`interactions`
`approvals`
`blockers`
`run` | 8 | attention request routing/resolution plus resumed session/run causality | set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable. | ### Scenario links @@ -468,10 +469,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston ### Catalog split and deliberate replacement - Scenario/eval catalog: **40** operations. -- Live dispatcher catalog: **44** operations. -- Shared: **28**; union/canonical authority: **56**. +- Live dispatcher catalog: **45** operations. +- Shared: **28**; union/canonical authority: **57**. - Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`. -- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`. +- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`. - The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision. - `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage. diff --git a/packages/paperclip-runner/src/backends/runtime-context.test.ts b/packages/paperclip-runner/src/backends/runtime-context.test.ts index 2fc3ac47260..af92ff7c405 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.test.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.test.ts @@ -76,7 +76,8 @@ describe("native runtime context files", () => { } expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("do not claim completion while gated"); expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("supplied link and action"); - expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("explicit wait for the next response"); + expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("yielded with response_wake for a response"); + expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("monitor after set_task_monitor confirms a schedule on this task"); expect(PRP_BLOCK_TOOL_DESCRIPTION).toContain("its owner, and the action needed to unblock it"); expect(constraints).not.toContain( "final response exactly once before invoking", diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 4ae8029b3bf..923fc036b33 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("pins the reconciled op-set relationship between the two catalogs", () => { const summary = capabilityCatalogReconciliation(); expect(summary.scenarioCount).toBe(40); - expect(summary.liveCount).toBe(44); + expect(summary.liveCount).toBe(45); expect(summary.sharedCount).toBe(28); - expect(summary.unionCount).toBe(56); + expect(summary.unionCount).toBe(57); // Any operation added to or removed from either catalog without a // reconciliation decision changes these exact sets and fails the gate. expect(summary.liveOnly).toEqual([ @@ -38,6 +38,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { "restore_agent_instructions", "schedule_wake", "search_api", + "set_task_monitor", "set_task_title", "submit_complaint", "submit_suggestion", @@ -60,7 +61,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("is the single source both catalogs derive their operation set from", () => { - expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(56); + expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(57); const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId)); // Neither catalog may contain an operation absent from the canonical source. for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); @@ -94,7 +95,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => { - expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(56); + expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(57); for (const operation of CAPABILITY_CANONICAL_CATALOG) { expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/); expect(Array.isArray(operation.requiredClaims)).toBe(true); @@ -120,7 +121,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("classifies real binding status so generic_api_request is never product coverage", () => { const summary = capabilityCatalogReconciliation(); expect(summary.byRealBindingStatus).toEqual({ - live_codex: 43, + live_codex: 44, scenario_mock: 12, test_only: 1, }); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index a313a9ab10f..df7307a078b 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -69,7 +69,8 @@ describe("semantic action catalog", () => { (action) => action.operationId, ); - expect(operationIds).toHaveLength(36); + expect(operationIds).toHaveLength(37); + expect(operationIds).toContain("set_task_monitor"); expect(new Set(operationIds).size).toBe(operationIds.length); expect(operationIds).not.toContain("generic_api_request"); expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts index cc7c2848662..6f4480a3385 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -1,3 +1,4 @@ +import { setTaskMonitorInputSchema } from "../protocol-actions/set-task-monitor.js"; import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js"; import { setTaskTitleAction } from "../protocol-actions/set-task-title.js"; import { reassignTaskAction } from "../protocol-actions/reassign-task.js"; @@ -583,6 +584,12 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ ), outputSchema: operationReceipt, }), + descriptor({ + operationId: "set_task_monitor", title: "Set task monitor", + description: "Schedule, replace or clear a persisted one-shot monitor on an owned task.", + placement: "optional", effect: "write", requiredClaims: [], allowedModes: ["standard"], + inputSchema: setTaskMonitorInputSchema, outputSchema: openObject, + }), descriptor({ operationId: "schedule_wake", title: "Schedule bounded wake", diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts index 0dd1f9c38ec..5f2f1bfef07 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts @@ -2,6 +2,7 @@ export type PaperclipSemanticActionId = | "search_api" | "call_api" | "set_task_title" + | "set_task_monitor" | "get_task_context" | "get_task_history" | "list_documents" diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts index 796791e904a..b6a01818187 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.test.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -31,6 +31,17 @@ describe("provider-neutral completion result schema", () => { const validate = new Ajv2020({ allErrors: true, strict: false }) .compile(PRP_COMPLETION_RESULT_OUTPUT_SCHEMA); + it("accepts an explicit monitor wait across provider and normalized schemas", () => { + const report = { ...baseResult, reportedWorkDisposition: "yielded", + completionClaim: { ...baseResult.completionClaim, objectiveSatisfied: false, + remainingWork: [{ description: "Check the next run", blocksCompletion: true }] }, + continuation: { kind: "monitor", summary: "Wait for persisted timer", idempotencyKey: "monitor-wait" } }; + expect(validate(report)).toBe(true); + const providerValidate = new Ajv2020({ allErrors: true, strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + expect(providerValidate(report)).toBe(true); + expect(validate({ ...report, continuation: { ...report.continuation, kind: "invented" } })).toBe(false); + }); + it("allows done with no verification and no actionable attention", () => { expect(validate(structuredClone(baseResult))).toBe(true); }); diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts index 87d8a34a29b..781bdd85c63 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -3,7 +3,7 @@ export const PRP_COMPLETION_TOOL_NAME = "paperclip_finish" as const; export const PRP_BLOCK_TOOL_NAME = "paperclip_block" as const; /** Native Runner guidance; legacy adapters use their own skill/API completion paths. */ export const PRP_COMPLETION_TOOL_DESCRIPTION = - "Report completed work (done), work requiring review (needs_review), or an explicit wait for the next response (yielded with response_wake). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls."; + "Report completed work (done), work requiring review (needs_review), or an explicit wait (yielded with response_wake for a response, or monitor after set_task_monitor confirms a schedule on this task). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls."; export const PRP_BLOCK_TOOL_DESCRIPTION = "Report work that cannot continue because of a concrete blocker. Identify the blocker, its owner, and the action needed to unblock it; use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome, explain the blocker and any required action in the final response, and end the turn without further tool calls."; export const PRP_SEMANTIC_TOOL_NAMES = [ @@ -144,14 +144,14 @@ const artifactsSchema = { }, } as const; -const responseWakeContinuationSchema = { +const waitContinuationSchema = { type: "object", description: - "Required when reportedWorkDisposition is yielded. Wait for the next response without scheduling work; include kind, summary, and a stable idempotencyKey.", + "Required when reportedWorkDisposition is yielded. Use response_wake for a response wait, or monitor only after a real monitor is persisted on the current task. Include kind, summary, and a stable idempotencyKey.", additionalProperties: false, required: ["kind", "summary", "idempotencyKey"], properties: { - kind: { type: "string", const: "response_wake" }, + kind: { type: "string", enum: ["response_wake", "monitor"] }, summary: { type: "string", minLength: 1, @@ -192,7 +192,7 @@ export const PRP_COMPLETION_RESULT_OUTPUT_SCHEMA = { properties: { ...commonResultProperties, reportedWorkDisposition: { enum: ["done", "needs_review", "yielded"] }, - continuation: responseWakeContinuationSchema, + continuation: waitContinuationSchema, }, allOf: [ { @@ -367,10 +367,10 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { // tool calls. Give non-yielding results an explicit absence value instead // of forcing callers to invent a response-wake continuation. continuation: { - ...responseWakeContinuationSchema, + ...waitContinuationSchema, type: ["object", "null"], description: - "Use null or omit this field for done, completed, or needs_review. Only yielded requires a response-wake object with kind, summary, and idempotencyKey.", + "Use null or omit this field for done, completed, or needs_review. Only yielded requires a wait object with kind (response_wake or monitor), summary, and idempotencyKey.", }, }, // Keep the provider-facing root a concrete object. Codex code-mode renders a diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 850825c29b1..fd17acf09f1 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const; -export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v5" as const; -export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const; +export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v6" as const; +export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. For a deferred check you own, call set_task_monitor with a future nextCheckAt and notes. Confirm the persisted schedule on the current task, then call paperclip_finish with reportedWorkDisposition yielded and continuation.kind monitor. End the turn; the one-shot monitor wakes you with issue_monitor_due. Re-arm explicitly only if another check is needed. Do not claim a monitor exists without its receipt. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const; export interface NativeRuntimeAssetReference { schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts index 16d5b011665..4e5c07a4c14 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts @@ -264,7 +264,8 @@ export function codexToolAcceptsResult( return false; } return result.reportedWorkDisposition !== "yielded" - || result.continuation?.kind === "response_wake"; + || result.continuation?.kind === "response_wake" + || result.continuation?.kind === "monitor"; } export function redactCodexValue(value: unknown, depth = 0): unknown { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts index e23b9f942a1..73b4bd6dddb 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts @@ -205,7 +205,7 @@ async function handleServerRequestBody( text: tool === CODEX_BLOCK_TOOL_NAME ? "paperclip_block requires reportedWorkDisposition=blocked." - : "paperclip_finish accepts done, needs_review, or yielded with a response_wake continuation.", + : "paperclip_finish accepts done, needs_review, or yielded with a response_wake or persisted monitor continuation.", }, ], }; diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts index 8a57f2509be..c90118fe819 100644 --- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts +++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts @@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => { candidateFailures: 36, }); expect(report.coverage).toMatchObject({ - canonicalOperations: 56, + canonicalOperations: 57, capabilityCases: 106, workflows: 12, stressFindings: 44, stressExclusions: 1, }); - expect(report.coverage.operations).toHaveLength(56); + expect(report.coverage.operations).toHaveLength(57); expect(report.coverage.composedWorkflows).toHaveLength(12); expect( report.coverage.operations.find( @@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => { )?.workflowIds.length, ).toBeGreaterThan(0); expect(renderRunnerWorkflowMarkdown(report)).toContain( - "56 operations · 106 capability cases · 12 workflows", + "57 operations · 106 capability cases · 12 workflows", ); expect(renderRunnerWorkflowJUnit(report)).toContain( 'tests="36" failures="36" skipped="0"', diff --git a/packages/paperclip-runner/src/mock-core/codex-runner.ts b/packages/paperclip-runner/src/mock-core/codex-runner.ts index d2c6b078e58..25d3b8195ff 100644 --- a/packages/paperclip-runner/src/mock-core/codex-runner.ts +++ b/packages/paperclip-runner/src/mock-core/codex-runner.ts @@ -114,12 +114,12 @@ function dispositionIssues( } else if (result.reportedWorkDisposition === "yielded") { if ( result.blocker !== undefined || - result.continuation?.kind !== "response_wake" + !["response_wake", "monitor"].includes(result.continuation?.kind ?? "") ) { issues.push({ code: "invalid_disposition", path: "/reportedWorkDisposition", - message: "yielded requires a response_wake continuation and must not include a blocker", + message: "yielded requires a response_wake or monitor continuation and must not include a blocker", }); } } else { diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index 35a003c5526..1c5b3083016 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -1,3 +1,4 @@ +import { setTaskMonitorAction } from "./set-task-monitor.js"; import { setTaskTitleAction } from "./set-task-title.js"; import { submitComplaintAction, submitSuggestionAction } from "./submit-agent-commentary.js"; import { readAgentInstructionsAction } from "./read-agent-instructions.js"; @@ -57,6 +58,7 @@ import { deepFreezeProtocolAction } from "./freeze.js"; export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([ setTaskTitleAction, + setTaskMonitorAction, submitComplaintAction, submitSuggestionAction, readAgentInstructionsAction, diff --git a/packages/paperclip-runner/src/protocol-actions/set-task-monitor.ts b/packages/paperclip-runner/src/protocol-actions/set-task-monitor.ts new file mode 100644 index 00000000000..a62dcb97227 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/set-task-monitor.ts @@ -0,0 +1,55 @@ +/** Production monitor tool; timers and wake delivery belong to Paperclip. */ +const description = "Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait."; + +export const setTaskMonitorInputSchema = { + type: "object", + additionalProperties: false, + required: ["idempotencyKey", "monitor"], + properties: { + taskId: { type: "string", description: "Owned task UUID. Omit for the active task." }, + idempotencyKey: { type: "string", minLength: 1, maxLength: 240, description: "Reuse with identical arguments on retries." }, + monitor: { + type: ["object", "null"], + additionalProperties: false, + required: ["nextCheckAt", "notes"], + properties: { + nextCheckAt: { type: "string", description: "Future UTC timestamp for the next check. This is one shot, not a recurring interval." }, + notes: { type: "string", minLength: 1, maxLength: 500, description: "What to check on the next run; do not include secrets." }, + kind: { type: ["string", "null"], enum: ["external_service", null] }, + serviceName: { type: ["string", "null"], minLength: 1, maxLength: 120 }, + externalRef: { type: ["string", "null"], minLength: 1, maxLength: 500 }, + timeoutAt: { type: ["string", "null"], description: "Optional deadline later than nextCheckAt." }, + maxAttempts: { type: ["integer", "null"], minimum: 1, maximum: 100, description: "Optional cumulative attempt limit for this task's monitor." }, + recoveryPolicy: { type: ["string", "null"], enum: ["wake_owner", "create_recovery_issue", "escalate_to_board", null] }, + }, + }, + }, +} as const; + +export const setTaskMonitorAction = { + id: "set_task_monitor", + canonical: { + operationId: "set_task_monitor", surfaces: ["live"], placement: "optional_agent_tool", + optionalGroup: "wake_scheduling", requiredClaims: [], taskModes: ["standard"], + sideEffectClass: "task_write", idempotency: "required", disabledByDefault: false, + realBindingStatus: "live_codex", realServiceBinding: "prepareIssueMonitorUpdate", + prpEvidence: "Transactional persisted issue monitor, audit activity and idempotent run receipt.", + prpBindingStatus: "bound", legacyAliases: [], + }, + documentation: { title: "Set task monitor", description, note: null }, + examples: { + call: { operationId: "set_task_monitor", input: { idempotencyKey: "check-ci", monitor: { nextCheckAt: "2030-01-01T12:00:00Z", notes: "Check the pending CI run." } } }, + scenarioCall: null, + success: { ok: true, operationId: "set_task_monitor", result: { taskId: "task-1", status: "in_progress", monitor: { nextCheckAt: "2030-01-01T12:00:00Z", status: "scheduled" }, replayed: false } }, + }, + live: { + order: 51, + descriptor: { + schema: "paperclip.semantic-tool.v1", operationId: "set_task_monitor", version: 1, + title: "Set task monitor", description, exposure: "optional", requiredClaims: [], + allowedModes: ["standard"], inputSchema: setTaskMonitorInputSchema, + outputSchema: { type: "object", additionalProperties: true }, + }, + }, + scenario: null, +} as const; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index 9ad3907a154..8877d2c765e 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -21,6 +21,7 @@ const MAX_DISCOVERY_RESULTS = 10; const NAMESPACE: Readonly> = Object.freeze({ set_task_title: "active_task", + set_task_monitor: "continuation", search_api: "api_fallback", call_api: "api_fallback", get_task_context: "active_task", get_task_history: "active_task", diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts index 1388697928e..491ac22e731 100644 --- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts @@ -13,6 +13,7 @@ import type { const NAMESPACE: Readonly> = Object.freeze({ set_task_title: "active_task", + set_task_monitor: "continuation", search_api: "api_fallback", call_api: "api_fallback", get_task_context: "active_task", diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index 85d6829cb01..7dbc66243f6 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -113,7 +113,8 @@ describe("Capability semantic catalog and authorization", () => { it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => { const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId); expect(new Set(names).size).toBe(names.length); - expect(names).toHaveLength(44); + expect(names).toHaveLength(45); + expect(names).toContain("set_task_monitor"); expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"])); expect(names).toContain("get_task_context"); expect(names).toContain("finish_task"); diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index 5c1d0a7e66f..28a5b1988de 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -19,6 +19,7 @@ export type CapabilitySemanticOperationId = | "search_api" | "call_api" | "set_task_title" + | "set_task_monitor" | "get_task_context" | "get_task_history" | "list_documents" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2d2f71d4d23..232f447caa0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1989,6 +1989,7 @@ export { updateIssueSchema, stalledReviewDecisionSchema, issueExecutionPolicySchema, + issueExecutionMonitorPolicySchema, issueExecutionStateSchema, resolveIssueRecoveryActionSchema, retryWorkspaceExportSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 0634cf27391..2b621a7f9c8 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -435,6 +435,7 @@ export { updateIssueSchema, stalledReviewDecisionSchema, issueExecutionPolicySchema, + issueExecutionMonitorPolicySchema, issueExecutionStateSchema, issueRecoveryActionReadModelSchema, resolveIssueRecoveryActionSchema, diff --git a/server/scripts/smoke-native-task-monitor.ts b/server/scripts/smoke-native-task-monitor.ts new file mode 100644 index 00000000000..76d327bd939 --- /dev/null +++ b/server/scripts/smoke-native-task-monitor.ts @@ -0,0 +1,98 @@ +/** Opt-in live qualification: creates an isolated database/workspace and spends two Codex turns. + * PATH=::$PATH node --import tsx server/scripts/smoke-native-task-monitor.ts --run + */ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { and, eq } from "drizzle-orm"; + +if (!process.argv.includes("--run")) throw new Error("Pass --run to authorize the live two-turn Codex smoke."); +const root = await mkdtemp(join(tmpdir(), "paperclip-live-task-monitor-")); +process.env.PAPERCLIP_HOME = join(root, "paperclip"); +process.env.PAPERCLIP_INSTANCE_ID = "monitor-smoke"; +process.env.PAPERCLIP_TELEMETRY_ENABLED = "false"; +const { createDb, companies, agents, authUsers, companyMemberships, issues, heartbeatRuns, agentWakeupRequests, nativeRunResults, statusDecisions } = await import("@paperclipai/db"); +const { startEmbeddedPostgresTestDatabase } = await import("../src/__tests__/helpers/embedded-postgres.js"); +const { heartbeatService } = await import("../src/services/heartbeat.js"); +const { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } = await import("../src/realtime/runner-prp-ws.js"); +const { closeIdleWarmNativeSessionsForRestart } = await import("../src/services/native-runtime/native-session-executor.js"); +const temporary = await startEmbeddedPostgresTestDatabase("live-task-monitor-"); +const db = createDb(temporary.connectionString); +const server = createServer(); +await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +if (!address || typeof address === "string") throw new Error("TCP listener missing"); +process.env.PAPERCLIP_API_URL = `http://127.0.0.1:${address.port}`; +setupRunnerPrpWebSocketServer(server, { apiUrl: process.env.PAPERCLIP_API_URL }); +const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); +const heartbeat = heartbeatService(db); +const readRuns = () => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)).orderBy(heartbeatRuns.createdAt); +console.log(JSON.stringify({ root, companyId, agentId, issueId })); +try { + const cwd = join(root, "workspace"); + await mkdir(cwd); + await db.insert(companies).values({ id: companyId, name: "Isolated monitor smoke", issuePrefix: "MON", defaultResponsibleUserId: "monitor-smoke", requireBoardApprovalForNewAgents: false }); + await db.insert(authUsers).values({ id: "monitor-smoke", name: "Monitor smoke", email: "monitor-smoke@example.test", createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "monitor-smoke", status: "active", membershipRole: "owner" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Monitor verifier", status: "active", adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", cwd, lifecycleMode: "warm", idleTimeoutMs: 300_000, timeoutSeconds: 180 }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } }); + await db.insert(issues).values({ id: issueId, companyId, title: "Verify a one-shot native task monitor", identifier: "MON-1", status: "in_progress", assigneeAgentId: agentId, + description: "This is an isolated two-run integration test. First run: use set_task_monitor on this task with a fresh nextCheckAt about 45 seconds in the future (compute the current UTC time if needed), notes saying to verify issue_monitor_due, and idempotencyKey monitor-live-first. Confirm the receipt. Then call paperclip_finish with yielded and continuation.kind monitor, accurately retaining the second run as blocking remaining work. End immediately after acceptance. Do not sleep or poll. Second run: if the wake reason is issue_monitor_due, the requested check succeeded. Report that reason and finish done using the current completion contract. Do not schedule another monitor or request human review. No files or other deliverables are required." }); + await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", reason: "monitor_smoke", + requestedByActorType: "system", requestedByActorId: "monitor-smoke", payload: { issueId }, contextSnapshot: { issueId, wakeReason: "monitor_smoke" } }); + const deadline = Date.now() + 8 * 60_000; + let firstReleasedAt: number | null = null; + while (Date.now() < deadline) { + const runs = await readRuns(); + if (runs.some(run => ["failed", "timed_out", "cancelled"].includes(run.status))) { + throw new Error(`Live run failed: ${JSON.stringify(runs.map(run => ({ id: run.id, status: run.status, error: run.error, errorCode: run.errorCode })))}`); + } + if (runs.length === 1 && runs[0].status === "succeeded") { + firstReleasedAt ??= Date.now(); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + assert.equal(issue.status, "in_progress"); + assert.ok(issue.monitorNextCheckAt, "first run must leave a persisted monitor"); + assert.equal(issue.executionRunId, null); + } + if (runs.length >= 2 && runs[1].status === "succeeded") break; + await heartbeat.tickTimers(); + await delay(1_000); + } + await heartbeat.drainActiveRunExecutions(); + const runs = await readRuns(); + assert.equal(runs.length, 2, "exactly two server runs must execute"); + assert.ok(runs.every(run => run.status === "succeeded")); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + const wakes = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_monitor_due"))); + assert.equal(wakes.length, 1); + assert.equal(runs[1].contextSnapshot?.wakeReason, "issue_monitor_due"); + assert.equal(issue.status, "done"); + assert.equal(issue.monitorNextCheckAt, null); + assert.ok(firstReleasedAt && runs[1].createdAt.getTime() - firstReleasedAt < 300_000, "wake must arrive within the configured warm window"); + const results = await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, companyId)); + const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.companyId, companyId)); + const evidence = { companyId, issueId, agentId, configuredIdleTimeoutMs: 300_000, firstReleasedAt, status: issue.status, + runs: runs.map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt, finishedAt: run.finishedAt, + wakeReason: run.contextSnapshot?.wakeReason, nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId, + processPid: run.processPid, processStartedAt: run.processStartedAt, providerSessionId: run.sessionIdAfter, + semanticToolReceipts: run.resultJson?.semanticToolReceipts })), + warmReuse: { sameNativeSession: runs[0].nativeSessionId === runs[1].nativeSessionId, + sameRunnerInstance: runs[0].runnerInstanceId === runs[1].runnerInstanceId, + sameProcess: runs[0].processPid != null && runs[0].processPid === runs[1].processPid && runs[0].processStartedAt?.getTime() === runs[1].processStartedAt?.getTime() }, + results: results.map(row => ({ runId: row.runId, result: row.resultJson })), + decisions: decisions.map(row => ({ runId: row.runId, reasonCode: row.reasonCode, toStatus: row.toStatus })), + monitorWakeIds: wakes.map(row => row.id) }; + await writeFile(join(root, "evidence.json"), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ passed: true, evidence: join(root, "evidence.json"), warmReuse: evidence.warmReuse })); +} finally { + await writeFile(join(root, "run-records.json"), JSON.stringify(await readRuns(), null, 2)); + await closeIdleWarmNativeSessionsForRestart(); + runnerPrpWebSocketInternals.resetForTests(); + await new Promise(resolve => server.close(() => resolve())); + await temporary.cleanup(); +} diff --git a/server/src/__tests__/issue-monitor-scheduler.test.ts b/server/src/__tests__/issue-monitor-scheduler.test.ts index 13389f73037..48a88c84259 100644 --- a/server/src/__tests__/issue-monitor-scheduler.test.ts +++ b/server/src/__tests__/issue-monitor-scheduler.test.ts @@ -253,6 +253,9 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { const { issueId, agentId } = await seedFixture(); const heartbeat = heartbeatService(db); const tickAt = new Date("2026-04-11T12:31:00.000Z"); + const [before] = await db.select().from(issues).where(eq(issues.id, issueId)); + const unrelatedPolicy = { commentRequired: false, futureAuthorization: { preserve: true } }; + await db.update(issues).set({ executionPolicy: { ...before.executionPolicy, ...unrelatedPolicy } }).where(eq(issues.id, issueId)); const result = await heartbeat.tickTimers(tickAt); @@ -260,6 +263,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!); expect(issue.monitorNextCheckAt).toBeNull(); + expect(issue.executionPolicy).toMatchObject(unrelatedPolicy); expect(issue.monitorAttemptCount).toBe(1); expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString()); expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull(); @@ -284,6 +288,89 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { expect(activity).toContain("issue.monitor_triggered"); }); + it("preserves a due monitor through native execution and dispatches once after release", async () => { + const { companyId, issueId, agentId, nextCheckAt } = await seedFixture(); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, + runtimeMode: "native", status: "running", contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + const heartbeat = heartbeatService(db); + expect((await heartbeat.tickTimers(new Date("2026-04-11T12:31:00.000Z"))).enqueued).toBe(0); + const [waiting] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(waiting.monitorNextCheckAt).toEqual(nextCheckAt); + expect(waiting.monitorWakeRequestedAt).toBeNull(); + expect(await db.select().from(agentWakeupRequests)).toHaveLength(0); + await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId)); + await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, issueId)); + expect((await heartbeat.tickTimers(new Date("2026-04-11T12:32:00.000Z"))).enqueued).toBe(1); + expect((await heartbeat.tickTimers(new Date("2026-04-11T12:33:00.000Z"))).enqueued).toBe(0); + const [triggered] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(triggered.monitorNextCheckAt).toBeNull(); + expect(triggered.monitorAttemptCount).toBe(1); + expect((await db.select().from(agentWakeupRequests)).filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(1); + }); + + it.each(["replaced", "cleared", "reassigned", "completed"] as const)("fences a monitor %s after claim but before wake admission", async (change) => { + const { companyId, issueId, agentId } = await seedFixture(); + const replacementAt = new Date("2026-04-11T13:30:00.000Z"); + let raced = false; + const racingDb = new Proxy(db, { + get(target, property, receiver) { + if (property !== "transaction") return Reflect.get(target, property, receiver); + return async (callback: Parameters[0]) => { + const result = await db.transaction(callback); + const claimed = result as { id?: string; monitorWakeRequestedAt?: Date } | undefined; + if (!raced && claimed?.id === issueId && claimed.monitorWakeRequestedAt) { + raced = true; + await db.update(issues).set(change === "replaced" ? { + monitorNextCheckAt: replacementAt, monitorWakeRequestedAt: null, + executionPolicy: { monitor: { nextCheckAt: replacementAt.toISOString(), notes: "Replacement check", scheduledBy: "assignee" } }, + } : change === "cleared" ? { monitorNextCheckAt: null, monitorWakeRequestedAt: null, executionPolicy: null } + : change === "reassigned" ? { assigneeAgentId: null } : { status: "done" }) + .where(eq(issues.id, issueId)); + } + return result; + }; + }, + }); + expect((await heartbeatService(racingDb).tickTimers(new Date("2026-04-11T12:31:00.000Z"))).enqueued).toBe(0); + expect(raced).toBe(true); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))) + .filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(0); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(0); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + if (change === "replaced") expect(issue.monitorNextCheckAt).toEqual(replacementAt); + if (change === "cleared") expect(issue.monitorNextCheckAt).toBeNull(); + }); + + it("does not erase a replacement scheduled after wake admission", async () => { + const { companyId, issueId } = await seedFixture(); + const nextCheckAt = new Date("2026-04-11T13:30:00.000Z"); + let replaced = false; + const racingDb = new Proxy(db, { + get(target, property, receiver) { + if (property !== "transaction") return Reflect.get(target, property, receiver); + return async (callback: Parameters[0]) => { + const result = await db.transaction(callback); + if (!replaced && (result as { kind?: string } | undefined)?.kind === "queued") { + replaced = true; + await db.update(issues).set({ monitorNextCheckAt: nextCheckAt, monitorWakeRequestedAt: null, + executionPolicy: { monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "New check", scheduledBy: "assignee" } }, + }).where(eq(issues.id, issueId)); + } + return result; + }; + }, + }); + await heartbeatService(racingDb).tickTimers(new Date("2026-04-11T12:31:00.000Z")); + expect(replaced).toBe(true); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue.monitorNextCheckAt).toEqual(nextCheckAt); + expect(issue.monitorWakeRequestedAt).toBeNull(); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))) + .filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(1); + }); + it.each(["unknown", "exhausted"] as const)("does not replay a quota monitor with %s execution evidence", async (kind) => { const sourceRunId = randomUUID(); const { companyId, issueId, agentId } = await seedFixture({ diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 41fc05cae7c..246b4e19f92 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -2907,6 +2907,39 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.idempotencyKey, key))).toHaveLength(1); }); + it.each(["scheduled", "cleared", "replaced", "reassigned"] as const)("revalidates a %s monitor under the final disposition lock", async (state) => { + const template = corpus.fixtures.find(candidate => candidate.mode === "native")!; + const fixture = { ...template, id: `monitor-commit-${state}`, given: { ...template.given, priorIssueStatus: "in_progress" } }; + const seeded = await seedFixture(fixture); + const nextCheckAt = new Date(Date.now() + 60_000).toISOString(); + const currentCheck = state === "replaced" ? new Date(Date.now() + 120_000).toISOString() : nextCheckAt; + await db.update(issues).set({ + executionRunId: seeded.runId, checkoutRunId: seeded.runId, + assigneeAgentId: state === "reassigned" ? null : agentId, + monitorNextCheckAt: state === "cleared" ? null : new Date(currentCheck), + executionPolicy: state === "cleared" ? null : { monitor: { nextCheckAt: currentCheck, notes: "Check again" } }, + }).where(eq(issues.id, seeded.issueId)); + const decision: NativeStatusDecision = { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, + statusAction: "preserve", toStatus: "in_progress", reasonCode: "scheduled_monitor_waiting", + unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }; + const commit = () => commitNativeStatusDecision({ db, companyId, issueId: seeded.issueId, runId: seeded.runId, + assessmentId: seeded.assessmentId, priorStatus: "in_progress", priorStatusVersion: 0, priorDecisionId: null, + decision, requireMonitorWait: { agentId, nextCheckAt } }); + if (state !== "scheduled") { + await expect(commit()).rejects.toThrow("native_status_race"); + expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).toHaveLength(0); + } else { + await commit(); + expect((await commit()).replayed).toBe(true); + const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); + expect(issue).toMatchObject({ status: "in_progress", checkoutRunId: null, executionRunId: null }); + expect(issue.monitorNextCheckAt?.toISOString()).toBe(nextCheckAt); + const effects = await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, seeded.issueId)); + expect(effects.map(effect => effect.effectKind)).toEqual(["release_checkout"]); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId))).filter(wake => wake.payload?.issueId === seeded.issueId)).toHaveLength(0); + } + }); + it("fails the transaction closed for an unknown status effect", async () => { const fixture = corpus.fixtures.find((candidate) => candidate.mode === "native"); if (!fixture) throw new Error("native corpus fixture missing"); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index dbd430dbc81..bf3fcda0318 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,3 +1,4 @@ +import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js"; import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js"; @@ -2170,106 +2171,6 @@ function summarizeIssueReferenceActivityDetails( }; } -function monitorPoliciesEqual( - left: NormalizedExecutionPolicy | null, - right: NormalizedExecutionPolicy | null, -) { - return ( - JSON.stringify(left?.monitor ?? null) === - JSON.stringify(right?.monitor ?? null) - ); -} - -function applyActorMonitorScheduledBy( - policy: NormalizedExecutionPolicy | null, - actorType: "agent" | "user", -) { - return setIssueExecutionPolicyMonitorScheduledBy( - policy, - actorType === "user" ? "board" : "assignee", - ); -} - -async function assertCanManageIssueMonitor( - accessSvc: ReturnType, - req: Request, - companyId: string, - assigneeAgentId: string | null, - monitorChanged: boolean, -) { - if (!monitorChanged) return; - if (req.actor.type === "board") return; - const runtimeDecision = await accessSvc.decide({ - actor: req.actor, - action: "runtime:manage", - resource: { type: "company", companyId }, - }); - if (!runtimeDecision.allowed) { - throw forbidden( - runtimeDecision.explanation, - authorizationDeniedDetails(runtimeDecision), - ); - } - if ( - req.actor.type === "agent" && - req.actor.agentId && - req.actor.agentId === assigneeAgentId - ) - return; - throw forbidden( - "Only the assignee agent or a board user can manage issue monitors", - ); -} - -function summarizeIssueMonitor( - issue: { - monitorNextCheckAt?: Date | null; - monitorLastTriggeredAt?: Date | null; - monitorAttemptCount?: number | null; - monitorNotes?: string | null; - monitorScheduledBy?: string | null; - executionState?: unknown; - }, - policy: NormalizedExecutionPolicy | null, -) { - const state = parseIssueExecutionState(issue.executionState); - return { - nextCheckAt: - issue.monitorNextCheckAt?.toISOString() ?? - policy?.monitor?.nextCheckAt ?? - null, - lastTriggeredAt: - issue.monitorLastTriggeredAt?.toISOString() ?? - state?.monitor?.lastTriggeredAt ?? - null, - attemptCount: - issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0, - notes: - policy?.monitor?.notes ?? - issue.monitorNotes ?? - state?.monitor?.notes ?? - null, - scheduledBy: - issue.monitorScheduledBy ?? - policy?.monitor?.scheduledBy ?? - state?.monitor?.scheduledBy ?? - null, - kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null, - serviceName: - policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null, - externalRef: redactIssueMonitorExternalRef( - policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null, - ), - timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null, - maxAttempts: - policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null, - recoveryPolicy: - policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null, - status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null), - clearReason: state?.monitor?.clearReason ?? null, - }; -} - function activityExecutionParticipantKey( participant: ActivityExecutionParticipant, ): string { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 9f9b697fe98..f406b9de39f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3786,6 +3786,8 @@ interface WakeupOptions { statuses: string[]; assigneeAgentId: string; statusVersion?: number; + monitorNextCheckAt?: string; + monitorWakeRequestedAt?: string; }; /** Keep causally distinct external chat continuations out of an existing run. */ allowRunCoalescing?: boolean; @@ -11661,18 +11663,19 @@ export function heartbeatService( runId: string | null; activitySource: "manual" | "scheduled"; }) { - await db + const cleared = await db .update(issues) .set({ - ...buildIssueMonitorClearedPatch({ + ...monitorOnlyDispatchPatch(buildIssueMonitorClearedPatch({ issue: input.claimed, policy: input.policy, clearReason: input.clearReason, clearedAt: input.now, - }), + })), updatedAt: input.now, }) - .where(eq(issues.id, input.claimed.id)); + .where(issueMonitorClaimCondition(input.claimed)).returning({ id: issues.id }); + if (cleared.length === 0) return { outcome: "skipped" as const, reason: "monitor_replaced" }; await logActivity(db, { companyId: input.claimed.companyId, @@ -11711,6 +11714,24 @@ export function heartbeatService( return { outcome: "skipped" as const, reason: input.clearReason }; } + function monitorOnlyDispatchPatch>(patch: T) { + // Admission and consumption are separate transactions. Preserve any review + // policy/state changes made between them; only the monitor belongs to us. + return { + ...patch, + executionPolicy: sql`nullif(${issues.executionPolicy} - 'monitor', '{}'::jsonb)`, + executionState: sql`jsonb_set(coalesce(${issues.executionState}, ${JSON.stringify(patch.executionState)}::jsonb), + '{monitor}', ${JSON.stringify(patch.executionState?.monitor ?? null)}::jsonb)`, + }; + } + + function issueMonitorClaimCondition(claimed: IssueMonitorDispatchRow) { + return and(eq(issues.id, claimed.id), eq(issues.companyId, claimed.companyId), + eq(issues.assigneeAgentId, claimed.assigneeAgentId!), isNull(issues.assigneeUserId), + eq(issues.status, claimed.status), eq(issues.monitorNextCheckAt, claimed.monitorNextCheckAt!), + eq(issues.monitorWakeRequestedAt, claimed.monitorWakeRequestedAt!)); + } + async function dispatchClaimedIssueMonitor( claimed: IssueMonitorDispatchRow, input: { @@ -11855,8 +11876,10 @@ export function heartbeatService( if (scheduled.outcome === "not_scheduled") throw conflict(scheduled.reason); } - } else - await enqueueWakeup(targetAgentId, { + } else { + const wake = await enqueueWakeup(targetAgentId, { + issueStateGuard: { statuses: [claimed.status], assigneeAgentId: claimed.assigneeAgentId, + monitorNextCheckAt: scheduledAtIso, monitorWakeRequestedAt: claimed.monitorWakeRequestedAt!.toISOString() }, source: input.source, triggerDetail: input.triggerDetail, reason: wakeReason, @@ -11886,18 +11909,26 @@ export function heartbeatService( manualTrigger: input.activitySource === "manual", }, }); + if (!wake) { + await db.update(issues).set({ monitorWakeRequestedAt: null, updatedAt: input.now }) + .where(issueMonitorClaimCondition(claimed)); + return { outcome: "skipped" as const, reason: "monitor_dispatch_deferred" }; + } + } - await db + const consumed = await db .update(issues) .set({ - ...buildIssueMonitorTriggeredPatch({ + ...monitorOnlyDispatchPatch(buildIssueMonitorTriggeredPatch({ issue: claimed, policy, triggeredAt: input.now, - }), + })), updatedAt: new Date(), }) - .where(eq(issues.id, claimed.id)); + .where(issueMonitorClaimCondition(claimed)) + .returning({ id: issues.id }); + if (consumed.length === 0) return { outcome: "skipped" as const, reason: "monitor_replaced" }; await logActivity(db, { companyId: claimed.companyId, @@ -11926,15 +11957,15 @@ export function heartbeatService( await db .update(issues) .set({ - ...buildIssueMonitorClearedPatch({ + ...monitorOnlyDispatchPatch(buildIssueMonitorClearedPatch({ issue: claimed, policy, clearReason: "dispatch_skipped", clearedAt: input.now, - }), + })), updatedAt: new Date(), }) - .where(eq(issues.id, claimed.id)); + .where(issueMonitorClaimCondition(claimed)); await logActivity(db, { companyId: claimed.companyId, @@ -11964,7 +11995,7 @@ export function heartbeatService( monitorWakeRequestedAt: null, updatedAt: new Date(), }) - .where(eq(issues.id, claimed.id)); + .where(issueMonitorClaimCondition(claimed)); } else { await db .update(issues) @@ -11972,13 +12003,21 @@ export function heartbeatService( monitorWakeRequestedAt: null, updatedAt: new Date(), }) - .where(eq(issues.id, claimed.id)); + .where(issueMonitorClaimCondition(claimed)); } throw err; } } + function noActiveNativeMonitorRun() { + return sql`not exists (select 1 from ${heartbeatRuns} monitor_run + where monitor_run.company_id = ${issues.companyId} + and monitor_run.native_issue_id = ${issues.id} + and monitor_run.runtime_mode = 'native' + and monitor_run.status in ('queued', 'running', 'scheduled_retry'))`; + } + async function triggerIssueMonitor( issueId: string, input?: { @@ -12071,6 +12110,7 @@ export function heartbeatService( .where( and( eq(companies.status, "active"), + noActiveNativeMonitorRun(), sql`${issues.monitorNextCheckAt} is not null`, lte(issues.monitorNextCheckAt, now), isNull(issues.assigneeUserId), @@ -12099,6 +12139,7 @@ export function heartbeatService( .where( and( eq(issues.id, due.id), + noActiveNativeMonitorRun(), sql`${issues.monitorNextCheckAt} is not null`, lte(issues.monitorNextCheckAt, now), isNull(issues.assigneeUserId), @@ -27839,6 +27880,9 @@ export function heartbeatService( executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: issues.executionWorkspaceSettings, assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + monitorNextCheckAt: issues.monitorNextCheckAt, + monitorWakeRequestedAt: issues.monitorWakeRequestedAt, executionRunId: issues.executionRunId, executionAgentNameKey: issues.executionAgentNameKey, createdAt: issues.createdAt, @@ -27870,6 +27914,54 @@ export function heartbeatService( return { kind: "skipped" as const }; } + const issueStateGuard = opts.issueStateGuard; + const activeMonitorRun = issueStateGuard?.monitorNextCheckAt === undefined ? null + : await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.nativeIssueId, issue.id), + eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]), + )).limit(1).then(rows => rows[0] ?? null); + if ( + issueStateGuard && + (!issueStateGuard.statuses.includes(issue.status) || + issue.assigneeAgentId !== issueStateGuard.assigneeAgentId || + (issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion) || + (issueStateGuard.monitorNextCheckAt !== undefined && ( + activeMonitorRun !== null || issue.assigneeUserId !== null || + issue.monitorNextCheckAt?.toISOString() !== issueStateGuard.monitorNextCheckAt || + issue.monitorWakeRequestedAt?.toISOString() !== issueStateGuard.monitorWakeRequestedAt + ))) + ) { + // A deferred monitor retains its schedule; do not create a receipt + // that could suppress its next admission attempt. + if (issueStateGuard.monitorNextCheckAt !== undefined) return { kind: "skipped" as const }; + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_state_guard_mismatch", + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: + "Issue status or assignee changed before the wake could be queued.", + issueId: issue.id, + expectedStatuses: issueStateGuard.statuses, + actualStatus: issue.status, + expectedAssigneeAgentId: issueStateGuard.assigneeAgentId, + actualAssigneeAgentId: issue.assigneeAgentId, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + if (opts.failedRunId) { // The issue lock makes double-clicks and network retries adopt the // same successor, including after it has already finished. @@ -28068,40 +28160,6 @@ export function heartbeatService( onBlocked: (reason, message) => { continuationWait = { reason, message }; }, }))) return deferBlockedExecution(executionBlocker); - const issueStateGuard = opts.issueStateGuard; - if ( - issueStateGuard && - (!issueStateGuard.statuses.includes(issue.status) || - issue.assigneeAgentId !== issueStateGuard.assigneeAgentId || - (issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion)) - ) { - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "issue_state_guard_mismatch", - payload: { - ...(payload ?? {}), - heartbeatSkip: { - reason: - "Issue status or assignee changed before the wake could be queued.", - issueId: issue.id, - expectedStatuses: issueStateGuard.statuses, - actualStatus: issue.status, - expectedAssigneeAgentId: issueStateGuard.assigneeAgentId, - actualAssigneeAgentId: issue.assigneeAgentId, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }); - return { kind: "skipped" as const }; - } if ( worktreeExecutionCutoff && diff --git a/server/src/services/issue-monitors.ts b/server/src/services/issue-monitors.ts new file mode 100644 index 00000000000..eede91c518e --- /dev/null +++ b/server/src/services/issue-monitors.ts @@ -0,0 +1,166 @@ +import { issueExecutionMonitorPolicySchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared"; +import type { issues } from "@paperclipai/db"; +import { z } from "zod"; +import { forbidden, unprocessable } from "../errors.js"; +import type { accessService } from "./access.js"; +import { authorizationDeniedDetails, type AuthorizationActor } from "./authorization.js"; +import { applyIssueMonitorPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, redactIssueMonitorExternalRef, setIssueExecutionPolicyMonitorScheduledBy } from "./issue-execution-policy.js"; + +type NormalizedExecutionPolicy = ReturnType; + +export function monitorPoliciesEqual( + left: NormalizedExecutionPolicy | null, + right: NormalizedExecutionPolicy | null, +) { + return ( + JSON.stringify(left?.monitor ?? null) === + JSON.stringify(right?.monitor ?? null) + ); +} + +export function applyActorMonitorScheduledBy( + policy: NormalizedExecutionPolicy | null, + actorType: "agent" | "user", +) { + return setIssueExecutionPolicyMonitorScheduledBy( + policy, + actorType === "user" ? "board" : "assignee", + ); +} + +export async function assertCanManageIssueMonitor( + accessSvc: Pick, "decide">, + req: { actor: AuthorizationActor }, + companyId: string, + assigneeAgentId: string | null, + monitorChanged: boolean, +) { + if (!monitorChanged) return; + if (req.actor.type === "board") return; + const runtimeDecision = await accessSvc.decide({ + actor: req.actor, + action: "runtime:manage", + resource: { type: "company", companyId }, + }); + if (!runtimeDecision.allowed) { + throw forbidden( + runtimeDecision.explanation, + authorizationDeniedDetails(runtimeDecision), + ); + } + if ( + req.actor.type === "agent" && + req.actor.agentId && + req.actor.agentId === assigneeAgentId + ) + return; + throw forbidden( + "Only the assignee agent or a board user can manage issue monitors", + ); +} + +export function summarizeIssueMonitor( + issue: { + monitorNextCheckAt?: Date | null; + monitorLastTriggeredAt?: Date | null; + monitorAttemptCount?: number | null; + monitorNotes?: string | null; + monitorScheduledBy?: string | null; + executionState?: unknown; + }, + policy: NormalizedExecutionPolicy | null, +) { + const state = parseIssueExecutionState(issue.executionState); + return { + nextCheckAt: + issue.monitorNextCheckAt?.toISOString() ?? + policy?.monitor?.nextCheckAt ?? + null, + lastTriggeredAt: + issue.monitorLastTriggeredAt?.toISOString() ?? + state?.monitor?.lastTriggeredAt ?? + null, + attemptCount: + issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0, + notes: + policy?.monitor?.notes ?? + issue.monitorNotes ?? + state?.monitor?.notes ?? + null, + scheduledBy: + issue.monitorScheduledBy ?? + policy?.monitor?.scheduledBy ?? + state?.monitor?.scheduledBy ?? + null, + kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null, + serviceName: + policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null, + externalRef: redactIssueMonitorExternalRef( + policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null, + ), + timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null, + maxAttempts: + policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null, + recoveryPolicy: + policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null, + status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null), + clearReason: state?.monitor?.clearReason ?? null, + }; +} + +export const setTaskMonitorSchema = z.object({ + taskId: z.string().uuid().optional(), + idempotencyKey: z.string().trim().min(1).max(240), + monitor: issueExecutionMonitorPolicySchema.omit({ scheduledBy: true }).extend({ + notes: z.string().trim().min(1).max(500), + }).strict().refine(monitor => monitor.serviceName !== PROVIDER_QUOTA_MONITOR_SERVICE_NAME, { + message: "This serviceName is reserved for server-owned quota recovery; use a different service name for an ordinary task check", + path: ["serviceName"], + }).nullable(), +}).strict(); + +/** Caller holds the issue lock; merge only monitor state, never review policy. */ +export function prepareIssueMonitorUpdate( + issue: typeof issues.$inferSelect, + monitor: z.infer["monitor"], + actor: AuthorizationActor, + now = new Date(), +) { + if (monitor && Date.parse(monitor.nextCheckAt) <= now.getTime()) { + throw unprocessable("Monitor nextCheckAt must be in the future"); + } + if (monitor?.timeoutAt && Date.parse(monitor.timeoutAt) <= Date.parse(monitor.nextCheckAt)) { + throw unprocessable("Monitor timeoutAt must be later than nextCheckAt"); + } + const previousPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy); + const policy = applyActorMonitorScheduledBy(normalizeIssueExecutionPolicy({ + ...previousPolicy, monitor, + }), actor.type === "board" ? "user" : "agent"); + const transition = applyIssueMonitorPolicyTransition({ + issue, policy, previousPolicy, requestedAssigneePatch: {}, + actor: { agentId: actor.agentId ?? null, userId: actor.userId ?? null }, + monitorExplicitlyUpdated: true, + }); + // Normalization supplies monitor defaults, but must not rewrite unrelated + // review/authorization settings (including forward-compatible policy fields). + const storedPolicy = { ...(issue.executionPolicy ?? {}) }; + if (policy?.monitor) storedPolicy.monitor = policy.monitor; + else delete storedPolicy.monitor; + return { ...transition.patch, executionPolicy: Object.keys(storedPolicy).length ? storedPolicy : null } as Partial; +} + +/** Timestamp presence alone is not authority to park a native task. */ +export function eligibleIssueMonitorWait( + issue: typeof issues.$inferSelect, + agentId: string, + now = new Date(), +): string | null { + if (issue.assigneeAgentId !== agentId || issue.assigneeUserId || + !["in_progress", "in_review"].includes(issue.status) || !issue.monitorNextCheckAt) return null; + const monitor = normalizeIssueExecutionPolicy(issue.executionPolicy)?.monitor; + if (!monitor || monitor.serviceName === PROVIDER_QUOTA_MONITOR_SERVICE_NAME || + Date.parse(monitor.nextCheckAt) !== issue.monitorNextCheckAt.getTime() || + (monitor.timeoutAt && Date.parse(monitor.timeoutAt) <= now.getTime()) || + (monitor.maxAttempts != null && issue.monitorAttemptCount >= monitor.maxAttempts)) return null; + return issue.monitorNextCheckAt.toISOString(); +} diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 92df948c82c..d814ca3a232 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -1,3 +1,4 @@ +import { eligibleIssueMonitorWait } from "../issue-monitors.js"; import { activeIssueInteractionCondition, ordinaryQuestionCondition } from "../issue-question-context.js"; import { publishedTaskDocuments, validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js"; import { findAutomaticCompletionReviews } from "./automatic-completion-reviews.js"; @@ -64,6 +65,7 @@ export async function nativeCompletionFeedback( if (!issue) throw new Error("Completion task no longer exists."); const reviewContext = readNativeReviewAssignmentContext(run.contextSnapshot); if (reviewContext) { + if (result.continuation?.kind === "monitor") throw new Error("Review-only runs cannot yield to a task monitor; resolve the assigned review or report its blocker."); const review = await getNativeReviewAssignment(db, { companyId: run.companyId, issueId: issue.id, agentId: run.agentId, contextSnapshot: reviewContext, allowResolvedByRunId: run.id, @@ -96,6 +98,12 @@ export async function nativeCompletionFeedback( } } + if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") { + if (issue.workMode !== "standard" || issue.executionRunId !== run.id || + !eligibleIssueMonitorWait(issue, run.agentId)) { + throw new Error("A monitor wait requires a persisted, eligible monitor on this task. Call set_task_monitor for the current task, confirm its schedule, then report yielded with continuation.kind monitor."); + } + } const signals = normalizePrpResultSignals(result); if ( result.reportedWorkDisposition === "done" && @@ -215,6 +223,9 @@ export async function nativeCompletionFeedback( if (readiness.unresolvedBlockerCount > 0) { return `Completion report accepted; this task still has unresolved dependencies. Explain the blockers on [this task](/issues/${issue.identifier ?? issue.id}); do not say the task is done.`; } + if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") { + return `Monitor wait accepted. The task remains active and Paperclip will wake its assignee at or after ${issue.monitorNextCheckAt!.toISOString()} with issue_monitor_due. End this turn; do not poll or mark the task done.`; + } if ( !isConversation(issue) && result.reportedWorkDisposition === "yielded" && diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 6cc62d264e5..d986baf2c70 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -1,3 +1,4 @@ +import { eligibleIssueMonitorWait } from "../issue-monitors.js"; import { isNativePlanWaitResult, readNativePlanWait } from "./native-plan-wait.js"; import { activeIssueInteractionCondition } from "../issue-question-context.js"; import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js"; @@ -1292,7 +1293,9 @@ export async function finalizeNativeRun(input: { }; const hasPendingChildCompletion = !reviewContext && await hasPendingNativeChildCompletion(input.db, childCompletionRecipient); + const monitorWaitAt = eligibleIssueMonitorWait(authoritativeIssue, run.agentId); const proposedDecision = resolveNativeFinalizerStatus({ + monitorWaitAuthorized: authoritativeIssue.workMode === "standard" && authoritativeIssue.executionRunId === run.id && monitorWaitAt !== null, planWaitAuthorized: planWait !== null, hasPendingChildCompletion, providerModelRejected: providerFailure?.errorCode === "native_provider_model_rejected" && ownsProviderFailureDecision, @@ -1381,6 +1384,8 @@ export async function finalizeNativeRun(input: { priorStatusVersion: Number(authoritativeIssue.statusVersion), priorDecisionId: authoritativeIssue.lastStatusDecisionId, decision, + requireMonitorWait: decision.reasonCode === "scheduled_monitor_waiting" && monitorWaitAt + ? { agentId: run.agentId, nextCheckAt: monitorWaitAt } : undefined, requirePlanWaitSource: decision.reasonCode === "native_plan_accepted_waiting_for_continuation" ? planWait?.source : undefined, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 7aebb789555..30b87a60858 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -102,7 +102,7 @@ describe("PaperclipRunnerToolAuthority", () => { issueId, runId, }); - expect(authority.definitions()).toHaveLength(38); + expect(authority.definitions()).toHaveLength(39); const questions = authority.definitions().find(tool => tool.name === "request_human_input")!; expect(questions.description).toContain("ask only the next unanswered question"); expect(questions.description).toContain("Never fabricate answers"); @@ -128,6 +128,7 @@ describe("PaperclipRunnerToolAuthority", () => { "request_human_input", "create_task", "set_dependencies", + "set_task_monitor", "list_documents", "read_document", "list_document_revisions", diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 8a78ed58846..0fe55c24e6d 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,4 @@ +import { assertCanManageIssueMonitor, prepareIssueMonitorUpdate, setTaskMonitorSchema, summarizeIssueMonitor } from "../issue-monitors.js"; import { readTaskQuestionContext } from "../issue-question-context.js"; import { isConversation } from "../agent-conversations.js"; import { setIssueTitle } from "../issue-title.js"; @@ -14,7 +15,7 @@ import type { createAssignedMcpTools } from "./assigned-mcp-tools.js"; import { assertAssignableAgent } from "../agent-assignability.js"; import { approvalReadSqlCondition, canActorReadApproval, authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../authorization.js"; import { resolveCoreTrustPreset } from "../trust-preset-resolver.js"; -import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js"; +import { normalizeIssueExecutionPolicy, redactIssueMonitorExternalRef } from "../issue-execution-policy.js"; import { buildLowTrustSourceTrust } from "../source-trust.js"; import { handoffPlanContext } from "./handoff-plan-context.js"; import { callCreateSkillTool, callUpdateSkillTool } from "../skill-tools.js"; @@ -101,7 +102,7 @@ const IMPLEMENTED_OPERATIONS = new Set([ "submit_complaint", "submit_suggestion", "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions", "search_api", "call_api", "hire_agent", - "get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", + "get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", "set_task_monitor", "request_human_input", "create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable", "list_documents", "read_document", "list_document_revisions", "write_document", @@ -582,6 +583,7 @@ export class PaperclipRunnerToolAuthority { (await captureRunIdentity(this.db, this.binding)).context?.id ?? null); case "reassign_task": return this.#reassignTask(input); case "set_task_title": return this.#setTaskTitle(input); + case "set_task_monitor": return this.#setTaskMonitor(input); case "set_dependencies": return this.#setDependencies(input); case "register_deliverable": return this.#registerDeliverable(input); default: throw new Error("paperclip_runner_tool_not_bound"); @@ -1349,6 +1351,84 @@ export class PaperclipRunnerToolAuthority { return result; } + async #setTaskMonitor(value: Record): Promise { + const input = setTaskMonitorSchema.parse(value); + if (input.monitor?.externalRef) input.monitor.externalRef = redactIssueMonitorExternalRef(input.monitor.externalRef); + const targetId = input.taskId ?? this.binding.issueId; + let publication: Parameters[0] | null = null; + let replayed = false; + let replayStatus: string | null = null; + let replayMonitor: ReturnType | null = null; + const authorizeTarget = async (tx: Db, context: { run: typeof heartbeatRuns.$inferSelect }) => { + // A second owned task may itself be writing another task: fail on lock + // contention instead of taking opposite issue/run locks and deadlocking. + const [target] = await tx.select().from(issues).where(and( + eq(issues.id, targetId), eq(issues.companyId, this.binding.companyId), + )).for("update", { noWait: true }); + if (!target) throw notFound("Task not found"); + const actor = this.#privacyActor(context.run); + const access = authorizationService(tx); + for (const action of ["issue:read", "issue:mutate"] as const) { + const decision = await access.decide({ actor, action, resource: { + type: "issue", companyId: this.binding.companyId, issueId: target.id, + status: target.status, assigneeAgentId: target.assigneeAgentId, assigneeUserId: target.assigneeUserId, + } }); + if (!decision.allowed) throw forbidden(decision.explanation); + } + await assertCanManageIssueMonitor(access, { actor }, target.companyId, target.assigneeAgentId, true); + if (target.assigneeAgentId !== this.binding.agentId || target.assigneeUserId || + !["in_progress", "in_review"].includes(target.status)) { + throw forbidden("Monitors require an in-progress or in-review task assigned to this agent"); + } + return { target, actor }; + }; + const result = await this.#withMutationReceipt("set_task_monitor", input.idempotencyKey, input, async (tx, context) => { + const { target, actor } = await authorizeTarget(tx, context); + // The task lock also serializes retries after a new run resumes the task. + // Keep the durable receipt in the existing run ledger, without a new table. + const [earlier] = await tx.select({ + receipt: sql`${heartbeatRuns.resultJson}->'semanticToolReceipts'->${input.idempotencyKey}`, + }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, this.binding.companyId), + eq(heartbeatRuns.agentId, this.binding.agentId), + eq(heartbeatRuns.nativeIssueId, this.binding.issueId), + sql`${heartbeatRuns.resultJson}->'semanticToolReceipts' ? ${input.idempotencyKey}`, + )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); + if (earlier?.receipt) { + if (earlier.receipt.operationId !== "set_task_monitor" || canonicalJson(earlier.receipt.input) !== canonicalJson(input)) { + throw new Error("paperclip_runner_tool_idempotency_conflict"); + } + replayed = true; + replayStatus = target.status; + replayMonitor = summarizeIssueMonitor(target, normalizeIssueExecutionPolicy(target.executionPolicy)); + return { taskId: target.id, status: target.status, monitor: replayMonitor }; + } + const patch = prepareIssueMonitorUpdate(target, input.monitor, actor); + const [updated] = await tx.update(issues).set({ ...patch, updatedAt: new Date() }) + .where(and(eq(issues.id, target.id), eq(issues.companyId, this.binding.companyId))).returning(); + if (!updated) throw notFound("Task not found"); + const monitor = summarizeIssueMonitor(updated, normalizeIssueExecutionPolicy(updated.executionPolicy)); + const activity = await persistActivity(tx, { + companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId, + agentId: this.binding.agentId, runId: this.binding.runId, + action: input.monitor ? "issue.monitor_scheduled" : "issue.monitor_cleared", + entityType: "issue", entityId: target.id, + details: { identifier: target.identifier, nextCheckAt: monitor.nextCheckAt, + notes: monitor.notes, scheduledBy: monitor.scheduledBy, serviceName: monitor.serviceName, + timeoutAt: monitor.timeoutAt, maxAttempts: monitor.maxAttempts, recoveryPolicy: monitor.recoveryPolicy }, + }); + publication = activity.publication; + return { taskId: updated.id, status: updated.status, monitor }; + }, { beforeReceiptReplay: async (tx, context) => { + const { target } = await authorizeTarget(tx, context); + replayed = true; + replayStatus = target.status; + replayMonitor = summarizeIssueMonitor(target, normalizeIssueExecutionPolicy(target.executionPolicy)); + } }); + if (publication) publishActivity(publication); + return { ...record(result), ...(replayed ? { status: replayStatus, monitor: replayMonitor } : {}), replayed }; + } + async #setDependencies(input: Record): Promise { const idempotencyKey = requiredString(input.idempotencyKey); if (!Array.isArray(input.blockedByTaskIds)) { @@ -2037,6 +2117,9 @@ function redactedTask(task: typeof issues.$inferSelect) { workMode: task.workMode, assigneeAgentId: task.assigneeAgentId, executionRunId: task.executionRunId, + ...(task.monitorNextCheckAt || task.monitorLastTriggeredAt ? { + monitor: summarizeIssueMonitor(task, normalizeIssueExecutionPolicy(task.executionPolicy)), + } : {}), parentId: task.parentId, projectId: task.projectId, goalId: task.goalId, diff --git a/server/src/services/native-runtime/status-arbiter.test.ts b/server/src/services/native-runtime/status-arbiter.test.ts index b8f9d8aa46e..2e77bb7e41c 100644 --- a/server/src/services/native-runtime/status-arbiter.test.ts +++ b/server/src/services/native-runtime/status-arbiter.test.ts @@ -44,6 +44,20 @@ function arbitrate( } describe("native status authority", () => { + it("waits on persisted monitors without an immediate continuation, even with unfinished work", () => { + const pending = assessment({ reportedDisposition: "yielded", hasBlockingRemainingWork: true, + continuation: { kind: "monitor", summary: "Check CI", idempotencyKey: "ci" } }); + for (const priorIssueStatus of ["in_progress", "in_review"] as const) { + expect(arbitrate({ assessment: pending, priorIssueStatus, monitorWaitAuthorized: true })) + .toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus, + reasonCode: "scheduled_monitor_waiting", effects: [{ kind: "release_checkout" }] }); + } + expect(arbitrate({ assessment: pending })).toMatchObject({ reasonCode: "monitor_wait_authority_lost", + effects: [{ kind: "record_finalization_error" }] }); + expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked"); + expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, governanceGate: { kind: "approval", id: "pending" } }).toStatus).toBe("in_review"); + }); + it("keeps a pending child result non-terminal without adding another continuation", () => { expect(arbitrate({ hasPendingChildCompletion: true })).toMatchObject({ statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_child_completion_pending", @@ -150,7 +164,7 @@ describe("native status authority", () => { toStatus: "in_review", effects: [expect.objectContaining({ kind: "create_interaction" })], }); - for (const kind of ["same_agent", "retry", "monitor"] as const) { + for (const kind of ["same_agent", "retry"] as const) { expect( arbitrate({ assessment: { @@ -562,7 +576,7 @@ describe("native status authority", () => { expect.objectContaining({ statusAction: "blocked", toStatus: "blocked", - policyVersion: "phase6-v10", + policyVersion: "phase6-v11", reasonCode: "current_track_blocker_waiting", unblockDescriptor: { owner: "board", diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index 8439247abcc..40fe4d7767e 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -1,7 +1,7 @@ import type { NativeEvidenceAssessment } from "./evidence-classifier.js"; import { NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_CAPACITY_MAX_RETRIES, NATIVE_PROVIDER_OVERLOADED_CODE, NATIVE_PROVIDER_OVERLOADED_MESSAGE } from "./native-provider-failure.js"; -export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v10"; +export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v11"; export type NativeAuthoritativeIssueStatus = | "backlog" @@ -113,6 +113,7 @@ export function arbitrateNativeStatus(input: { "authorized" | "revoked" | "not_applicable"; /** Server-verified accepted plan request and normal provider terminal. */ planWaitAuthorized?: boolean; + monitorWaitAuthorized?: boolean; boardResponseWaitAuthorized?: boolean; boardResponseWaitOrigin?: boolean; isConversation?: boolean; @@ -325,7 +326,7 @@ export function arbitrateNativeStatus(input: { input.assessment.hasBlockingRemainingWork; if ( input.hasUnresolvedIssueBlockers === true && - (["done", "blocked"].includes(input.assessment.reportedDisposition) || unfinishedResponseWait) + (["done", "blocked"].includes(input.assessment.reportedDisposition) || unfinishedResponseWait || input.assessment.continuation?.kind === "monitor") ) { const owner = input.assessment.blocker?.boardOwned ? ("board" as const) @@ -461,6 +462,25 @@ export function arbitrateNativeStatus(input: { ], }; } + if (input.assessment.reportedDisposition === "yielded" && input.assessment.continuation?.kind === "monitor") { + if (input.monitorWaitAuthorized) { + return { + policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, + statusAction: "preserve", toStatus: input.priorIssueStatus, + reasonCode: "scheduled_monitor_waiting", unblockDescriptor: null, + // The persisted timer owns delivery, never enqueue an immediate wake. + effects: [{ kind: "release_checkout" }], + }; + } + return { + policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, + statusAction: "preserve", toStatus: input.priorIssueStatus, + reasonCode: "monitor_wait_authority_lost", unblockDescriptor: null, + effects: [{ kind: "record_finalization_error", cause: "monitor_wait_authority_lost", + nextAction: "The scheduled monitor was cleared or became ineligible. Re-establish a valid task action path.", + agentId: input.agentId }], + }; + } if ( input.assessment.reportedDisposition === "yielded" && input.assessment.continuation?.kind === "response_wake" && diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 1834ee01fde..6d60ac6143c 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -1,3 +1,4 @@ +import { eligibleIssueMonitorWait } from "../issue-monitors.js"; import { readNativePlanWait, type NativePlanWaitSource } from "./native-plan-wait.js"; import { activeIssueInteractionCondition } from "../issue-question-context.js"; import { isConversation } from "../agent-conversations.js"; @@ -732,15 +733,13 @@ async function materializeDecisionEffect(input: { }, }; } + if (effect.continuationKind === "monitor") throw new Error("Monitor delivery belongs to the issue monitor scheduler"); const wakeId = await enqueueWake({ tx: input.tx, companyId: input.companyId, issueId: input.issue.id, agentId: effect.agentId, - reason: - effect.continuationKind === "monitor" - ? "monitor_due" - : "issue_status_changed", + reason: "issue_status_changed", idempotencyKey: `native-status:${input.decisionId}:continuation`, payload: { nativeDecisionId: input.decisionId, @@ -1556,6 +1555,7 @@ export async function commitNativeStatusDecision(input: { supersedesCommittedDecisionId?: string; requireExternalChatResponseWaitAuthorization?: { agentId: string }; requirePlanWaitSource?: NativePlanWaitSource; + requireMonitorWait?: { agentId: string; nextCheckAt: string }; requireNoPendingChildCompletion?: NativeChildCompletionRecipient; requireModelRejectionOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null }; requireProviderFailureOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null }; @@ -1646,6 +1646,15 @@ export async function commitNativeStatusDecision(input: { ) { throw new NativeStatusRaceError(); } + if (reasonCode === "scheduled_monitor_waiting") { + const expected = input.requireMonitorWait; + if (!expected || issue.workMode !== "standard" || issue.executionRunId !== input.runId || + input.decision.statusAction !== "preserve" || input.decision.effects.length !== 1 || + input.decision.effects[0]?.kind !== "release_checkout" || + eligibleIssueMonitorWait(issue, expected.agentId) !== expected.nextCheckAt) { + throw new NativeStatusRaceError(); + } + } // Recheck under the parent status lock: a child can finish between the // finalizer's read and this commit. Re-arbitrate instead of discarding its wake. if (input.requireNoPendingChildCompletion && await hasPendingNativeChildCompletion( diff --git a/server/src/services/native-runtime/task-monitor.integration.test.ts b/server/src/services/native-runtime/task-monitor.integration.test.ts new file mode 100644 index 00000000000..2c7f5930ac2 --- /dev/null +++ b/server/src/services/native-runtime/task-monitor.integration.test.ts @@ -0,0 +1,204 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; +import { buildIssueMonitorTriggeredPatch, normalizeIssueExecutionPolicy } from "../issue-execution-policy.js"; +import { nativeCompletionFeedback } from "./native-completion-feedback.js"; +import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +if (!embeddedPostgresSupport.supported) { + console.warn(`Skipping embedded Postgres native task monitor tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`); +} + +describeEmbeddedPostgres("native task monitors", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("native-task-monitor-"); + db = createDb(temporary.connectionString); + }); + afterAll(async () => { await temporary?.cleanup(); }); + + async function fixture() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Monitors", issuePrefix: `M${companyId.slice(0, 7)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "active" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Deferred check", status: "in_progress", assigneeAgentId: agentId }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + const binding = { companyId, agentId, issueId, runId }; + const authority = new PaperclipRunnerToolAuthority(db, binding); + const monitor = { nextCheckAt: new Date(Date.now() + 60_000).toISOString(), notes: "Compare this task's run and session identities." }; + const call = (args: Record) => authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: args }); + const read = async () => (await db.select().from(issues).where(eq(issues.id, issueId)))[0]!; + return { ...binding, binding, authority, monitor, call, read }; + } + + it("advertises a real binding only in standard execution", async () => { + const value = await fixture(); + expect(value.authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(true); + for (const workMode of ["planning", "ask"] as const) { + const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, workMode }); + expect(authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(false); + await db.update(issues).set({ workMode }).where(eq(issues.id, value.issueId)); + await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "forbidden", monitor: value.monitor } })).rejects.toThrow(); + } + }); + + it("keeps provider-neutral discovery and review-only restrictions", async () => { + const value = await fixture(); + for (const provider of ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"]) { + await db.update(agents).set({ adapterConfig: { provider } }).where(eq(agents.id, value.agentId)); + expect(value.authority.definitions().map(tool => tool.name)).toContain("set_task_monitor"); + } + const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, + nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }); + expect(authority.definitions().map(tool => tool.name)).not.toContain("set_task_monitor"); + await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "review", monitor: value.monitor } })).rejects.toThrow("review run"); + }); + + it("rechecks the responsible user's permissions before mutation and replay", async () => { + const value = await fixture(), userId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: value.companyId, principalType: "user", principalId: userId, membershipRole: "owner" }); + await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, value.runId)); + const original = { idempotencyKey: "authorized", monitor: value.monitor }; + await value.call(original); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.companyId, value.companyId)); + await expect(value.call(original)).rejects.toThrow(); + await expect(value.call({ idempotencyKey: "clear", monitor: null })).rejects.toThrow(); + expect((await value.read()).monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt); + }); + + it("persists a monitor and its audit atomically while preserving review policy", async () => { + const value = await fixture(); + const policy = { mode: "normal", commentRequired: false, stages: [{ id: randomUUID(), type: "review", approvalsNeeded: 1, participants: [{ type: "user", userId: "reviewer" }] }] }; + await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, value.issueId)); + const result = await value.call({ idempotencyKey: "schedule", monitor: value.monitor }); + expect(result).toMatchObject({ taskId: value.issueId, monitor: { ...value.monitor, scheduledBy: "assignee", status: "scheduled" }, replayed: false }); + const saved = await value.read(); + expect(saved.executionPolicy).toMatchObject(policy); + expect(saved.monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt); + expect(saved.status).toBe("in_progress"); + const audits = await db.select().from(activityLog).where(and(eq(activityLog.entityId, value.issueId), eq(activityLog.action, "issue.monitor_scheduled"))); + expect(audits).toHaveLength(1); + expect(audits[0]).toMatchObject({ runId: value.runId, agentId: value.agentId }); + }); + + it("replaces and clears schedules without re-arming a receipt on replay", async () => { + const value = await fixture(); + const original = { idempotencyKey: "first", monitor: value.monitor }; + await value.call(original); + const replacement = { ...value.monitor, nextCheckAt: new Date(Date.now() + 120_000).toISOString() }; + await value.call({ idempotencyKey: "replace", monitor: replacement }); + expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: replacement.nextCheckAt }, replayed: true }); + await value.call({ idempotencyKey: "clear", monitor: null }); + expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: null, status: "cleared" }, replayed: true }); + expect((await value.read()).monitorNextCheckAt).toBeNull(); + await expect(value.call({ ...original, monitor: replacement })).rejects.toThrow("idempotency_conflict"); + const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId)); + expect(audits.filter(row => row.action.startsWith("issue.monitor_"))).toHaveLength(3); + }); + + it("exposes consumed monitor instructions to a resumed task and does not re-arm them on retry", async () => { + const value = await fixture(); + const input = { idempotencyKey: "consumed", monitor: value.monitor }; + await value.call(input); + const scheduled = await value.read(); + await db.update(issues).set(buildIssueMonitorTriggeredPatch({ issue: scheduled, + policy: normalizeIssueExecutionPolicy(scheduled.executionPolicy), triggeredAt: new Date() })).where(eq(issues.id, value.issueId)); + expect(await value.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} })) + .toMatchObject({ activeTask: { monitor: { status: "triggered", nextCheckAt: null, notes: value.monitor.notes, attemptCount: 1 } } }); + expect(await value.call(input)).toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "triggered" } }); + expect((await value.read()).monitorNextCheckAt).toBeNull(); + }); + + it("keeps a cleared schedule cleared when a successor run retries the original key", async () => { + const value = await fixture(); + const original = { idempotencyKey: "durable-schedule", monitor: value.monitor }; + await value.call(original); + await value.call({ idempotencyKey: "durable-clear", monitor: null }); + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, value.runId)); + const nextRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: nextRunId, companyId: value.companyId, agentId: value.agentId, + nativeIssueId: value.issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId: value.issueId } }); + await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, value.issueId)); + const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, runId: nextRunId }); + expect(await authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: original })) + .toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "cleared" } }); + expect((await value.read()).monitorNextCheckAt).toBeNull(); + await expect(value.call(original)).rejects.toThrow(); + }); + + it("allows another owned task but rejects foreign ownership and company scope", async () => { + const value = await fixture(), other = await fixture(), targetId = randomUUID(); + await db.insert(issues).values({ id: targetId, companyId: value.companyId, title: "Another owned check", status: "in_review", assigneeAgentId: value.agentId }); + expect(await value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).toMatchObject({ taskId: targetId }); + await expect(value.call({ taskId: other.issueId, idempotencyKey: "foreign", monitor: value.monitor })).rejects.toThrow("Task not found"); + const otherAgent = randomUUID(); + await db.insert(agents).values({ id: otherAgent, companyId: value.companyId, name: "Other", adapterType: "paperclip_runner" }); + await db.update(issues).set({ assigneeAgentId: otherAgent }).where(eq(issues.id, targetId)); + await expect(value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).rejects.toThrow(); + }); + + it("rejects invalid schedules, exhausted bounds and ineligible tasks without writes", async () => { + const value = await fixture(); + for (const monitor of [ + { ...value.monitor, notes: "" }, + { ...value.monitor, nextCheckAt: new Date(0).toISOString() }, + { ...value.monitor, scheduledBy: "board" }, + { ...value.monitor, timeoutAt: value.monitor.nextCheckAt }, + { ...value.monitor, maxAttempts: 101 }, + ]) await expect(value.call({ idempotencyKey: randomUUID(), monitor })).rejects.toThrow(); + await db.update(issues).set({ monitorAttemptCount: 1 }).where(eq(issues.id, value.issueId)); + await expect(value.call({ idempotencyKey: "exhausted", monitor: { ...value.monitor, maxAttempts: 1 } })).rejects.toThrow(); + for (const status of ["blocked", "backlog", "done", "cancelled"] as const) { + await db.update(issues).set({ status }).where(eq(issues.id, value.issueId)); + await expect(value.call({ idempotencyKey: randomUUID(), monitor: value.monitor })).rejects.toThrow(); + } + expect((await value.read()).monitorNextCheckAt).toBeNull(); + }); + + it("accepts unfinished monitor waits only after scheduling the current task", async () => { + const value = await fixture(); + const result: PrpStructuredRunResult = { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "I will compare the next run.", + completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Compare next run", blocksCompletion: true }] }, + continuation: { kind: "monitor", summary: "Wait for the scheduled check", idempotencyKey: "wait" }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + }; + await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor"); + const otherId = randomUUID(); + await db.insert(issues).values({ id: otherId, companyId: value.companyId, title: "Other", status: "in_progress", assigneeAgentId: value.agentId }); + await value.call({ taskId: otherId, idempotencyKey: "other", monitor: value.monitor }); + await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor"); + await value.call({ idempotencyKey: "self", monitor: value.monitor }); + await expect(nativeCompletionFeedback(db, value.runId, result)).resolves.toContain("issue_monitor_due"); + // Legacy APIs may persist a server-owned quota monitor. It cannot justify + // a native wait because its dispatcher explicitly excludes native runs. + await db.update(issues).set({ executionPolicy: { monitor: { ...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME } } }).where(eq(issues.id, value.issueId)); + await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor"); + await value.call({ idempotencyKey: "clear", monitor: null }); + await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor"); + }); + + it("rejects the server-owned quota recovery name without promising a wake", async () => { + const value = await fixture(); + await expect(value.call({ idempotencyKey: "quota", monitor: { + ...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME, externalRef: value.runId, + } })).rejects.toThrow("reserved for server-owned quota recovery"); + expect((await value.read()).monitorNextCheckAt).toBeNull(); + const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId)); + expect(audits).toHaveLength(0); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, value.runId)); + expect(run.resultJson?.semanticToolReceipts).toBeUndefined(); + await expect(value.call({ idempotencyKey: "quota", monitor: { + ...value.monitor, serviceName: "Provider usage dashboard", + } })).resolves.toMatchObject({ monitor: { status: "scheduled" }, replayed: false }); + }); +}); From 1640c5b6abe7373b75606433c5f4a34450123170 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:01:05 -0500 Subject: [PATCH 06/55] feat(ui): render HTML artifacts in a secure sandbox (#15447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents deliver reports as task attachments and workspace files. > - The board already renders Markdown, but it shows HTML reports as source or rejects their preview. > - Reports can need inline scripts to build charts and tables. > - Artifact scripts must not read board cookies, storage, or the parent page. > - This pull request adds an opaque-origin HTML preview and view controls beside Download. > - Users can explore a report, inspect its source, and download the original file. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task attachment panel and workspace file viewers. **Subsystem affected** ui/ and the server workspace file preview service. **Current behavior** HTML attachments show source text. Workspace HTML files cannot be previewed. **Proposed behavior** Render self-contained HTML reports in a sandboxed iframe. Place eye and code controls beside Download. Preserve the original file for raw view and download. **Reason and benefit** Users can read and filter agent reports in Paperclip without giving artifact scripts access to the board session. **Breaking changes** HTML previews now open rendered. Scripts and styles must be embedded in the report. Remote resources, API requests, forms, popups, and host navigation are blocked. Workspace HTML content uses the existing bounded UTF-8 JSON response. **Additional context** Related closed proposals: #3293 and #4857. This change uses the current attachment and workspace viewers and adds no report-serving endpoint. The Artifacts and Work Products roadmap item is complete; this improves its existing preview behavior. ## What Changed - Add a shared HTML iframe renderer with `sandbox="allow-scripts"` and no `allow-same-origin`. - Install a restrictive CSP before artifact markup. Keep inline report scripts and styles. - Add shared rendered/raw icon controls beside Download in the attachment panel, workspace panel, and file sheet. - Return workspace HTML as bounded text inside JSON. Keep download and path-access protections. - Add Storybooks for reports, security probes, workspace viewers, a task journey, and mobile layouts. - Document the security boundary and browser test command. ## Verification - `pnpm -r typecheck` and `pnpm build` pass. - Token gates and the static Storybook build pass. - Four browser tests pass. They cover report filters, raw mode, task entry, workspace viewers, cookies, storage, host DOM, resource requests, forms, popups, and host navigation. - The focused UI suite passes 26 tests. The file-resource server suite passes 36 tests. - Local full-stack acceptance passes with an actual 52 KB HTML report. Its chart and filter render. Raw mode preserves the source. Download bytes match the original file. - The complete local UI suite passes: 698 files and 7,754 tests. - `pnpm test:run` was attempted. Its server group recorded two unrelated timeouts and eight connector failures. All failed cases pass in isolated reruns, including the complete 388-test connector suite. The remaining local run was stopped after the full CI test coverage passed, to release test database resources. The full local command did not complete cleanly. - The separate local CLI group passes 511 tests. Three worktree database cases fail to start embedded PostgreSQL because this Mac has exhausted its shared-memory allocation. One isolated rerun fails at the same database startup step. No CLI code was changed. The corresponding CI test jobs pass. - All 56 PR checks are clean on `c771255f1a2e86bd8825f22eafb5d14a5e342cce`. Greptile gives 5/5 with no actionable findings. The security scan passes. The branch has no merge conflict. - Review the **HTML artifacts** section in Storybook. Run `pnpm exec playwright test --config tests/html-preview/playwright.config.ts` for the browser checks. ## Risks - Never add `allow-same-origin` to this iframe. Its opaque origin is the cookie, storage, and host-DOM boundary. - Reports that depend on CDN assets need to embed their dependencies. - The frame can navigate itself. Sandbox restrictions remain after navigation. This is not full network isolation. - Inline scripts can consume browser resources. This change does not isolate CPU or memory use. - No database migrations or API shape changes are required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository editing, code execution, and browser tool use. The runtime does not expose a more specific model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass — feature and UI checks pass; the full local run has the infrastructure limits recorded above. All CI test jobs pass. - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/DEVELOPING.md | 5 ++ doc/SPEC-implementation.md | 1 + doc/SPEC.md | 4 + doc/html-artifact-previews.md | 43 ++++++++++ server/src/__tests__/file-resources.test.ts | 17 +++- .../src/services/workspace-file-resources.ts | 3 +- tests/html-preview/html-preview.spec.ts | 65 ++++++++++++++ tests/html-preview/playwright.config.ts | 17 ++++ ui/src/components/FilePreviewModeToggle.tsx | 21 +++++ ui/src/components/FileViewerSheet.tsx | 24 +++++- ui/src/components/HtmlArtifactPreview.tsx | 18 ++++ .../TaskAttachmentPanel.test.tsx | 34 ++++++++ .../task-side-panel/TaskAttachmentPanel.tsx | 29 +++---- .../TaskWorkspaceFilePanel.tsx | 11 ++- ui/src/lib/html-preview.test.ts | 25 ++++++ ui/src/lib/html-preview.ts | 28 +++++++ ui/src/lib/issue-attachments.ts | 2 + .../html-artifacts/01-preview.stories.tsx | 25 ++++++ .../html-artifacts/02-workspace.stories.tsx | 40 +++++++++ .../03-task-journey.stories.tsx | 84 +++++++++++++++++++ .../stories/html-artifacts/fixtures.ts | 27 ++++++ .../stories/html-artifacts/report.html | 29 +++++++ 22 files changed, 530 insertions(+), 22 deletions(-) create mode 100644 doc/html-artifact-previews.md create mode 100644 tests/html-preview/html-preview.spec.ts create mode 100644 tests/html-preview/playwright.config.ts create mode 100644 ui/src/components/FilePreviewModeToggle.tsx create mode 100644 ui/src/components/HtmlArtifactPreview.tsx create mode 100644 ui/src/lib/html-preview.test.ts create mode 100644 ui/src/lib/html-preview.ts create mode 100644 ui/storybook/stories/html-artifacts/01-preview.stories.tsx create mode 100644 ui/storybook/stories/html-artifacts/02-workspace.stories.tsx create mode 100644 ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx create mode 100644 ui/storybook/stories/html-artifacts/fixtures.ts create mode 100644 ui/storybook/stories/html-artifacts/report.html diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c8cca2fc2e9..cc6c53791cd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -103,6 +103,11 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +**HTML artifacts** covers secure rendered/raw attachment previews, interactive +reports, security probes, workspace previews, and opening a report from a task. +See [HTML artifact previews](html-artifact-previews.md) for the security boundary, +supported content, and browser verification command. + **Composer → New task** includes agent/user/project/task mentions, skill and routine slash commands, and populated rich chips on desktop and mobile. Agent mentions use the same avatars in suggestions, inserted chips, and the mocked diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5fc5c9a361d..9fb9c80d7f1 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -487,6 +487,7 @@ Operational policy: - Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download. - Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`. - Script-capable content such as HTML is always served as an attachment with `X-Content-Type-Options: nosniff` and a sandboxed, deny-by-default CSP; it is never rendered inline on the Paperclip origin. + - The board can fetch bounded HTML source for a rich preview in an opaque-origin iframe (`sandbox="allow-scripts"`, without `allow-same-origin`). A trusted CSP precedes artifact markup and blocks remote subresources, API connections, forms, and embeds. Rendered/raw icons sit beside Download. Workspace HTML previews return UTF-8 inside JSON under the existing text limits. See `doc/html-artifact-previews.md`. - Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking. - Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`. - Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access. diff --git a/doc/SPEC.md b/doc/SPEC.md index ccdf5dcb721..a346d7abcdb 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -489,6 +489,10 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline. +Self-contained HTML reports can render in an opaque-origin sandbox. The report +cannot access the board's cookies, storage, or DOM. Users can switch to the raw +source beside Download, which returns the original file. + Users may start a task with only a prompt. Paperclip uses a short prompt slice as its initial title and asks the assigned agent to name the task early. Explicit user titles are preserved, and naming does not change task execution state. diff --git a/doc/html-artifact-previews.md b/doc/html-artifact-previews.md new file mode 100644 index 00000000000..cfb9fafbe99 --- /dev/null +++ b/doc/html-artifact-previews.md @@ -0,0 +1,43 @@ +# HTML artifact previews + +HTML attachments and workspace `.html`/`.htm` files open rendered by default. +The eye and code icons beside Download switch between the report and its original +source. Download always returns the original file. Empty and oversized attachments +use the existing text-viewer states and limits. + +The shared `HtmlArtifactPreview` uses `srcdoc` in an iframe with +`sandbox="allow-scripts"`. **Never add `allow-same-origin`.** Its opaque origin +prevents artifact JavaScript from reading Paperclip cookies, storage, or the parent +DOM. Forms, popups, downloads from inside the frame, and top navigation are not +granted. The host does not accept messages or privileged requests from artifacts. + +A trusted CSP is installed before any artifact markup. It permits inline scripts +and styles for self-contained tables, charts, filtering, and other report controls. +It blocks API connections, remote scripts/styles/images/fonts/media, child frames, +objects, base URLs, and form submissions. Embedded data images/fonts/media work. +An artifact's own CSP cannot weaken the first policy. No artifact HTML is parsed +or inserted into the host DOM. The iframe sends no referrer. + +This supports self-contained reports. Reports that need CDN libraries or external +resources must embed them instead. Ordinary external links cannot open new tabs +or navigate the host. The sandbox does allow navigation within the artifact frame; +its opaque-origin restrictions remain after navigation. This is not a claim of +complete network isolation or protection against resource exhaustion by scripts. + +Attachment HTTP serving retains forced download, `nosniff`, and its existing +sandbox CSP. Workspace previews return bounded UTF-8 inside JSON, and workspace +downloads retain `Content-Disposition: attachment`. No database or API shape changes +are needed. + +Review **HTML artifacts** in Storybook for the production viewer, security probes, +workspace panel/file sheet, and a task-to-report journey. Run the browser security +checks with: + +```sh +pnpm exec playwright test --config tests/html-preview/playwright.config.ts +``` + +These checks use Chromium and simulated report data. They verify working report +controls, raw mode, cookie/storage/parent access denials, blocked resource and API +requests, and denied top navigation/popups. They do not replace testing the actual +attachment journey in a running Paperclip instance. diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 2748e28d22c..e1aea92c813 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -995,7 +995,7 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(linkedSecret.status).toBe(403); }); - it("rejects denied paths, non-regular files, oversized text, binary, and HTML while previewing SVG as source", async () => { + it("rejects denied paths, non-regular files, oversized text and binary while returning HTML and SVG as text", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.mkdir(path.join(projectRoot, ".git"), { recursive: true }); @@ -1014,7 +1014,7 @@ describeEmbeddedPostgres("workspace file resources", () => { isInstanceAdmin: false, }); - for (const filePath of [".git/config", "folder", "big.txt", "blob.bin", "index.html"]) { + for (const filePath of [".git/config", "folder", "big.txt", "blob.bin"]) { const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: filePath }); @@ -1026,6 +1026,19 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(svg.status).toBe(200); expect(svg.body.resource.previewKind).toBe("text"); expect(svg.body.content.data).toContain("alert(1)" }); + const downloaded = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: "index.html", download: "1" }); + expect(downloaded.status).toBe(200); + expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="index.html"'); }); it("rejects remote workspaces without fetching provider resources", async () => { diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 31c6eb0df31..9b57cfe3458 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -359,7 +359,8 @@ function previewKindForKnownContentType(contentType: string | null): WorkspaceFi if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image"; if (contentType.startsWith("video/")) return "video"; if (contentType === "application/pdf") return "pdf"; - if (contentType === "text/html") return "unsupported"; + // HTML is returned as bounded UTF-8 in the JSON content response. The UI + // renders it only in its opaque-origin sandbox; downloads stay attachments. if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text"; return "unsupported"; } diff --git a/tests/html-preview/html-preview.spec.ts b/tests/html-preview/html-preview.spec.ts new file mode 100644 index 00000000000..1a304f4d81f --- /dev/null +++ b/tests/html-preview/html-preview.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; + +test("interactive reports work and rendered/raw controls preserve the original download", async ({ page }) => { + await page.goto("/iframe.html?id=html-artifacts-01-preview--interactive-report&viewMode=story"); + const report = page.frameLocator('iframe[title="repository-usage.html rendered HTML"]'); + await expect(report.getByRole("cell", { name: "juspay/hyperswitch", exact: true })).toBeVisible(); + await report.getByPlaceholder("Search repositories").fill("storybook"); + await expect(report.getByText("1 qualifying repositories")).toBeVisible(); + await expect(report.getByRole("cell", { name: "juspay/hyperswitch", exact: true })).toHaveCount(0); + const original = await page.getByRole("link", { name: "Download repository-usage.html" }).getAttribute("href"); + await page.getByRole("button", { name: "Raw", exact: true }).click(); + await expect(page.locator("iframe[title$='rendered HTML']")).toHaveCount(0); + await expect(page.getByLabel("repository-usage.html raw text")).toContainText("'; + const host = document.createElement("div"); const root = createRoot(host); + await act(async () => root.render()); + const frame = host.querySelector("iframe"); + expect(frame?.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer"); + expect(frame?.getAttribute("srcdoc")).toContain("Content-Security-Policy"); + expect(host.querySelector("script")).toBeNull(); + const toggle = host.querySelector('[aria-label="HTML view"]'); + expect(toggle?.nextElementSibling?.getAttribute("href")).toBe("/download"); + await act(async () => host.querySelector('[aria-label="Raw"]')!.click()); + expect(host.querySelector("iframe")).toBeNull(); + expect(host.querySelector("pre")?.textContent).toBe(html); + expect(host.querySelector('[aria-label="Raw"]')?.getAttribute("aria-pressed")).toBe("true"); + await act(async () => host.querySelector('[aria-label="Rendered"]')!.click()); + expect(host.querySelector("iframe")).not.toBeNull(); + await act(async () => root.unmount()); + }); + it("opens HTML from an authorized attachment and resets the view for the next file", async () => { + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + client.setQueryData(queryKeys.issues.attachments("task"), ["one", "two"].map(id => ({ id, originalFilename: `${id}.html`, contentType: "text/html", byteSize: 20, contentPath: `/api/attachments/${id}/content` }))); + for (const id of ["one", "two"]) client.setQueryData(["task-text-attachment", "task", id], `

${id}

`); + const host = document.createElement("div"); const root = createRoot(host); + const panel = (id: string) => ; + await act(async () => root.render(panel("one"))); + expect(host.querySelector("iframe")?.getAttribute("title")).toBe("one.html rendered HTML"); + await act(async () => host.querySelector('[aria-label="Raw"]')!.click()); + await act(async () => root.render(panel("two"))); + expect(host.querySelector("iframe")?.getAttribute("title")).toBe("two.html rendered HTML"); + expect(fetch).not.toHaveBeenCalled(); + await act(async () => root.unmount()); client.clear(); + }); it("rejects HTTP errors, oversized responses and binary data", async () => { await expect(readTextPreview(new Response("denied", { status: 403 }))).rejects.toThrow("403"); await expect(readTextPreview(new Response("x".repeat(TEXT_PREVIEW_MAX_BYTES + 1)))).rejects.toThrow("too large"); diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx index 9b2760dea92..dea605ae638 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Code2, Download, Eye } from "lucide-react"; +import { Download } from "lucide-react"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview"; +import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; +import { isHtmlPreview } from "@/lib/html-preview"; import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments"; import { queryKeys } from "@/lib/queryKeys"; @@ -34,34 +37,28 @@ export async function readTextPreview(response: Response) { } } -export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: { +export function TextAttachmentPreview({ title, text, markdown, html = false, downloadUrl }: { title: string; text: string; markdown: boolean; + html?: boolean; downloadUrl: string; }) { - const [raw, setRaw] = useState(false); + const [mode, setMode] = useState("rendered"); + const renderHtml = html && mode === "rendered" && text.length > 0; return (

{title}

- {markdown ? ( -
- - -
- ) : null} + {markdown || html ? : null}
-
+
{text.length === 0 ?

File is empty.

- : markdown && !raw ? {text} + : renderHtml ? + : markdown && mode === "rendered" ? {text} :
{text}
}
@@ -99,5 +96,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string ); } if (content.data === undefined) return

Loading file…

; - return ; + return ; } diff --git a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx index 4c776e94bbb..802195472bc 100644 --- a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx +++ b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, Download } from "lucide-react"; import { fileResourcesApi } from "@/api/file-resources"; import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet"; import { Button } from "@/components/ui/button"; +import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; +import { isHtmlPreview } from "@/lib/html-preview"; import type { FileViewerUrlState } from "@/context/FileViewerContext"; import { copyTextToClipboard } from "@/lib/clipboard"; import { queryKeys } from "@/lib/queryKeys"; @@ -22,6 +24,8 @@ export function TaskWorkspaceFilePanel({ }) { const [copied, setCopied] = useState(false); const [announcement, setAnnouncement] = useState(""); + const [htmlMode, setHtmlMode] = useState("rendered"); + useEffect(() => setHtmlMode("rendered"), [payload.path, payload.workspace, payload.workspaceId, payload.projectId]); const state: FileViewerUrlState = { path: payload.path, workspace: payload.workspace, @@ -72,6 +76,10 @@ export function TaskWorkspaceFilePanel({ {copied ? : } ) : null} + {contentQuery.data?.resource.previewKind === "text" && contentQuery.data.content.encoding === "utf8" + && isHtmlPreview(contentQuery.data.resource.contentType, contentQuery.data.resource.displayPath || contentQuery.data.resource.title) ? ( + + ) : null} {downloadUrl ? (
diff --git a/ui/src/lib/html-preview.test.ts b/ui/src/lib/html-preview.test.ts new file mode 100644 index 00000000000..f9c9d5064ec --- /dev/null +++ b/ui/src/lib/html-preview.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { HTML_PREVIEW_CSP, htmlPreviewDocument, isHtmlPreview } from "./html-preview"; +import { isTextAttachment } from "./issue-attachments"; + +describe("HTML artifact previews", () => { + it("recognizes HTML MIME types and filenames including generic uploaded files", () => { + for (const [type, name] of [["text/html; charset=utf-8", "report"], ["TEXT/HTML", "report"], ["application/xhtml+xml", "report"], ["application/octet-stream", "report.HTML"], ["text/plain", "report.htm"]]) { + expect(isHtmlPreview(type, name)).toBe(true); + expect(isTextAttachment({ contentType: type, originalFilename: name })).toBe(true); + } + expect(isHtmlPreview("text/plain", "notes.txt")).toBe(false); + expect(isHtmlPreview("text/markdown", "report.md")).toBe(false); + }); + it("puts the trusted policy before adversarial complete documents without parsing in the host DOM", () => { + const html = ''; + const doc = htmlPreviewDocument(html); + expect(doc.indexOf(`content="${HTML_PREVIEW_CSP}"`)).toBeLessThan(doc.indexOf(html)); + expect(doc).toContain("connect-src 'none'"); + expect(doc).toContain("form-action 'none'"); + expect(doc).toContain("base-uri 'none'"); + expect(doc).toContain("frame-src 'none'"); + expect(doc).not.toContain("script-src 'self'"); + expect(doc).not.toContain("unsafe-eval"); + }); +}); diff --git a/ui/src/lib/html-preview.ts b/ui/src/lib/html-preview.ts new file mode 100644 index 00000000000..6a906f83593 --- /dev/null +++ b/ui/src/lib/html-preview.ts @@ -0,0 +1,28 @@ +/** HTML previews are documents, never nodes in the Paperclip DOM. */ +export function isHtmlPreview(contentType: string | null | undefined, filename: string | null | undefined) { + const type = contentType?.toLowerCase().split(";")[0].trim(); + return type === "text/html" || type === "application/xhtml+xml" || /\.html?$/i.test(filename ?? ""); +} + +// Inline scripts support self-contained reports/charts. No origin privileges, +// external scripts, API calls, forms, embeds, or remote subresources are granted. +// Multiple CSPs intersect; artifact markup cannot loosen this first policy. +export const HTML_PREVIEW_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + "img-src data:", + "font-src data:", + "media-src data:", + "connect-src 'none'", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", +].join("; "); + +export function htmlPreviewDocument(html: string) { + // Install the policy before ANY untrusted markup is parsed. Do not parse the + // artifact in the host document (even detached DOMs can load resources). + return `${html}`; +} diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts index 31977a6891b..6d0c8d020d5 100644 --- a/ui/src/lib/issue-attachments.ts +++ b/ui/src/lib/issue-attachments.ts @@ -1,6 +1,7 @@ import type { IssueAttachment } from "@paperclipai/shared"; import { isMarkdownAttachmentContent } from "@paperclipai/shared"; import { isImageLikeOutput, isVideoLikeOutput } from "./issue-output"; +import { isHtmlPreview } from "./html-preview"; type AttachmentPathLike = { contentPath: string; @@ -47,6 +48,7 @@ export function isTextAttachment(attachment: { }) { const type = attachment.contentType.toLowerCase().split(";")[0].trim(); return isMarkdownAttachmentContent(attachment) + || isHtmlPreview(type, attachment.originalFilename) || type.startsWith("text/") || /^(application\/(json|xml|javascript|x-yaml|yaml)|application\/[\w.-]+\+json)$/.test(type) || (["", "application/octet-stream"].includes(type) diff --git a/ui/storybook/stories/html-artifacts/01-preview.stories.tsx b/ui/storybook/stories/html-artifacts/01-preview.stories.tsx new file mode 100644 index 00000000000..953713b37ae --- /dev/null +++ b/ui/storybook/stories/html-artifacts/01-preview.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; +import { reportHtml, securityHtml } from "./fixtures"; + +function Preview({ html = reportHtml, title = "repository-usage.html", width = 800 }: { html?: string; title?: string; width?: number }) { + return
+
+ +
+
; +} + +const meta = { + title: "HTML artifacts/01 Preview", + component: Preview, + args: { html: reportHtml, title: "repository-usage.html", width: 800 }, + parameters: { layout: "fullscreen", docs: { description: { component: "Production attachment viewer. Inline CSS and JavaScript run in an opaque-origin sandbox. Remote dependencies and API requests are blocked. The original file is available through Download or Raw." }, story: { inline: false } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const InteractiveReport: Story = {}; +export const SecurityChecks: Story = { args: { html: securityHtml, title: "isolation-checks.html" } }; +export const Empty: Story = { args: { html: "", title: "empty.html" } }; +export const Mobile: Story = { args: { width: 360 }, globals: { viewport: { value: "mobile", isRotated: false } } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx b/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx new file mode 100644 index 00000000000..eedb1f17d0d --- /dev/null +++ b/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ResolvedWorkspaceResource } from "@paperclipai/shared"; +import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspaceFilePanel"; +import { FileViewerSheet } from "@/components/FileViewerSheet"; +import { FileViewerProvider } from "@/context/FileViewerContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { reportHtml } from "./fixtures"; + +const issueId = "html-workspace-story"; +const state = { path: "reports/repository-usage.html", workspace: "project" as const, workspaceId: null, projectId: null, line: null, column: null }; + +function Workspace({ sheet = false }: { sheet?: boolean }) { + const [client] = useState(() => { + const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + const resource: ResolvedWorkspaceResource = { + kind: "file", provider: "local_fs", title: "repository-usage.html", displayPath: state.path, + workspaceLabel: "Research workspace", workspaceKind: "project_workspace", workspaceId: "research-workspace", + contentType: "text/html", byteSize: reportHtml.length, previewKind: "text", denialReason: null, + capabilities: { preview: true, download: true, listChildren: false }, + }; + cache.setQueryData(queryKeys.issues.fileResource(issueId, state), resource); + cache.setQueryData(queryKeys.issues.fileResourceContent(issueId, state), { resource, content: { encoding: "utf8", data: reportHtml } }); + return cache; + }); + return +
+ {sheet ? :
+ +
} +
+
; +} +const meta = { title: "HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => , parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const TaskPanel: Story = {}; +export const FileSheet: Story = { args: { sheet: true } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } }; diff --git a/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx b/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx new file mode 100644 index 00000000000..80458df3e00 --- /dev/null +++ b/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { IssueAttachment } from "@paperclipai/shared"; +import { Routes, Route } from "@/lib/router"; +import { Layout } from "@/components/Layout"; +import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; +import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel"; +import { IssueArtifactFile } from "@/components/artifacts/IssueArtifactCard"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { queryKeys } from "@/lib/queryKeys"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; +import { reportHtml } from "./fixtures"; +import reportDownload from "./report.html?url"; + +const issue = createIssue({ id: "html-artifact-journey", identifier: "DEMO-101", title: "Find popular repositories that use Paperclip a lot", status: "done", completedAt: new Date("2026-10-07T12:54:00Z"), currentExecutionWorkspace: null, executionWorkspaceId: null }); +const attachment: IssueAttachment = { + id: "html-usage-report", companyId: issue.companyId, issueId: issue.id, issueCommentId: null, + assetId: "html-usage-asset", provider: "local_disk", sha256: "storybook-fixture", + createdByAgentId: "agent-codex", createdByUserId: null, originatingRunId: null, + originalFilename: "repository-usage.html", objectKey: "repository-usage.html", contentType: "text/html", + byteSize: reportHtml.length, contentPath: "/api/attachments/html-usage-report/content", + downloadPath: reportDownload, + createdAt: new Date("2026-10-07T12:54:00Z"), + updatedAt: new Date("2026-10-07T12:54:00Z"), +}; + +function TaskJourney({ openInitially = false }: { openInitially?: boolean }) { + const [open, setOpen] = useState<{ id: string; title: string; requestId: number } | null>(() => openInitially ? { id: attachment.id, title: attachment.originalFilename!, requestId: 1 } : null); + const [client] = useState(() => { + const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + cache.setQueryData(queryKeys.issues.attachments(issue.id), [attachment]); + cache.setQueryData(queryKeys.issues.workProducts(issue.id), []); + cache.setQueryData([...queryKeys.issues.documents(issue.id), "list"], []); + cache.setQueryData([...queryKeys.issues.documents(issue.id), "plan"], null); + cache.setQueryData(queryKeys.issues.runs(issue.id), []); + cache.setQueryData(queryKeys.agents.list(issue.companyId), storybookAgents); + cache.setQueryData(["task-text-attachment", issue.id, attachment.id], reportHtml); + window.localStorage.removeItem(`paperclip:task-side-panel:v1:storybook-html:${issue.companyId}`); + return cache; + }); + return + setOpen({ id, title, requestId: Date.now() })}> +
+
+

{issue.title}

+ + + +
+
+ {}} fileTabsEnabled={false} openAttachment={open} /> +
+
+
+
; +} + +function Journey(props: { openInitially?: boolean }) { + return }> + } /> + ; +} +const meta = { + title: "HTML artifacts/03 Task journey", component: Journey, + parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/html-artifact-journey"], docs: { description: { component: "Open the HTML artifact from the task's report card or Artifacts tab, filter the report, then switch to Raw beside Download. Uses the production app shell, artifact card, task panel and HTML renderer with simulated report data." }, story: { inline: false } } }, + beforeEach: () => { + const originalFetch = window.fetch; + const fixtureFetch: typeof window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); + if (url.pathname === `/api/issues/${issue.id}/work-products`) return Response.json([]); + return originalFetch(input, init); + }; + window.fetch = fixtureFetch; + return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; }; + }, + render: (args, context) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const OpenFromTask: Story = {}; +export const ReportOpen: Story = { args: { openInitially: true } }; +export const Mobile: Story = { args: { openInitially: true }, globals: { viewport: { value: "mobile", isRotated: false } } }; diff --git a/ui/storybook/stories/html-artifacts/fixtures.ts b/ui/storybook/stories/html-artifacts/fixtures.ts new file mode 100644 index 00000000000..b961083ecf4 --- /dev/null +++ b/ui/storybook/stories/html-artifacts/fixtures.ts @@ -0,0 +1,27 @@ +import reportHtml from "./report.html?raw"; +export { reportHtml }; + +export const securityHtml = ` + + + + +

HTML isolation checks

    +Remote image is blocked + +
    + +`; diff --git a/ui/storybook/stories/html-artifacts/report.html b/ui/storybook/stories/html-artifacts/report.html new file mode 100644 index 00000000000..336ca4125b1 --- /dev/null +++ b/ui/storybook/stories/html-artifacts/report.html @@ -0,0 +1,29 @@ + + +Popular repositories with repeated Paperclip credit + +
    Research report · October 7, 2026
    +

    Popular repositories that use Paperclip a lot

    +

    Compare repository popularity with repeated, attributed usage. Adjust the weighting to explore the ranking.

    +
    + + +
    +

    +
    RankRepositoryStarsCreditsScore
    +Illustrative Storybook snapshot. Credits measure attribution, not unique users. + From 083073703086dd699f3f2bfd852e56c7150c2d09 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:17:18 -0500 Subject: [PATCH 07/55] feat(ui): add CSV table previews to task file tabs (#15445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and inspect their work. > - Task file tabs show the files that agents produce. > - Markdown has a readable view, but CSV files show only source text. > - People need to scan CSV reports without a separate app. > - This pull request adds a table view to attachment and workspace file tabs. > - View buttons let people inspect the original source beside download. ## Linked Issues or Issue Description **Subsystem affected** ui/ — task file tabs. **Problem or motivation** CSV exports are difficult to scan as source text. Related work: https://github.com/paperclipai/paperclip/pull/14297 added text attachment tabs. **Proposed solution** Render CSV as a table by default. Add row numbers, sticky headers, and record counts. Keep rendered and raw icons beside download in task tabs. **Roadmap alignment** This is a small improvement to existing task file previews. It does not add a new core subsystem. ## What Changed - Add a shared CSV parser and table preview. - Handle quoted commas, escaped quotes, UTF-8 BOM, CRLF, and multiline cells. - Show at most 500 data rows and 100 columns. Show a notice for larger files. Keep the complete download. - Add CSV attachment and workspace previews. Put workspace tab view controls in the header. - Add parser and interaction tests, Storybook examples, and product documentation. ## Verification - 28 focused tests pass across the parser, attachment tabs, and file viewer. - UI typecheck, UI build, and token gates pass. - Playwright checked desktop and mobile rendered → raw → rendered flows using the real attachment preview component in Storybook. Screenshots are recorded on the driving task. - Repository-wide typecheck and build were attempted. Both require cargo, which is absent in this environment. - The earlier repository-wide local test run stopped after a server test race failure. The prior commit passed all GitHub CI jobs. - Review fixes add source-line navigation coverage and CSV boundary tests. All 22 file-viewer tests pass. UI typecheck and token gates pass. - All 54 active GitHub checks pass on the final commit. Two Storybook checks are skipped. Greptile gives 5/5 with no actionable findings. - The final version preserves HTML previews from master and uses the shared toolbar control. All 35 focused tests, UI typecheck, UI build, and token gates pass. ## Risks - The first CSV record supplies column headers. Files without headers use that same rule. - The table preview is bounded. Download and raw view retain the complete file. - No API, database, or dependency changes. ## Model Used - OpenAI Codex, GPT-6. The runtime does not expose a more precise model ID or context window. Used code editing, shell tools, and browser checks. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/PRODUCT.md | 9 ++++ ui/src/components/CsvPreview.tsx | 41 ++++++++++++++++ .../components/FileViewerSheet.copy.test.tsx | 26 +++++++++- ui/src/components/FileViewerSheet.test.tsx | 48 +++++++++++++++++++ ui/src/components/FileViewerSheet.tsx | 41 +++++++++------- .../TaskAttachmentPanel.test.tsx | 16 +++++++ .../task-side-panel/TaskAttachmentPanel.tsx | 16 +++++-- .../TaskWorkspaceFilePanel.tsx | 16 +++---- ui/src/lib/csv-preview.test.ts | 23 +++++++++ ui/src/lib/csv-preview.ts | 29 +++++++++++ ui/storybook/stories/csv-preview.stories.tsx | 41 ++++++++++++++++ 11 files changed, 276 insertions(+), 30 deletions(-) create mode 100644 ui/src/components/CsvPreview.tsx create mode 100644 ui/src/lib/csv-preview.test.ts create mode 100644 ui/src/lib/csv-preview.ts create mode 100644 ui/storybook/stories/csv-preview.stories.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 97560d6d9ea..b47d36a50b4 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -232,3 +232,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread, Blocked, Recent, Everything) and the task-status views (All, Active, Backlog, Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that carry a task filter open All. Old `/inbox` links redirect to the matching view. + +### CSV file previews + +Task attachment and workspace file tabs render CSV files as tables by default. +The first record supplies column headers. Row numbers, row and column counts, +and sticky headers help operators scan exports. Rendered and raw view icons +remain next to download; raw view preserves the original source. The table +shows up to 500 data rows and 100 columns, with a notice when the preview is +limited. Download retains the complete file. diff --git a/ui/src/components/CsvPreview.tsx b/ui/src/components/CsvPreview.tsx new file mode 100644 index 00000000000..8a855aed044 --- /dev/null +++ b/ui/src/components/CsvPreview.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { parseCsv } from "@/lib/csv-preview"; + +const MAX_VISIBLE_ROWS = 500; +const MAX_VISIBLE_COLUMNS = 100; + +export function CsvPreview({ text, title }: { text: string; title: string }) { + const parsed = useMemo(() => { + try { return { rows: parseCsv(text), error: null }; } + catch (error) { return { rows: [], error: (error as Error).message }; } + }, [text]); + if (parsed.error) return

    {parsed.error}

    ; + if (!parsed.rows.length) return

    File is empty.

    ; + const [header, ...rows] = parsed.rows; + const columnCount = parsed.rows.reduce((count, row) => Math.max(count, row.length), 0); + const columns = Array.from({ length: Math.min(columnCount, MAX_VISIBLE_COLUMNS) }, (_, index) => index); + return ( +
    +
    + CSV preview + {rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"} + {columnCount.toLocaleString()} {columnCount === 1 ? "column" : "columns"} + {(rows.length > MAX_VISIBLE_ROWS || columnCount > MAX_VISIBLE_COLUMNS) && Showing the first {Math.min(rows.length, MAX_VISIBLE_ROWS)} rows and {columns.length} columns. Download for the complete file.} +
    +
    + + + + + {columns.map((column) => )} + + + {rows.slice(0, MAX_VISIBLE_ROWS).map((row, index) => + + {columns.map((column) => )} + )} +
    {title} — first record used as column headers
    Row#{header[column] || `Column ${column + 1}`}
    {index + 1}{row[column] ?? ""}
    +
    +
    + ); +} diff --git a/ui/src/components/FileViewerSheet.copy.test.tsx b/ui/src/components/FileViewerSheet.copy.test.tsx index 1eb5127b67c..027a66d0eec 100644 --- a/ui/src/components/FileViewerSheet.copy.test.tsx +++ b/ui/src/components/FileViewerSheet.copy.test.tsx @@ -4,7 +4,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedWorkspaceResource, WorkspaceFileContent } from "@paperclipai/shared"; -import { FileViewerSheet } from "./FileViewerSheet"; +import { FileContentViewer, FileViewerSheet } from "./FileViewerSheet"; const useQueryMock = vi.fn(); const viewerMock = { @@ -206,3 +206,27 @@ describe("FileViewerSheet copy actions", () => { expect(document.body.querySelector('[aria-label="launch.md rendered Markdown"]')).toBeNull(); }); }); + + +describe("source line navigation", () => { + it("scrolls plain source to its selected line when the toolbar requests rendered mode", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const scrollIntoView = vi.fn(); + const original = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + flushSync(() => root.render()); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "center", behavior: "auto" }); + expect(container.textContent).toContain("second"); + } finally { + flushSync(() => root.unmount()); + container.remove(); + HTMLElement.prototype.scrollIntoView = original; + } + }); +}); diff --git a/ui/src/components/FileViewerSheet.test.tsx b/ui/src/components/FileViewerSheet.test.tsx index 8e1ba75d02b..0a1ff0692d8 100644 --- a/ui/src/components/FileViewerSheet.test.tsx +++ b/ui/src/components/FileViewerSheet.test.tsx @@ -168,3 +168,51 @@ describe("FileViewerMetadataRow", () => { expect(markup).toContain("Loading file details"); }); }); + + +describe("workspace CSV content", () => { + const csv: WorkspaceFileContent = { + resource: { kind: "file", provider: "git_worktree", title: "export.csv", displayPath: "export.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: 20, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } }, + content: { encoding: "utf8", data: 'Name,Amount\n"Lee, Sam",12' }, + }; + it("defaults to a table and supports toolbar-controlled raw mode", () => { + const rendered = renderToStaticMarkup(); + expect(rendered).toContain("); + expect(raw).toContain("export.csv source"); + expect(raw).not.toContain(" { + it.each([[500, 2], [501, 2], [1, 100], [1, 101]])( + "bounds a file with %i rows and %i columns and preserves raw source", + (rowCount, columnCount) => { + const header = Array.from({ length: columnCount }, (_, i) => `Header${i}`).join(","); + const row = Array.from({ length: columnCount }, (_, i) => `Value${i}`).join(","); + const source = [header, ...Array.from({ length: rowCount }, () => row)].join("\n"); + const content: WorkspaceFileContent = { + resource: { kind: "file", provider: "git_worktree", title: "limits.csv", displayPath: "limits.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: source.length, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } }, + content: { encoding: "utf8", data: source }, + }; + const rendered = renderToStaticMarkup(); + expect((rendered.match(/ 500 || columnCount > 100) { + expect(rendered).toContain(`Showing the first ${Math.min(rowCount, 500)} rows and ${Math.min(columnCount, 100)} columns.`); + } else { + expect(rendered).not.toContain("Showing the first"); + } + const raw = renderToStaticMarkup(); + expect(raw).not.toContain(" void; + previewMode?: "raw" | "rendered"; htmlMode?: FilePreviewMode; } type MarkdownPreviewMode = "raw" | "rendered"; -export function FileContentViewer({ content, highlightedLine, onLoaded, htmlMode = "rendered" }: FileContentViewerProps) { +export function FileContentViewer({ content, highlightedLine, onLoaded, previewMode, htmlMode = "rendered" }: FileContentViewerProps) { const { resource } = content; const isMarkdown = resource.previewKind === "text" && content.content.encoding === "utf8" && isMarkdownResource(resource); - const isHtml = resource.previewKind === "text" && content.content.encoding === "utf8" - && isHtmlPreview(resource.contentType, resource.displayPath || resource.title); - const [markdownMode, setMarkdownMode] = useState("rendered"); + const isCsv = resource.previewKind === "text" && content.content.encoding === "utf8" && isCsvFile(resource.displayPath || resource.title, resource.contentType ?? ""); + const [localMode, setMarkdownMode] = useState("rendered"); + const markdownMode = isMarkdown || isCsv ? (previewMode ?? localMode) : "raw"; + const isHtml = resource.previewKind === "text" && content.content.encoding === "utf8" && isHtmlPreview(resource.contentType, resource.displayPath || resource.title); + const previewLabel = isCsv ? "CSV" : "Markdown"; const lines = useMemo(() => { if (resource.previewKind === "text") { return splitContentIntoLines(content.content.data); @@ -259,8 +264,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, htmlMode const highlightedLineRef = useRef(null); useEffect(() => { - setMarkdownMode(isMarkdown ? "rendered" : "raw"); - }, [isMarkdown, resource.displayPath, resource.title, resource.contentType]); + setMarkdownMode(isMarkdown || isCsv ? "rendered" : "raw"); + }, [isMarkdown, isCsv, resource.displayPath, resource.title, resource.contentType]); useEffect(() => { if (!lines) return; @@ -271,7 +276,7 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, htmlMode if (markdownMode !== "raw") return; if (!highlightedLine || !highlightedLineRef.current) return; highlightedLineRef.current.scrollIntoView({ block: "center", behavior: "auto" }); - }, [highlightedLine, markdownMode, htmlMode]); + }, [highlightedLine, markdownMode, htmlMode, previewMode]); if (resource.previewKind === "image") { const dataUrl = content.content.encoding === "base64" @@ -380,27 +385,27 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, htmlMode ); if (isHtml) { - return htmlMode === "raw" ? rawSourceView : ; + return (previewMode ?? htmlMode) === "raw" ? rawSourceView : ; } - if (!isMarkdown) { + if (!isMarkdown && !isCsv) { return rawSourceView; } return (
    -
    + {previewMode === undefined &&
    + } {markdownMode === "raw" ? ( rawSourceView - ) : ( + ) : isCsv ? : (
    void; onSetAnnouncement: (message: string) => void; onFallbackToProject: null | (() => void); + previewMode?: "raw" | "rendered"; htmlMode?: FilePreviewMode; } @@ -963,6 +970,7 @@ export function FileViewerBody({ onRetry, onSetAnnouncement, onFallbackToProject, + previewMode, htmlMode, }: FileViewerBodyProps) { if (resolveQuery.isFetching && !resolveQuery.data) { @@ -1061,6 +1069,7 @@ export function FileViewerBody({ diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx index a2726b25d0d..72ad95bac0e 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx @@ -54,6 +54,22 @@ describe("text attachment tabs", () => { expect(host.querySelector("[data-rendered]")).not.toBeNull(); await act(async () => root.unmount()); host.remove(); }); + it("renders CSV cells safely and switches to the exact raw source beside download", async () => { + const host = document.createElement("div"); const root = createRoot(host); + const text = 'Name,Notes,Amount\n"Lee, Sam","",12\nJo,"line 1\nline 2",'; + await act(async () => root.render()); + expect(host.querySelector("table")).not.toBeNull(); + expect(host.textContent).toContain("2 rows"); + expect(host.querySelector("td")?.textContent).toBe("Lee, Sam"); + expect(host.querySelector("script")).toBeNull(); + expect(host.querySelector("header")?.lastElementChild?.getAttribute("href")).toBe("/download"); + await act(async () => host.querySelector('[aria-label="Raw"]')!.click()); + expect(host.querySelector("pre")?.textContent).toBe(text); + expect(host.querySelector("table")).toBeNull(); + await act(async () => host.querySelector('[aria-label="Rendered"]')!.click()); + expect(host.querySelector("table")).not.toBeNull(); + await act(async () => root.unmount()); + }); it("shows plain text literally without Markdown controls", async () => { const host = document.createElement("div"); const root = createRoot(host); await act(async () => root.render()); diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx index dea605ae638..adda17ea9cf 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Download } from "lucide-react"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; +import { CsvPreview } from "@/components/CsvPreview"; +import { isCsvFile } from "@/lib/csv-preview"; import { MarkdownBody } from "@/components/MarkdownBody"; import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview"; import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; @@ -37,26 +39,30 @@ export async function readTextPreview(response: Response) { } } -export function TextAttachmentPreview({ title, text, markdown, html = false, downloadUrl }: { +export function TextAttachmentPreview({ title, text, markdown, html = false, csv = false, downloadUrl }: { title: string; text: string; markdown: boolean; html?: boolean; + csv?: boolean; downloadUrl: string; }) { const [mode, setMode] = useState("rendered"); + useEffect(() => setMode("rendered"), [title]); + const renderCsv = csv && mode === "rendered" && text.length > 0; const renderHtml = html && mode === "rendered" && text.length > 0; return (

    {title}

    - {markdown || html ? : null} + {markdown || html || csv ? : null}
    -
    +
    {text.length === 0 ?

    File is empty.

    + : renderCsv ? : renderHtml ? : markdown && mode === "rendered" ? {text} :
    {text}
    } @@ -96,5 +102,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string ); } if (content.data === undefined) return

    Loading file…

    ; - return ; + return ; } diff --git a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx index 802195472bc..874ffdee2cc 100644 --- a/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx +++ b/ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx @@ -1,3 +1,4 @@ +import { isCsvFile } from "@/lib/csv-preview"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, Download } from "lucide-react"; @@ -22,10 +23,10 @@ export function TaskWorkspaceFilePanel({ payload: WorkspaceFilePayload; onFallbackToProject?: () => void; }) { + const [previewMode, setPreviewMode] = useState<"raw" | "rendered">("rendered"); + useEffect(() => setPreviewMode("rendered"), [payload.path, payload.workspace, payload.workspaceId, payload.projectId]); const [copied, setCopied] = useState(false); const [announcement, setAnnouncement] = useState(""); - const [htmlMode, setHtmlMode] = useState("rendered"); - useEffect(() => setHtmlMode("rendered"), [payload.path, payload.workspace, payload.workspaceId, payload.projectId]); const state: FileViewerUrlState = { path: payload.path, workspace: payload.workspace, @@ -50,6 +51,8 @@ export function TaskWorkspaceFilePanel({ ? fileResourcesApi.downloadUrl(issueId, state) : null; + const renderedPreview = resource && (isHtmlPreview(resource.contentType, resource.displayPath || resource.title) || isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "") || /\.(md|markdown|mdown|mkdn|mkd)$/i.test(resource.displayPath || resource.title) || resource.contentType?.includes("markdown")); + return (
    @@ -76,10 +79,7 @@ export function TaskWorkspaceFilePanel({ {copied ? : } ) : null} - {contentQuery.data?.resource.previewKind === "text" && contentQuery.data.content.encoding === "utf8" - && isHtmlPreview(contentQuery.data.resource.contentType, contentQuery.data.resource.displayPath || contentQuery.data.resource.title) ? ( - - ) : null} + {renderedPreview && contentQuery.data ? : null} {downloadUrl ? (
    -
    +
    {announcement}
    diff --git a/ui/src/lib/csv-preview.test.ts b/ui/src/lib/csv-preview.test.ts new file mode 100644 index 00000000000..94e58739546 --- /dev/null +++ b/ui/src/lib/csv-preview.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { isCsvFile, parseCsv } from "./csv-preview"; + +describe("CSV preview parsing", () => { + it("handles BOM, CRLF, quoted commas, escaped quotes and multiline cells", () => { + expect(parseCsv('\uFEFFName,Notes,Amount\r\n"Lee, Sam","Said ""hello""\nagain",12\r\n')).toEqual([ + ["Name", "Notes", "Amount"], ["Lee, Sam", 'Said "hello"\nagain', "12"], + ]); + }); + it("preserves trailing empty fields, blank records and uneven rows", () => { + expect(parseCsv("a,b\n1,\n\n2,3,4")).toEqual([["a", "b"], ["1", ""], [""], ["2", "3", "4"]]); + expect(parseCsv("")).toEqual([]); + expect(parseCsv("a,b")).toEqual([["a", "b"]]); + }); + it("reports incomplete quoted fields", () => { + expect(() => parseCsv('a,b\n"unfinished')).toThrow("incomplete"); + }); + it("recognizes CSV filenames and media types", () => { + expect(isCsvFile("EXPORT.CSV", "application/octet-stream")).toBe(true); + expect(isCsvFile("download", "text/csv; charset=utf-8")).toBe(true); + expect(isCsvFile("notes.txt", "text/plain")).toBe(false); + }); +}); diff --git a/ui/src/lib/csv-preview.ts b/ui/src/lib/csv-preview.ts new file mode 100644 index 00000000000..a53b29bbd74 --- /dev/null +++ b/ui/src/lib/csv-preview.ts @@ -0,0 +1,29 @@ +export function isCsvFile(filename: string, contentType = "") { + return /\.csv$/i.test(filename) || contentType.split(";")[0].trim().toLowerCase() === "text/csv"; +} + +/** Parse comma-separated records, preserving quoted newlines and escaped quotes. */ +export function parseCsv(text: string): string[][] { + const source = text.replace(/^\uFEFF/, ""); + if (!source) return []; + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + for (let i = 0; i < source.length; i++) { + const char = source[i]; + if (char === '"') { + if (quoted && source[i + 1] === '"') { field += '"'; i++; } + else if (quoted || field === "") quoted = !quoted; + else field += char; + } else if (!quoted && char === ",") { + row.push(field); field = ""; + } else if (!quoted && (char === "\n" || char === "\r")) { + row.push(field); rows.push(row); row = []; field = ""; + if (char === "\r" && source[i + 1] === "\n") i++; + } else field += char; + } + if (quoted) throw new Error("A quoted field is incomplete. Switch to raw view to inspect the file."); + if (field !== "" || row.length || !/[\r\n]$/.test(source)) { row.push(field); rows.push(row); } + return rows; +} diff --git a/ui/storybook/stories/csv-preview.stories.tsx b/ui/storybook/stories/csv-preview.stories.tsx new file mode 100644 index 00000000000..40dc7e2b302 --- /dev/null +++ b/ui/storybook/stories/csv-preview.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspaceFilePanel"; +import { queryKeys } from "@/lib/queryKeys"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; + +const meta: Meta = { + title: "Components/CSV File Tab", + component: TextAttachmentPreview, + parameters: { layout: "fullscreen" }, + decorators: [(Story) =>
    ], +}; +export default meta; +type Story = StoryObj; +export const RevenueExport: Story = { + args: { + title: "monthly-revenue.csv", + markdown: false, + csv: true, + downloadUrl: "data:text/csv,Month%2CRevenue%0AOctober%2C12800", + text: 'Month,Customer,Plan,Revenue,Status,Notes\nOctober,"Acme, Inc.",Enterprise,12800,Active,"Expanded to 40 seats"\nOctober,Northstar Studio,Pro,2400,Active,"Annual renewal"\nOctober,Atlas Labs,Enterprise,9600,Active,"Includes onboarding"\nOctober,Juniper Design,Starter,480,Trial,"Review on Friday"\nSeptember,"Acme, Inc.",Enterprise,11200,Active,"32 seats"\nSeptember,Northstar Studio,Pro,2400,Active,"Annual renewal"\nSeptember,Atlas Labs,Pro,4800,Active,"Upgrade scheduled"\nSeptember,Juniper Design,Starter,480,Trial,"First month"', + }, +}; +export const Empty: Story = { args: { ...RevenueExport.args, text: "" } }; +export const Invalid: Story = { args: { ...RevenueExport.args, text: 'Name,Notes\nSam,"unfinished' } }; + +// Exercise the same CSV renderer through the workspace tab query boundary. +function WorkspaceCsvTab() { + const client = useQueryClient(); + const payload = { kind: "workspace-file" as const, path: "reports/monthly-revenue.csv", workspace: "auto" as const, projectId: null, workspaceId: null, line: null, column: null }; + const { kind: _kind, ...state } = payload; + useState(() => { + const resource = { kind: "file", provider: "git_worktree", title: "monthly-revenue.csv", displayPath: payload.path, workspaceLabel: "Report workspace", workspaceKind: "execution_workspace", workspaceId: "csv-story-workspace", contentType: "text/csv", byteSize: 512, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } }; + client.setQueryData(queryKeys.issues.fileResource("csv-story-task", state), resource); + client.setQueryData(queryKeys.issues.fileResourceContent("csv-story-task", state), { resource, content: { encoding: "utf8", data: RevenueExport.args!.text } }); + return true; + }); + return ; +} +export const WorkspaceTab: Story = { render: () => }; From f669194298ea36d5dab375f2b297a48266de80a3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:16:49 -0500 Subject: [PATCH 08/55] fix(runner): propagate configured environment to future turns (#15451) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native runners start provider processes and enforce a separate tool environment policy. > - The server resolves task environment bindings at each run boundary. > - Fixed launch allowlists dropped custom variables after resolution. > - Warm process reuse and a blanket fingerprint exclusion also hid configuration changes. > - This pull request carries a bounded, server-selected list of task variable names through each process boundary. > - The benefit is that later turns and tool commands receive configured values while ambient host secrets stay excluded. ## Linked Issues or Issue Description **What happened?** A native Codex agent could not use configured task credentials. Adding the variables in Settings did not fix the next turn. The provider sanitizer, runner launch, Rust provider launch, and shell policy each used fixed allowlists. Effective config fingerprints also excluded all variables with the `PAPERCLIP_` prefix. **Expected behavior** Explicitly bound task variables must reach provider processes and tool commands. Added, changed, and removed values must take effect at the next run boundary. A running turn keeps its original configuration. **Steps to reproduce** 1. Start a native Codex task without a custom environment binding. 2. Add a fake `PAPERCLIP_PAGE_BUCKET` value and a fake Pages credential binding in Settings. 3. Continue the task and inspect the tool environment. 4. Before this fix, those values are absent even from a fresh runner launch. **Paperclip version or commit** Reproduced at `b31558064`. The fix is rebased on current master. **Deployment mode** Self-hosted server with a native process runner. Related changes: [the legacy Codex MCP environment fix](https://github.com/paperclipai/paperclip/pull/13321) and [ambient server-secret exclusion](https://github.com/paperclipai/paperclip/pull/12870). These affect different launch paths. This change preserves their credential boundaries. ## What Changed - Capture scoped task bindings after resolution, before managed provider credential injection. Mint and validate the names-only projection at native dispatch before host inheritance. Legacy adapters retain their previous environment limits. - Strip user-supplied projection markers from agent, environment, project, and routine config. - Carry selected values through the Codex, ACPX, OpenCode, runnerd, and Rust subprocess launch boundaries. - Add selected names to native and ACPX Codex shell include lists. Keep selected values out of command arguments, including selected bootstrap values. - Reject malformed projections, reserved authority and loader names, missing values, null bytes, and oversized input. - Replace a retained native process when projected values change. Keep unchanged processes reusable. - Fingerprint custom namespaced variables while excluding known generated runtime variables. - Document next-run behavior and add regression coverage. ## Verification - Red: the permanent reproduction failed at four launch/tool boundaries and the namespaced fingerprint check. Two control checks passed. - Green: the initial regression plus existing Codex environment and shell tests passed (39 tests). - Server config resolution, fingerprints, and native-session suites passed (653 tests), including addition, rotation, removal, and unchanged warm-session reuse. - Rust regression tests passed. A real shell child received added and rotated values, then lost them after removal. Unselected host variables stayed absent. - `pnpm -r typecheck` and `pnpm build` passed after rebase. The full `pnpm test:run` was attempted but could not complete: fresh embedded PostgreSQL databases fail during bootstrap on this macOS host. An isolated suite and a disposable native `initdb` probe reproduced the failure before test execution. `shmget` reports `No space left on device` because the host has exhausted shared-memory IDs. This is not disk exhaustion. The run was stopped after confirming the external setup failure. CI results will be recorded separately. - Runner boundary suites: 361 tests passed. Two process-launch errors during concurrent binary staging passed on isolated rerun. - Rust Codex provider and process supervisor integration suites: 97 passed, 2 intentionally ignored. - ACPX shell and selected-bootstrap argv regressions: 3 failed before the fix, then all 55 relevant tests passed. - Review compatibility regression: 129-variable and large-value legacy configurations failed before the correction and passed after moving native-only validation to dispatch. - Final review head `3f11e8d25`: repository typechecks and production build passed. CI completed its implementation checks; one general-server shard hit SQL `40P01` in `heartbeat-runtime-skills.test.ts` during its `beforeEach` table truncate (1,199 tests passed in that shard). The shard passed on its single rerun. All CI checks for this head are green. Greptile reviewed this head at 5/5 with no new actionable findings; the compatibility thread is resolved. - No live provider credentials or model calls are required by these tests. ## Risks - Configured task credentials now reach the tools they were configured for. The controller selects names only after existing scope and secret-binding authorization. - TypeScript and Rust validate the same bounded projection. Their reserved-name rules must stay aligned. - A changed projection replaces an idle provider process. Unchanged values preserve reuse. Active turns retain their original environment. - No database migration or API schema change. > This fixes existing runner configuration behavior. It does not add a new roadmap capability. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, local code execution, and repository tools. The session does not expose a more specific API model identifier or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 2 +- .../runner-core/src/configured_environment.rs | 225 ++++++++++++++++++ .../runner/crates/runner-core/src/lib.rs | 1 + .../runner-core/src/process_supervisor.rs | 3 + .../src/configured-environment.test.ts | 99 ++++++++ .../src/configured-environment.ts | 76 ++++++ .../durable-prp-control-plane.ts | 3 +- .../src/drivers/acpx/environment.ts | 3 +- .../src/drivers/acpx/runtime-sandbox.test.ts | 17 ++ .../src/drivers/acpx/runtime-sandbox.ts | 9 +- .../src/drivers/codex/app-server-transport.ts | 3 +- .../drivers/codex/codex-security-config.ts | 8 +- packages/paperclip-runner/src/index.ts | 1 + .../src/live/runnerd-codex-transport.ts | 5 +- .../effective-run-config-fingerprints.test.ts | 27 +++ .../__tests__/heartbeat-project-env.test.ts | 39 +++ .../effective-run-config-fingerprints.ts | 4 +- server/src/services/heartbeat.ts | 10 +- .../native-session-executor.test.ts | 37 +++ .../native-runtime/native-session-executor.ts | 7 + server/src/vendor/paperclip-runner/index.ts | 5 + 21 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs create mode 100644 packages/paperclip-runner/src/configured-environment.test.ts create mode 100644 packages/paperclip-runner/src/configured-environment.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index cc6c53791cd..a25a6801f56 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -858,7 +858,7 @@ If a repository is detached or its source configuration changes, its previous ta ## Config Freshness -Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. +Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. Native runners project explicitly configured task environment variables into provider processes and tool commands, including custom `PAPERCLIP_*` names such as `PAPERCLIP_PAGE_BUCKET`. Adding, changing, or removing a projected variable replaces a retained provider process at the next run boundary. Unchanged variables permit process reuse. The projection contains names only; secret values stay in the child environment. Ambient host secrets are not projected. Native projections allow up to 128 variable names, 64 KiB per entry, and 256 KiB of values in total. These native launch limits do not apply to legacy adapter configuration. Runtime authority, provider login credentials, and process-loader settings use their existing restricted paths. When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs new file mode 100644 index 00000000000..27c0c24b004 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs @@ -0,0 +1,225 @@ +//! Controller-selected task environment; mirrors configured-environment.ts. +use crate::local_runner::LocalRunnerError; +use std::process::Command; + +const MARKER: &str = "PAPERCLIP_CONFIGURED_ENV_KEYS"; +const RESERVED: &[&str] = &[ + "AGENT_HOME", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "BASH_ENV", + "CLAUDE_CODE_OAUTH_TOKEN", + "CODEX_API_KEY", + "CODEX_HOME", + "COPILOT_GITHUB_TOKEN", + "CURSOR_API_KEY", + "CURSOR_AUTH_TOKEN", + "ENV", + "GH_ENTERPRISE_TOKEN", + "GH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITHUB_TOKEN", + "HOME", + "NODE_OPTIONS", + "NODE_PATH", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "PAPERCLIP_ACPX_CREDENTIAL_BINDING", + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_MESSAGE_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AI_PROVIDER_KEY", + "PAPERCLIP_API_BRIDGE_MODE", + "PAPERCLIP_API_KEY", + "PAPERCLIP_API_URL", + "PAPERCLIP_APPROVAL_ID", + "PAPERCLIP_APPROVAL_STATUS", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_CONFIGURED_ENV_KEYS", + "PAPERCLIP_EXECUTION_MODE", + "PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY", + "PAPERCLIP_GITHUB_AUTH_MODE", + "PAPERCLIP_GITHUB_BRIDGE_TOKEN", + "PAPERCLIP_GITHUB_BROKER_TOKEN", + "PAPERCLIP_GITHUB_BROKER_URL", + "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GITHUB_LAUNCHER_DIR", + "PAPERCLIP_GIT_METADATA_ROOTS", + "PAPERCLIP_GIT_TOKEN", + "PAPERCLIP_HARNESS_CHECKOUT_KEY", + "PAPERCLIP_INSTANCE_ID", + "PAPERCLIP_LINKED_ISSUE_IDS", + "PAPERCLIP_NATIVE_MCP_NAME", + "PAPERCLIP_NATIVE_MCP_TOKEN", + "PAPERCLIP_NATIVE_MCP_URL", + "PAPERCLIP_NORMALIZED_SESSION_ID", + "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", + "PAPERCLIP_PROVIDER_TRACE_PATH", + "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET", + "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", + "PAPERCLIP_RUNNER_INSTANCE_ID", + "PAPERCLIP_RUNNER_NETWORK_ACCESS", + "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_RUNTIME_PRIMARY_URL", + "PAPERCLIP_RUNTIME_SERVICES_JSON", + "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON", + "PAPERCLIP_RUN_ID", + "PAPERCLIP_TASK_ID", + "PAPERCLIP_WAKE_COMMENT_ID", + "PAPERCLIP_WAKE_PAYLOAD_JSON", + "PAPERCLIP_WAKE_REASON", + "PAPERCLIP_WORKSPACES_JSON", + "PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT", + "PAPERCLIP_WORKSPACE_BRANCH", + "PAPERCLIP_WORKSPACE_CWD", + "PAPERCLIP_WORKSPACE_ID", + "PAPERCLIP_WORKSPACE_REALIZATION_MODE", + "PAPERCLIP_WORKSPACE_REPO_REF", + "PAPERCLIP_WORKSPACE_REPO_URL", + "PAPERCLIP_WORKSPACE_SOURCE", + "PAPERCLIP_WORKSPACE_WORKTREE_PATH", + "PATH", + "PERL5LIB", + "PERL5OPT", + "PYTHONHOME", + "PYTHONPATH", + "RUBYLIB", + "RUBYOPT", + "SHELL", + "USERPROFILE", + "XAI_API_KEY", +]; + +fn eligible_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + !key.is_empty() + && key.len() <= 128 + && key + .bytes() + .enumerate() + .all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit())) + && !RESERVED.contains(&upper.as_str()) + && ![ + "LD_", + "DYLD_", + "GIT_CONFIG_", + "PAPERCLIP_RUNNER_", + "PAPERCLIP_NATIVE_", + "PAPERCLIP_GITHUB_", + "PAPERCLIP_ACPX_", + "PAPERCLIP_VERIFIED_", + ] + .iter() + .any(|prefix| upper.starts_with(prefix)) +} + +pub(crate) fn apply_configured_environment( + command: &mut Command, + lookup: impl Fn(&str) -> Option, +) -> Result<(), LocalRunnerError> { + let Some(raw) = lookup(MARKER) else { + return Ok(()); + }; + let invalid = || LocalRunnerError::invalid("invalid configured environment projection"); + if raw.len() > 20_000 { + return Err(invalid()); + } + let mut names: Vec = serde_json::from_str(&raw).map_err(|_| invalid())?; + if names.len() > 128 || names.iter().any(|name| !eligible_key(name)) { + return Err(invalid()); + } + names.sort(); + if names.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(invalid()); + } + let mut bytes = 0; + // Validate all values before modifying the child launch specification. + let mut values = Vec::new(); + for name in &names { + let value = lookup(name).ok_or_else(invalid)?; + let size = name.len() + value.len(); + bytes += size; + if value.contains('\0') || size > 65_536 || bytes > 262_144 { + return Err(invalid()); + } + values.push((name, value)); + } + command.envs(values); + command.env( + MARKER, + serde_json::to_string(&names).map_err(|_| invalid())?, + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn rejects_reserved_malformed_missing_and_oversized_environment() { + for raw in [ + "not-json", + "[1]", + "[\"NODE_OPTIONS\"]", + "[\"PAPERCLIP_API_KEY\"]", + "[\"PAPERCLIP_RUNNER_BOOTSTRAP_TICKET\"]", + "[\"OPENAI_API_KEY\"]", + "[\"LD_PRELOAD\"]", + "[\"bad=name\"]", + "[\"CUSTOM\",\"CUSTOM\"]", + "[\"MISSING\"]", + ] { + let mut command = Command::new("unused"); + assert!( + apply_configured_environment(&mut command, |key| if key == MARKER { + Some(raw.into()) + } else { + None + }) + .is_err() + ); + assert_eq!(command.get_envs().count(), 0); + } + let mut command = Command::new("unused"); + assert!( + apply_configured_environment(&mut command, |key| Some(if key == MARKER { + "[\"CUSTOM\"]".into() + } else { + "x".repeat(65_536) + })) + .is_err() + ); + } + + #[test] + #[cfg(unix)] + fn child_receives_only_selected_task_values_across_configuration_changes() { + for secret in [None, Some("first-secret"), Some("rotated-secret"), None] { + let mut source = BTreeMap::from([ + ("HOST_SECRET", "unbound-secret".to_string()), + ("NODE_OPTIONS", "unbound-loader".to_string()), + ]); + let names = if let Some(value) = secret { + source.insert("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", value.into()); + vec!["PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"] + } else { + vec![] + }; + source.insert(MARKER, serde_json::to_string(&names).unwrap()); + let mut command = Command::new("/bin/sh"); + command.env_clear().args(["-c", "printf '%s|%s|%s' \"$PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY\" \"$HOST_SECRET\" \"$NODE_OPTIONS\""]); + apply_configured_environment(&mut command, |key| source.get(key).cloned()).unwrap(); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!("{}||", secret.unwrap_or_default()) + ); + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index ab2e2774dfa..a96f4cb07c5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod aws_agentcore_provider; pub mod claude_managed_provider; pub mod codex_provider; mod codex_startup_trust; +mod configured_environment; pub mod durable; pub mod fake_harness; mod generated_acpx_profiles; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index af12df41e53..415a72186b5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -780,6 +780,9 @@ impl SupervisedProcess { command.env(key, value); } } + crate::configured_environment::apply_configured_environment(&mut command, |key| { + std::env::var(key).ok() + })?; if let Some(executable) = verified_runtime_executable { // Node reports a sealed memfd launch as `/memfd:... (deleted)` via // process.execPath. Give descriptor-loaded runtimes the inherited, diff --git a/packages/paperclip-runner/src/configured-environment.test.ts b/packages/paperclip-runner/src/configured-environment.test.ts new file mode 100644 index 00000000000..94313010790 --- /dev/null +++ b/packages/paperclip-runner/src/configured-environment.test.ts @@ -0,0 +1,99 @@ +import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentProjection } from './configured-environment.js'; +import { describe, expect, it, vi } from 'vitest'; +import { createSanitizedCodexEnvironment } from './drivers/codex/app-server-transport.js'; +import { createIsolatedCodexAppServerArgs } from './drivers/codex/codex-security-config.js'; +import { createCapabilityRunnerdProviderEnvironment } from './live/runnerd-codex-transport.js'; +import { spawnRunner } from './control-plane/durable-prp-control-plane.js'; + +const pages = { + PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: 'fake-page-access-key', + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: 'fake-page-secret', + PAPERCLIP_PAGE_BUCKET: 'pages.example.test', +}; +const source = { ...configuredEnvironmentProjection(pages), PATH: '/bin', ...pages, DATABASE_URL: 'host-secret-must-not-cross', NODE_OPTIONS: '--require=/untrusted/bootstrap.js', PAPERCLIP_API_KEY: 'controller-api-key-must-not-cross' }; +const identity = { runnerInstanceId: 'runner-proof', environmentLeaseId: 'lease-proof', runId: 'run-proof', normalizedSessionId: 'session-proof', turnId: 'turn-proof', itemId: 'item-proof' }; +function shellKeys() { + const args = createIsolatedCodexAppServerArgs(source); + const setting = args.find(value => value.startsWith('shell_environment_policy.include_only='))!; + return { keys: JSON.parse(setting.slice(setting.indexOf('=') + 1)) as string[], serialized: args.join('\n') }; +} + +describe('controller-configured environment across native runner boundaries', () => { + it('retains configured Pages values at the native Codex provider sanitizer', () => { + expect(createSanitizedCodexEnvironment(source)).toMatchObject(pages); + }); + it('retains configured Pages values when preparing a fresh runnerd process', () => { + expect(createCapabilityRunnerdProviderEnvironment({ provider: 'codex', options: { environment: source }, identity, codexHome: '/isolated/codex-home', runtimeContextPath: '/isolated/runtime-context.json', hasRuntimeContext: false })).toMatchObject(pages); + }); + it('passes configured Pages values through the runner spawn boundary', () => { + let launched: NodeJS.ProcessEnv | undefined; + spawnRunner({ connection: { mode: 'connect', connectUrl: 'ws://127.0.0.1:43127' }, stateDirectory: '/tmp/runner-env-proof', identity, ticket: 'fake-ticket', maxOutboxBytes: 262144, p0ReserveBytes: 65536, runnerVersion: '0.3.0', runnerDigest: `sha256:${'a'.repeat(64)}`, environment: source, + processLauncher: spec => { launched = spec.environment; return { child: { pid: 42, exitCode: null, signalCode: null, kill: () => true }, completion: Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '' }) }; }, + }); + expect(launched).toMatchObject(pages); + expect(launched!.DATABASE_URL).toBeUndefined(); + expect(launched!.NODE_OPTIONS).toBeUndefined(); + expect(launched!.PAPERCLIP_API_KEY).toBeUndefined(); + }); + it('allows configured Pages values into model-issued shell commands without serializing secrets into argv', () => { + const { keys, serialized } = shellKeys(); + expect(keys).toEqual(expect.arrayContaining(Object.keys(pages))); + expect(keys).not.toContain('DATABASE_URL'); + expect(keys).not.toContain('NODE_OPTIONS'); + expect(keys).not.toContain('PAPERCLIP_API_KEY'); + for (const value of Object.values(pages)) expect(serialized).not.toContain(value); + }); + it('continues to exclude unbound ambient Pages credentials and host secrets', () => { + vi.stubEnv('PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY', 'ambient-page-secret'); + try { + expect(createSanitizedCodexEnvironment().PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY).toBeUndefined(); + const sanitized = createSanitizedCodexEnvironment(source); + expect(sanitized.DATABASE_URL).toBeUndefined(); + expect(sanitized.NODE_OPTIONS).toBeUndefined(); + expect(sanitized.PAPERCLIP_API_KEY).toBeUndefined(); + } finally { vi.unstubAllEnvs(); } + }); +}); + + +describe("configured environment authority", () => { + it("keeps selected bootstrap values in the environment rather than argv", () => { + const environment = { PATH: "/bin", ...configuredEnvironmentProjection({ LANG: "configured-language-value" }) }; + const args = createIsolatedCodexAppServerArgs(environment); + expect(args.join("\n")).not.toContain("configured-language-value"); + expect(args.find(arg => arg.startsWith("shell_environment_policy.include_only="))).toContain("LANG"); + }); + + it("projects ordinary task credentials only when explicitly selected", () => { + const projected = configuredEnvironmentProjection({ DATABASE_URL: "task-db", CUSTOM_FLAG: "on", NODE_OPTIONS: "unsafe", PAPERCLIP_API_KEY: "controller" }); + expect(createSanitizedCodexEnvironment(projected)).toMatchObject({ DATABASE_URL: "task-db", CUSTOM_FLAG: "on" }); + expect(projected.NODE_OPTIONS).toBeUndefined(); + expect(projected.PAPERCLIP_API_KEY).toBeUndefined(); + }); + + it("does not accept an ambient projection in the provider or shell policy", () => { + vi.stubEnv(CONFIGURED_ENVIRONMENT_KEYS, JSON.stringify(["PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"])); + vi.stubEnv("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", "ambient-secret"); + try { + expect(createSanitizedCodexEnvironment().PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY).toBeUndefined(); + expect(createIsolatedCodexAppServerArgs().join("\n")).not.toContain("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"); + } finally { vi.unstubAllEnvs(); } + }); + + it.each(["not-json", "[1]", '["NODE_OPTIONS"]', '["PAPERCLIP_API_KEY"]', '["PAPERCLIP_RUNNER_BOOTSTRAP_TICKET"]', '["OPENAI_API_KEY"]', '["LD_PRELOAD"]', '["bad=name"]', '["CUSTOM","CUSTOM"]', '["MISSING"]']) + ("rejects malformed or reserved projection %s", raw => { + expect(() => configuredEnvironment({ [CONFIGURED_ENVIRONMENT_KEYS]: raw })).toThrow("Invalid configured environment"); + }); + + it("rejects oversized and null-containing values without reporting their contents", () => { + for (const value of ["x".repeat(65_536), "secret\0suffix"]) { + expect(() => configuredEnvironmentProjection({ CUSTOM: value })).toThrow(/configured environment|Configured environment/); + } + }); + + it("preserves configured values through ACPX and OpenCode preparation", async () => { + const { createSanitizedAcpxSpawnInput } = await import("./drivers/acpx/environment.js"); + expect(createSanitizedAcpxSpawnInput(source, "codex").env).toMatchObject(pages); + expect(createCapabilityRunnerdProviderEnvironment({ provider: "opencode", options: { environment: source }, identity, codexHome: "/isolated/home", runtimeContextPath: "/isolated/context", hasRuntimeContext: false })).toMatchObject(pages); + }); +}); diff --git a/packages/paperclip-runner/src/configured-environment.ts b/packages/paperclip-runner/src/configured-environment.ts new file mode 100644 index 00000000000..bd10a7e7813 --- /dev/null +++ b/packages/paperclip-runner/src/configured-environment.ts @@ -0,0 +1,76 @@ +/** Names minted by the controller from the resolved task configuration. + * Values remain in the process environment, never in argv or this manifest. + * Do not mint this projection from ambient process.env. + */ +export const CONFIGURED_ENVIRONMENT_KEYS = "PAPERCLIP_CONFIGURED_ENV_KEYS"; + +export const GENERATED_RUNTIME_ENVIRONMENT_KEYS: ReadonlySet = new Set([ + CONFIGURED_ENVIRONMENT_KEYS, + "PAPERCLIP_AGENT_ID", "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + "PAPERCLIP_API_KEY", "PAPERCLIP_API_URL", "PAPERCLIP_API_BRIDGE_MODE", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_INSTANCE_ID", + "PAPERCLIP_RUN_ID", "PAPERCLIP_NORMALIZED_SESSION_ID", "PAPERCLIP_RUNNER_INSTANCE_ID", "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET", "PAPERCLIP_TASK_ID", "PAPERCLIP_APPROVAL_ID", "PAPERCLIP_APPROVAL_STATUS", "PAPERCLIP_LINKED_ISSUE_IDS", + "PAPERCLIP_WAKE_REASON", "PAPERCLIP_WAKE_COMMENT_ID", "PAPERCLIP_WAKE_PAYLOAD_JSON", "PAPERCLIP_EXECUTION_MODE", + "PAPERCLIP_WORKSPACES_JSON", "PAPERCLIP_WORKSPACE_ID", "PAPERCLIP_WORKSPACE_CWD", "PAPERCLIP_WORKSPACE_SOURCE", + "PAPERCLIP_WORKSPACE_REPO_URL", "PAPERCLIP_WORKSPACE_REPO_REF", "PAPERCLIP_WORKSPACE_BRANCH", + "PAPERCLIP_WORKSPACE_WORKTREE_PATH", "PAPERCLIP_WORKSPACE_REALIZATION_MODE", "PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT", + "PAPERCLIP_RUNTIME_PRIMARY_URL", "PAPERCLIP_RUNTIME_SERVICES_JSON", "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON", + "PAPERCLIP_AGENT_MESSAGE_KEY", "PAPERCLIP_HARNESS_CHECKOUT_KEY", "PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY", + "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_AI_PROVIDER_KEY", + "PAPERCLIP_PROVIDER_TRACE_PATH", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", "PAPERCLIP_ACPX_CREDENTIAL_BINDING", + "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", + "PAPERCLIP_GITHUB_AUTH_MODE", "PAPERCLIP_GITHUB_HOST_HOME", "PAPERCLIP_GIT_METADATA_ROOTS", + "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_BROKER_URL", "PAPERCLIP_GITHUB_BRIDGE_TOKEN", "PAPERCLIP_GITHUB_LAUNCHER_DIR", +]); + +const RESERVED_KEYS = new Set([ + "PATH", "HOME", "USERPROFILE", "CODEX_HOME", "AGENT_HOME", "SHELL", "NODE_OPTIONS", "NODE_PATH", + "PYTHONPATH", "PYTHONHOME", "RUBYOPT", "RUBYLIB", "PERL5OPT", "PERL5LIB", "BASH_ENV", "ENV", + "OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", "OPENROUTER_API_KEY", "XAI_API_KEY", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", + "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", +]); + +function eligibleKey(key: string): boolean { + const upper = key.toUpperCase(); + return /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) + && !GENERATED_RUNTIME_ENVIRONMENT_KEYS.has(upper) + && !RESERVED_KEYS.has(upper) + && !/^(?:LD_|DYLD_|GIT_CONFIG_|PAPERCLIP_(?:RUNNER_|NATIVE_|GITHUB_|ACPX_|VERIFIED_))/.test(upper); +} + +function checkedNames(names: unknown): string[] { + if (!Array.isArray(names) || names.length > 128 || names.some(name => typeof name !== "string" || !eligibleKey(name)) + || new Set(names).size !== names.length) throw new Error("Invalid configured environment projection"); + return (names as string[]).sort(); +} + +/** Mint at the server's resolved configuration boundary, before host inheritance. */ +export function configuredEnvironmentProjection(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const names = checkedNames(Object.keys(source).filter(key => source[key] !== undefined && eligibleKey(key))); + return configuredEnvironment({ ...source, [CONFIGURED_ENVIRONMENT_KEYS]: JSON.stringify(names) }); +} + +/** Accept only an explicitly supplied, controller-minted projection. */ +export function configuredEnvironment(source: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const raw = source?.[CONFIGURED_ENVIRONMENT_KEYS]; + if (raw === undefined) return {}; + if (Buffer.byteLength(raw) > 20_000) throw new Error("Invalid configured environment projection"); + let names: string[]; + try { names = checkedNames(JSON.parse(raw)); } catch { throw new Error("Invalid configured environment projection"); } + const result: NodeJS.ProcessEnv = {}; + let bytes = 0; + for (const name of names) { + const value = source?.[name]; + if (typeof value !== "string" || value.includes("\0")) throw new Error("Invalid configured environment value"); + const size = Buffer.byteLength(name) + Buffer.byteLength(value); + bytes += size; + if (size > 65_536 || bytes > 262_144) throw new Error("Configured environment exceeds its bounded launch size"); + result[name] = value; + } + result[CONFIGURED_ENVIRONMENT_KEYS] = JSON.stringify(names); + return result; +} + +export function configuredEnvironmentKeys(source: NodeJS.ProcessEnv | undefined): string[] { + return Object.keys(configuredEnvironment(source)).filter(key => key !== CONFIGURED_ENVIRONMENT_KEYS).sort(); +} diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 4c4e20c73f3..27e8a2b1d05 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -1,3 +1,4 @@ +import { configuredEnvironment } from "../configured-environment.js"; import { spawn } from "node:child_process"; import { createCipheriv, @@ -3440,7 +3441,7 @@ function runnerEnvironment( if (explicitSource[key] !== undefined) environment[key] = explicitSource[key]; } } - Object.assign(environment, githubCredentialEnvironment(explicitSource)); + Object.assign(environment, githubCredentialEnvironment(explicitSource), configuredEnvironment(explicitSource)); } return environment; } diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 2ecbf367474..069333204bf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -1,3 +1,4 @@ +import { configuredEnvironment } from "../../configured-environment.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING"; @@ -87,7 +88,7 @@ export function createSanitizedAcpxSpawnInput( ): SanitizedAcpxSpawnInput { const source = environment ?? process.env; const candidate = isCandidate(agent); - const result: NodeJS.ProcessEnv = {}; + const result: NodeJS.ProcessEnv = configuredEnvironment(environment); const credentialNames = ACPX_CREDENTIAL_NAMES[agent]; const allowed = new Set([ "PATH", diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index 53423467c92..fbe2ff00faa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -1,3 +1,4 @@ +import { configuredEnvironmentProjection } from "../../configured-environment.js"; import { chmod, lstat, @@ -34,6 +35,22 @@ afterEach(async () => { }); describe("ACPX runtime sandbox", () => { + it.each([false, true])("allows selected task secrets in Codex shells (agent identity: %s)", async identity => { + const fixture = await sandboxFixture("codex"); + const pages = { PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "task-pages-secret", CUSTOM_TOKEN: "task-service-secret" }; + const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "codex", environment: { + PATH: process.env.PATH, OPENAI_API_KEY: "provider-secret", HOST_SECRET: "unselected-secret", + ...configuredEnvironmentProjection(pages), ...(identity ? { PAPERCLIP_AGENT_KEY_ID: "agent-key" } : {}), + } }); + const config = await readFile(join(sandbox.agentHomeDirectory, "config.toml"), "utf8"); + const keys = JSON.parse(config.split("\n").find(line => line.startsWith("include_only = "))?.slice("include_only = ".length) ?? "[]"); + expect(keys).toEqual(expect.arrayContaining(Object.keys(pages))); + expect(keys).not.toContain("OPENAI_API_KEY"); + expect(keys).not.toContain("HOST_SECRET"); + expect(sandbox.launchEnvironment).toMatchObject(pages); + for (const value of Object.values(pages)) expect(config).not.toContain(value); + }); + it("restores Grok permission controls on recovery without importing compatible hooks or credentials", async () => { const fixture = await sandboxFixture("grok"); const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "grok" }); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 74c2e1c45a1..f38f174712d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,3 +1,4 @@ +import { configuredEnvironmentKeys } from "../../configured-environment.js"; import { randomBytes } from "node:crypto"; import { constants, @@ -443,6 +444,7 @@ export async function prepareAcpxRuntimeSandbox(input: { })}\n`); } if (input.agent === "codex") { + const taskEnvironmentKeys = configuredEnvironmentKeys(input.environment); await writePrivateFile( join(agentHomeDirectory, "config.toml"), [ @@ -454,14 +456,15 @@ export async function prepareAcpxRuntimeSandbox(input: { // also affect provider startup and belongs at the launch boundary. "[features]", "shell_snapshot = false", - ...(input.environment?.PAPERCLIP_AGENT_KEY_ID ? [ + ...(input.environment?.PAPERCLIP_AGENT_KEY_ID || taskEnvironmentKeys.length > 0 ? [ "[shell_environment_policy]", 'inherit = "all"', "ignore_default_excludes = true", `include_only = ${JSON.stringify([...new Set([ "PATH", "HOME", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME", "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", - // Provider/config secrets keep Codex's default shell exclusions; + ...taskEnvironmentKeys, + // Unselected provider/config secrets keep Codex's default shell exclusions; // only Paperclip's scoped API token is required by Bash/curl skills. - ...Object.keys(input.environment).filter(key => key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), + ...Object.keys(input.environment ?? {}).filter(key => key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), ])])}`, ] : []), "", diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 53ce851d113..5e6fac128da 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -1,3 +1,4 @@ +import { configuredEnvironment } from "../../configured-environment.js"; import type { NativeTurnControlCapabilities } from "../../contracts/types.js"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; @@ -243,7 +244,7 @@ export function createSanitizedCodexEnvironment( for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) { if (explicit?.[key] !== undefined) environment[key] = explicit[key]; } - Object.assign(environment, githubCredentialEnvironment(source)); + Object.assign(environment, githubCredentialEnvironment(source), configuredEnvironment(explicit)); return environment; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 3cc702004c2..f363bd8163b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -1,3 +1,4 @@ +import { configuredEnvironmentKeys } from "../../configured-environment.js"; import { resolve, isAbsolute, join, dirname, delimiter } from "node:path"; import { existsSync, realpathSync, readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; @@ -167,23 +168,28 @@ function tomlString(value: string): string { } export function createIsolatedCodexAppServerArgs( - source: NodeJS.ProcessEnv = process.env, + source: NodeJS.ProcessEnv | undefined = undefined, readOnlyRoots: string[] = [], /** Server-registered run copy, never an environment/config-supplied root. */ instructionWorkingCopyRoot?: string, ): string[] { + const explicitSource = source; + source ??= process.env; const gitRoots = gitFilesystemRoots(source); readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])]; const networkAccess = codexNetworkAccess(source); const externalRunnerSandbox = usesExternalRunnerSandbox(source); const inheritedGitHubKeys = [ ...githubCredentialEnvironmentKeys(source), + ...configuredEnvironmentKeys(explicitSource), ...["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"].filter(key => source[key] !== undefined), ]; const hasProjectedEnvironment = inheritedGitHubKeys.length > 0; // Codex filters the configured `set` values through include_only as well. // Retain the explicit command PATH/HOME/locale settings, not ambient secrets. const commandEnvironment = codexCommandEnvironment(source); + // Selected task values are inherited, never serialized into configuration argv. + for (const key of configuredEnvironmentKeys(explicitSource)) delete commandEnvironment[key]; if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot; const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort(); if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR]; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index fb89bc147b8..ba50467357b 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -1,3 +1,4 @@ +export * from "./configured-environment.js"; export * from "./catalog/index.js"; export { CURSOR_DISTRIBUTION_PINS } from "./drivers/acpx/generated-profiles.js"; export * from "./contracts/control-plane-port.js"; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 5b92af0a3ee..ba6f3f32487 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { configuredEnvironment } from "../configured-environment.js"; import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; import { isAcpxCanonicalInputMethod } from "../drivers/acpx/profile-extensions.js"; import { RunnerdTraceFrameIndex } from "./runnerd-trace-frame-index.js"; @@ -3339,14 +3340,14 @@ function createSanitizedOpenCodeRunnerEnvironment( delete candidate[key]; if (source?.[key] !== undefined) candidate[key] = source[key]; } - return Object.fromEntries( + return { ...configuredEnvironment(source), ...Object.fromEntries( Object.entries(candidate).filter( ([key, value]) => typeof value === "string" && (OPEN_CODE_RUNNER_ENVIRONMENT_KEYS.has(key) || /^LC_[A-Z0-9_]{1,32}$/.test(key)), ), - ); + ) }; } export function resolveSourceCodexHome( diff --git a/server/src/__tests__/effective-run-config-fingerprints.test.ts b/server/src/__tests__/effective-run-config-fingerprints.test.ts index 02afa38a4c4..aceed12fd7d 100644 --- a/server/src/__tests__/effective-run-config-fingerprints.test.ts +++ b/server/src/__tests__/effective-run-config-fingerprints.test.ts @@ -3,6 +3,7 @@ import { canonicalizeEffectiveRunConfigCategory, createEffectiveRunConfigFingerprints, diffEffectiveRunConfigFingerprints, + type EffectiveRunConfigSecretManifestEntry, } from "../services/effective-run-config-fingerprints.ts"; describe("effective run config fingerprints", () => { @@ -301,3 +302,29 @@ describe("effective run config fingerprints", () => { }); }); }); + + +describe("configured Paperclip environment freshness", () => { + const fingerprint = (env: Record, secretManifest?: EffectiveRunConfigSecretManifestEntry[]) => + createEffectiveRunConfigFingerprints({ session: { env }, secretManifest }).sessionFingerprint; + + it("detects additions, changes, and removals without recording values", () => { + const absent = fingerprint({}); + const first = fingerprint({ PAPERCLIP_PAGE_BUCKET: "first-bucket" }); + const changed = fingerprint({ PAPERCLIP_PAGE_BUCKET: "second-bucket" }); + expect(first.fingerprint).not.toBe(absent.fingerprint); + expect(changed.fingerprint).not.toBe(first.fingerprint); + expect(fingerprint({}).fingerprint).toBe(absent.fingerprint); + expect(first.canonicalJson).not.toContain("first-bucket"); + for (const key of ["PAPERCLIP_RUN_ID", "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", "PAPERCLIP_NORMALIZED_SESSION_ID"]) { + expect(fingerprint({ [key]: "first" }).fingerprint).toBe(fingerprint({ [key]: "second" }).fingerprint); + } + }); + + it("detects custom namespaced secret version changes", () => { + const env = { PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: { type: "secret_ref", secretId: "pages-key" } }; + const manifest = (version: number) => [{ configPath: "env.PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", envKey: "PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", secretId: "pages-key", version }]; + expect(fingerprint(env, manifest(1)).fingerprint).not.toBe(fingerprint(env, manifest(2)).fingerprint); + expect(fingerprint(env, manifest(1)).canonicalJson).toContain("pages-key"); + }); +}); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index b14ba118ae4..fe200aa1dd9 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -21,6 +21,45 @@ import type { AuthorizationActor, AuthorizationDecision } from "../services/auth import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts"; describe("resolveExecutionRunAdapterConfig", () => { + it.each(["many variables", "large value"])("keeps legacy adapter configuration compatible with %s", async kind => { + const env = kind === "many variables" + ? Object.fromEntries(Array.from({ length: 129 }, (_, index) => [`CUSTOM_${index}`, "value"])) + : { CUSTOM_VALUE: "x".repeat(65_536) }; + const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", adapterType: "process", executionRunConfig: { env }, secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn(async (_companyId, config) => ({ config, secretKeys: new Set(), manifest: [] })), + } as any }); + expect(result.resolvedConfig.env).toEqual(env); + }); + + it("captures scoped resolved task values and rejects configured markers", async () => { + const key = "PAPERCLIP_CONFIGURED_ENV_KEYS"; + const secret = { type: "secret_ref", secretId: "pages-secret" }; + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", agentId: "agent-1", environmentId: "environment-1", projectId: "project-1", routineId: "routine-1", + executionRunConfig: { env: { PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: secret, SHARED: "agent", [key]: '["HOST_SECRET"]' } }, + environmentEnv: { SHARED: "environment", [key]: '["HOST_SECRET"]' }, + projectEnv: { SHARED: "project", [key]: '["HOST_SECRET"]' }, + routineEnv: { SHARED: "routine", [key]: '["HOST_SECRET"]' }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn(async (companyId, config) => { + expect(companyId).toBe("company-1"); + expect(config.env[key]).toBeUndefined(); + return { config: { ...config, env: { ...config.env, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "resolved-task-secret" } }, secretKeys: new Set(["PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"]), manifest: [] }; + }), + resolveEnvBindings: vi.fn(async (companyId, env) => { + expect(companyId).toBe("company-1"); + expect(env[key]).toBeUndefined(); + return { env, secretKeys: new Set(), manifest: [] }; + }), + } as any, + }); + expect(result.configuredTaskEnvironment).toEqual({ + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "resolved-task-secret", SHARED: "routine", + }); + expect(result.resolvedConfig.env).not.toHaveProperty(key); + expect(result.configuredTaskEnvironment).not.toHaveProperty(key); + }); + it("does not preflight or resolve legacy GitHub token bindings for managed executions", async () => { const assertNoGitHubBinding = (env: Record) => { for (const key of Object.keys(env)) if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN)$/.test(key)) { diff --git a/server/src/services/effective-run-config-fingerprints.ts b/server/src/services/effective-run-config-fingerprints.ts index 3dd19c17822..1438d7939fe 100644 --- a/server/src/services/effective-run-config-fingerprints.ts +++ b/server/src/services/effective-run-config-fingerprints.ts @@ -1,3 +1,4 @@ +import { GENERATED_RUNTIME_ENVIRONMENT_KEYS } from "../vendor/paperclip-runner/index.js"; import { createHash } from "node:crypto"; import type { RuntimeSecretManifestEntry } from "./secrets.js"; @@ -66,7 +67,6 @@ export interface EffectiveRunConfigFingerprintDiff { const OMIT = Symbol("omit-from-effective-run-config-fingerprint"); const REDACTED_VALUE: EffectiveRunConfigCanonicalValue = { type: "redacted", present: true }; -const GENERATED_RUNTIME_ENV_KEY_RE = /^PAPERCLIP_/; const SENSITIVE_CONFIG_KEY_RE = /(?:api[_-]?key|access[_-]?token|auth(?:orization)?|bearer|cookie|credential|jwt|password|passwd|private[_-]?key|secret|token)$/i; const VOLATILE_CONFIG_KEYS = new Set([ @@ -276,7 +276,7 @@ function canonicalizeEnvRecord( if (!isPlainObject(envValue)) return {}; const canonicalEnv: Record = {}; for (const key of Object.keys(envValue).sort()) { - if (GENERATED_RUNTIME_ENV_KEY_RE.test(key)) continue; + if (GENERATED_RUNTIME_ENVIRONMENT_KEYS.has(key)) continue; const manifestEntry = context.secrets.byConfigPath.get(`env.${key}`) ?? context.secrets.byEnvKey.get(key); if (manifestEntry) { canonicalEnv[key] = canonicalSecretMetadata(manifestEntry); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f406b9de39f..744f1ec293c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,4 @@ +import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironmentProjection } from "../vendor/paperclip-runner/index.js"; import { activeIssueInteractionCondition, TASK_QUESTION_GUIDANCE } from "./issue-question-context.js"; import { createAgentIdentityRedactor } from "./agent-identity-redaction.js"; import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js"; @@ -1495,6 +1496,7 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE = // 3. Any other PAPERCLIP_*-named binding is user data and flows through to // the run env like any non-prefixed binding. const FORBIDDEN_ENV_BINDING_KEYS = new Set([ + CONFIGURED_ENVIRONMENT_KEYS, "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", @@ -1950,6 +1952,11 @@ export async function resolveExecutionRunAdapterConfig(input: { } return { resolvedConfig, + // Capture resolved task values before provider credential injection or host + // inheritance. Native-only launch limits are checked at native dispatch. + configuredTaskEnvironment: Object.fromEntries( + Object.entries(parseObject(resolvedConfig.env)).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), secretKeys, secretManifest: [ ...(environmentEnvResolution.manifest ?? []), @@ -21794,7 +21801,7 @@ export function heartbeatService( selectedEnvironmentForConfig?.driver ?? "local", ); - const { resolvedConfig, secretKeys, secretManifest } = + const { resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ managedAiCredentials: Boolean(aiBinding), managedGitHubCredentials: !useHostGitHub, @@ -24991,6 +24998,7 @@ export function heartbeatService( managedAiCredentialIdentity: managedAiRuntime?.identity, managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record).CODEX_HOME) : undefined, runnerEnvironment: { + ...configuredEnvironmentProjection(configuredTaskEnvironment), ...buildNativeProviderEnvironment( adapterEnv, process.env, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index ed6f95e1e65..ba758de838b 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1,3 +1,4 @@ +import { configuredEnvironmentProjection } from "../../vendor/paperclip-runner/index.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { PgDialect } from "drizzle-orm/pg-core"; @@ -6325,6 +6326,42 @@ describe("native session same-turn steering", () => { }); describe("native warm session supervision", () => { + it.each([ + { change: "addition", before: {}, after: { CUSTOM_TOKEN: "first" }, replaced: true }, + { change: "rotation", before: { CUSTOM_TOKEN: "first" }, after: { CUSTOM_TOKEN: "second" }, replaced: true }, + { change: "removal", before: { CUSTOM_TOKEN: "first" }, after: {}, replaced: true }, + { change: "unchanged values", before: { CUSTOM_TOKEN: "first", FLAG: "on" }, after: { FLAG: "on", CUSTOM_TOKEN: "first" }, replaced: false }, + ])("applies configured environment $change on the next warm turn", async ({ change, before, after, replaced }) => { + const name = `warm-task-env-${change}`; + const current = { ...execution, + binding: { ...execution.binding, runId: `${name}-one`, executionWorkspaceId: name }, + session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 20 } }, + } as NativeExecutionInputV1; + const first = { close: vi.fn(async () => undefined) }; + const second = { close: vi.fn(async () => undefined) }; + const result = { result: { summary: "done" }, terminal: { runTerminalState: "succeeded" }, + turnId: name, normalizedSessionId: name, providerSessionId: name, driverKind: "test", driverVersion: "1", + nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + state.execute.mockReset() + .mockImplementationOnce(async options => { await options.onSession?.(first); return result; }) + .mockImplementationOnce(async options => { + expect(options.existingSession).toBe(replaced ? undefined : first); + await options.onSession?.(replaced ? second : first); + return result; + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, + runnerEnvironment: configuredEnvironmentProjection(before) }); + const next = { ...current, binding: { ...current.binding, runId: `${name}-two` } }; + await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: name, + runnerEnvironment: configuredEnvironmentProjection(after) }); + expect(first.close).toHaveBeenCalledTimes(replaced ? 1 : 0); + if (replaced) expect(first.close).toHaveBeenCalledWith({ reason: "warm native session configuration changed" }); + await vi.advanceTimersByTimeAsync(20); + } finally { vi.useRealTimers(); } + }); + it.each([ { runTerminalState: "failed", managedFiles: true }, { runTerminalState: "cancelled", managedFiles: true }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index d4dec4ee160..b815f976d2e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,4 @@ +import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js"; import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js"; import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; @@ -413,6 +414,7 @@ type WarmNativeSession = { session: NativeSession; ownerToken: symbol; configDigest: string; + configuredEnvironmentDigest: string; ownerScope: string; companyId: string; environmentId: string | null; @@ -8268,6 +8270,9 @@ async function executePaperclipNativeSessionWithinScope( input.runnerExecutionTarget?.kind ?? "local", ) : null; + const configuredEnvironmentDigest = createHash("sha256") + .update(JSON.stringify(configuredEnvironment(input.runnerEnvironment))) + .digest("hex"); const warmSessionOwnerToken = Symbol( `native-warm-session:${input.execution.binding.runId}`, ); @@ -8294,6 +8299,7 @@ async function executePaperclipNativeSessionWithinScope( input.refreshTools === true || entry.closeOnReleaseReason !== undefined || entry.configDigest !== warmConfigDigest || + entry.configuredEnvironmentDigest !== configuredEnvironmentDigest || entry.instructionCopy?.root !== input.instructionWorkingCopy?.root || entry.managedAiCredentialIdentity !== input.managedAiCredentialIdentity || credentialRunChanged || @@ -8674,6 +8680,7 @@ async function executePaperclipNativeSessionWithinScope( session, ownerToken: warmSessionOwnerToken, configDigest: warmConfigDigest, + configuredEnvironmentDigest, ownerScope: nativeSessionOwnerScope( input.execution, input.runnerExecutionTarget?.environmentId ?? null, ), diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 8d76d47ca82..2c603f543cc 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -139,3 +139,8 @@ export const completeTerminatedLocalNativeSessionCleanup = runner.completeTermin export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProviderPackManifestPath; export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary; + +export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; +export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; +export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; +export const configuredEnvironment = runner.configuredEnvironment; From ceabc3bc880676c49eee907a8d736f961a1358eb Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 7 Oct 2026 08:57:56 -0700 Subject: [PATCH 09/55] fix(e2e): wait on the server's own deferral signal in the signoff wakeup helper (#15345) ## Thinking Path > - Paperclip coordinates work for AI agents through server-managed tasks and heartbeats > - Signoff policy tests exercise stage transitions that wake the next participant > - A stage transition can defer a wake while the previous run still holds the issue execution lock > - The test helper used a fixed wait and could fail before the server released that lock > - This pull request waits on the server deferral signal and verifies the returned run identity > - The benefit is a stable test with clear failure details and no repeated wake request ## Linked Issues or Issue Description **What happened?** The signoff policy end-to-end spec failed intermittently with `No issue-bound heartbeat run became available for agent `. The server had deferred the wake while another run held the issue execution lock. **Expected behavior** The test helper waits for the named blocking run to finish, then reads and verifies the new run for the requested agent and issue. **Steps to reproduce** 1. Run `npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/signoff-policy.spec.ts`. 2. Exercise a signoff stage transition while the previous stage run still holds the issue execution lock. 3. Confirm that the helper waits on the server deferral signal and returns only a matching run. This change relates to [PR #11299](https://github.com/paperclipai/paperclip/pull/11299), which also touches the signoff policy end-to-end spec. ## What Changed - Call the wakeup endpoint that reports the server deferral reason. - Wait for the named blocking run instead of using a fixed delay. - Verify that each returned run belongs to the requested agent and issue. - Bound read and wait loops and report the last server signal and lock fields on failure. - Keep one wakeup request per helper call. ## Verification - `npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/signoff-policy.spec.ts` passed locally. - All five signoff policy cases passed locally. - The spec keeps all 60 assertions. - The diff contains only `tests/e2e/signoff-policy.spec.ts`. - The full CI end-to-end shards must pass before merge. ## Risks - The helper now depends on the wakeup endpoint's deferral fields. - A server change to those fields can fail the test with a named diagnostic. - The change affects end-to-end test behavior only. ## Model Used OpenAI GPT-5 Codex, exact runtime model `gpt-5-codex`, with tool use and code review assistance. The runtime does not expose a separate context-window value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: nickyleach <331803+nickyleach@users.noreply.github.com> Co-authored-by: Paperclip --- tests/e2e/signoff-policy.spec.ts | 204 ++++++++++++++++++++++++++----- 1 file changed, 172 insertions(+), 32 deletions(-) diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 71868b9a6a5..471589cf3cc 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -57,14 +57,91 @@ async function createAgentRequest(token: string): Promise { }); } -/** Invoke a heartbeat run for an agent, returning the run ID. */ +// The wait below follows the server's own deferral signal. It does not guess +// a delay. WAKEUP_WAIT_TOTAL_MS is a per-call ceiling, not a fixed wait: one +// test can call invokeHeartbeat several times under the suite's 60_000 ms +// per-test timeout (see tests/e2e/playwright.config.ts), so each call caps +// itself to the test's own remaining time, minus a reserve left for the +// assertions that still have to run after the call returns. A stuck wait +// then throws a named error, not a generic Playwright timeout. +// The server's rewake cooldown (ISSUE_REWAKE_BASE_COOLDOWN_MS, 120_000 ms in +// server/src/services/issue-rewake-throttle.ts) can outlast one test's life. +// When that happens, this helper still fails fast and states the reason. +const WAKEUP_WAIT_TOTAL_MS = 45_000; +const WAKEUP_WAIT_RESERVE_MS = 10_000; +const WAKEUP_POLL_INTERVAL_MS = 100; +const MAX_CONSECUTIVE_RUN_READ_FAILURES = 5; + +// First-seen start time for the current test, keyed by Playwright's own +// TestInfo object. Playwright does not expose a test start timestamp, so +// this helper records one the first time a test calls invokeHeartbeat. +const testStartTimesMs = new WeakMap, number>(); + +/** + * Return how long one invokeHeartbeat call may wait this time: the smaller + * of WAKEUP_WAIT_TOTAL_MS and the current test's remaining time, after + * WAKEUP_WAIT_RESERVE_MS is set aside for whatever the test still does once + * this call returns. Returns 0 when the test has no time left to spare, so + * the caller fails fast instead of starting a wait it cannot finish. + */ +function wakeupWaitBudgetMs(): number { + const info = test.info(); + const testTimeoutMs = info.timeout; + if (testTimeoutMs <= 0) return WAKEUP_WAIT_TOTAL_MS; // no test timeout set + + let startedAtMs = testStartTimesMs.get(info); + if (startedAtMs === undefined) { + startedAtMs = Date.now(); + testStartTimesMs.set(info, startedAtMs); + } + + const elapsedMs = Date.now() - startedAtMs; + const remainingMs = testTimeoutMs - elapsedMs - WAKEUP_WAIT_RESERVE_MS; + return Math.max(0, Math.min(WAKEUP_WAIT_TOTAL_MS, remainingMs)); +} + +/** + * Fetch a candidate run and confirm it belongs to this agent and this issue. + * Return null when the run fails to load, or when it names a different + * agent or a different issue. A caller must never treat an unverified lock + * field as the answer. + */ +async function fetchVerifiedRun( + board: APIRequestContext, + candidateRunId: string | null | undefined, + agentId: string, + issueId: string, +): Promise<{ id: string; status: string } | null> { + if (!candidateRunId) return null; + const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidateRunId}`); + if (!runRes.ok()) return null; + const candidateRun = await runRes.json(); + const context = candidateRun.contextSnapshot ?? {}; + if (candidateRun.agentId !== agentId) return null; + if (context.issueId !== issueId && context.taskId !== issueId) return null; + return { id: candidateRunId, status: candidateRun.status }; +} + +/** + * Invoke a heartbeat run for an agent, returning the run ID. + * + * Posts the wakeup exactly once. A second on-demand wake for the same + * (agent, issue) pair while the first has not yet produced visible progress + * reads as a redundant re-poll to the server's rewake throttle, so this + * never re-posts — it only reads state after the single post. + * + * Every returned run ID passes `fetchVerifiedRun` first. The issue's + * run-lock fields name a candidate, not a verified answer, because the lock + * holder and the issue assignee can differ during a stage transition. + */ async function invokeHeartbeat( board: APIRequestContext, agentId: string, issueId: string, ): Promise { - const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`, { + const res = await board.post(`${BASE_URL}/api/agents/${agentId}/wakeup`, { data: { + source: "on_demand", reason: "issue_assigned", payload: { issueId, taskId: issueId, taskKey: issueId }, }, @@ -73,49 +150,112 @@ async function invokeHeartbeat( const run = await res.json(); if (typeof run.id === "string" && run.id.length > 0) return run.id; - // A stage transition can already be replacing the previous executor's run - // with the participant's queued run. If the legacy invoke is skipped and - // that run has already released the issue lock, recover it from the agent's - // recent run receipts. - const deadline = Date.now() + 3_000; - do { + const waitBudgetMs = wakeupWaitBudgetMs(); + const deadline = Date.now() + waitBudgetMs; + const lastSeen = { + reason: typeof run.reason === "string" ? run.reason : null, + checkoutRunId: null as string | null, + executionRunId: typeof run.executionRunId === "string" ? run.executionRunId : null, + blockingRunStatus: null as string | null, + blockingRunReadFailed: false, + }; + + // A stage transition can invoke the next participant before the prior + // stage's run releases the issue's execution lock. The server names the + // run that holds the lock for two skip reasons: `issue_execution_deferred` + // (an active execution run) and `execution_reconciliation_required` (an + // execution blocker). Both name the blocking run in the same two fields, + // so this code handles them the same way. + // A third reason, `wakeup_skipped`, can also carry an `executionRunId`, + // but only when that run already finished. A finished run is never this + // agent's turn. So for `wakeup_skipped`, this code ignores `executionRunId` + // and falls through to the verified wait below. + if ( + (lastSeen.reason === "issue_execution_deferred" || lastSeen.reason === "execution_reconciliation_required") && + typeof run.executionRunId === "string" + ) { + if (run.executionAgentId === agentId) { + // A run for this same agent on this same issue already holds the lock + // (for example one started when the issue was assigned). Verify it + // before this code treats it as the agent's current turn. + const verified = await fetchVerifiedRun(board, run.executionRunId, agentId, issueId); + if (verified) return verified.id; + } else { + // A different agent holds the lock (the prior stage's participant, or + // the blocker's owner). Wait for that run to finish. It promotes this + // wake into a real run as part of releasing the lock, so this code + // waits for that instead of guessing how long the release takes. + let consecutiveRunReadFailures = 0; + while (Date.now() < deadline) { + const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${run.executionRunId}`); + if (runRes.ok()) { + consecutiveRunReadFailures = 0; + const blockingRun = await runRes.json(); + lastSeen.blockingRunStatus = typeof blockingRun.status === "string" ? blockingRun.status : null; + if (lastSeen.blockingRunStatus !== "queued" && lastSeen.blockingRunStatus !== "running") break; + } else { + // A 404 or a 500 response must not spin this loop forever. Count + // repeated read failures and stop once they dominate the wait. + // The failure then reads as "the run read kept failing", not as a + // generic timeout. + consecutiveRunReadFailures += 1; + if (consecutiveRunReadFailures >= MAX_CONSECUTIVE_RUN_READ_FAILURES) { + lastSeen.blockingRunReadFailed = true; + break; + } + } + await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS)); + } + } + } + + // Read the issue's own run-lock fields, and this agent's recent runs, + // until this agent's run for this issue appears and passes verification. + // Negative-authorization tests invoke a non-participant on purpose, so an + // assignee mismatch is a genuine rejection to return untouched, not a + // signal to keep waiting for a run that must never exist for that agent. + while (Date.now() < deadline) { const issueRunLock = await getIssueRunLockState(board, issueId); + lastSeen.checkoutRunId = issueRunLock.checkoutRunId; + lastSeen.executionRunId = issueRunLock.executionRunId; if (issueRunLock.assigneeAgentId !== agentId) { - // Negative authorization cases intentionally invoke a non-participant. - // Preserve the server rejection instead of waiting for a run that must - // never be assigned to that agent. return issueRunLock.executionRunId ?? issueRunLock.checkoutRunId ?? ""; } - const candidates = new Set([ - run.executionRunId, - issueRunLock.executionRunId, - issueRunLock.checkoutRunId, - ].filter((candidate): candidate is string => Boolean(candidate))); + + const verifiedCheckout = await fetchVerifiedRun(board, issueRunLock.checkoutRunId, agentId, issueId); + if (verifiedCheckout) return verifiedCheckout.id; + const verifiedExecution = await fetchVerifiedRun(board, issueRunLock.executionRunId, agentId, issueId); + if (verifiedExecution) return verifiedExecution.id; + const recentRunsRes = await board.get( `${BASE_URL}/api/companies/${issueRunLock.companyId}/heartbeat-runs?agentId=${agentId}&limit=20`, ); if (recentRunsRes.ok()) { const recentRuns = await recentRunsRes.json(); for (const recentRun of Array.isArray(recentRuns) ? recentRuns : []) { - if (typeof recentRun.id === "string") candidates.add(recentRun.id); + const context = recentRun.contextSnapshot ?? {}; + if ( + typeof recentRun.id === "string" && + recentRun.agentId === agentId && + (context.issueId === issueId || context.taskId === issueId) + ) { + return recentRun.id; + } } } - for (const candidate of candidates) { - const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidate}`); - if (!runRes.ok()) continue; - const candidateRun = await runRes.json(); - const context = candidateRun.contextSnapshot ?? {}; - if ( - candidateRun.agentId === agentId && - (context.issueId === issueId || context.taskId === issueId) - ) { - return candidate; - } - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } while (Date.now() < deadline); + await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS)); + } - throw new Error(`No issue-bound heartbeat run became available for agent ${agentId}`); + throw new Error( + `No verified heartbeat run became available for agent ${agentId} on issue ${issueId} ` + + `within ${waitBudgetMs}ms` + + `${waitBudgetMs <= 0 ? " (the test's remaining time budget was already spent before this wait could start)" : ""}. ` + + `Last skip reason: ${lastSeen.reason ?? "none"}. ` + + `Last checkoutRunId: ${lastSeen.checkoutRunId ?? "none"}. ` + + `Last executionRunId: ${lastSeen.executionRunId ?? "none"}. ` + + `Blocking run status: ${lastSeen.blockingRunStatus ?? "none"}` + + `${lastSeen.blockingRunReadFailed ? " (the blocking run read failed repeatedly)" : ""}.`, + ); } async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise { From 9fb955e9a285ed35efcfafa781d753112c5bc0bf Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 7 Oct 2026 10:37:55 -0700 Subject: [PATCH 10/55] fix(codex): gate models on the Codex CLI floor before the ChatGPT backend rejects them (#15399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Codex adapter and the native runner run Codex in local hosts and in managed sandboxes, and the model catalog lists the models an operator can select > - The Codex backend accepts a new model with ChatGPT sign-in only from a recent enough Codex CLI. An older CLI fails every turn with `The '' model is not supported when using Codex with a ChatGPT account.` > - After #14942 added `gpt-6.1-sol`, an operator selected it for an agent in a managed Daytona sandbox. The sandbox image still shipped Codex 0.156.0. The connection test and runs failed with that sentence, which reads like an account problem > - Paperclip only checked the Codex compatibility window (`>=0.149.0 <0.161.0`), so 0.156.0 passed and the failure surfaced from the backend without a cause > - This pull request records the verified Codex CLI floor for each gated model, compares the installed `codex --version` with that floor in the environment Test and in the remote runner, and names the backend rejection when it still happens > - The benefit is a precise, actionable message ("gpt-6.1-sol requires Codex CLI 0.159.0 or newer; detected 0.156.0; promote a sandbox image with Codex 0.160.0") instead of an opaque 400, and no doomed hello probe ## Linked Issues or Issue Description Refs #14942 **Bug: selecting `gpt-6.1-sol` in a managed sandbox fails with a ChatGPT account error.** **Steps to reproduce** 1. Promote a sandbox image that ships Codex CLI 0.156.0. 2. Create a Codex agent with a ChatGPT sign-in connection, select `gpt-6.1-sol`, and run the environment Test or a task in that sandbox. **Expected behavior** The Test or the run tells the operator that the Codex CLI in the sandbox is older than the model needs. **Actual behavior** The Test reports `codex_hello_probe_failed` with `{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."}}`. A run fails with "The selected model is not supported by the current ChatGPT connection." **Evidence** - Codex 0.156.1 and older are rejected for `gpt-6.1-sol` with ChatGPT sign-in: https://github.com/openai/codex/issues/49396 - Codex 0.159.0 and 0.159.2 are accepted: https://github.com/openai/codex/issues/49464 and https://github.com/decolua/9router/issues/4471 (same error, fixed by raising the client version identity from 0.155.0 to 0.159.0) - Codex 0.157.0 release notes: "Add GPT-6 Sol and Luna to the model catalog": https://github.com/openai/codex/releases/tag/rust-v0.157.0 - GPT-6.1 Sol is included for Plus, Pro, Business, Enterprise, and Edu with ChatGPT sign-in: https://learn.chatgpt.com/docs/models ## What Changed - `packages/adapters/codex-local/src/index.ts`: add `minimumCodexCliVersionForModel` (`gpt-6.1-sol` → 0.159.0, `gpt-6-sol` and `gpt-6-luna` → 0.157.0, with the sources above), `parseCodexCliVersionOutput`, `codexCliVersionAtLeast`, and `CODEX_CHATGPT_MODEL_REJECTION_RE`. Models without a verified floor return `null`, so nothing probes them. The agent configuration doc describes the floors. - `packages/adapters/codex-local/src/server/cli-version.ts` (new): run `codex --version` where the run would execute it (local, SSH, or sandbox), compare it with the model floor, and build the `codex_cli_version_compatible` / `codex_cli_version_incompatible` checks. Map the backend rejection sentence to `codex_hello_probe_model_rejected` with the detected CLI version and a hint that separates a stale CLI from a plan that does not include the model. - `packages/adapters/codex-local/src/server/test.ts` (CLI lane Test): run the version check before the hello probe when the model has a floor. Skip the hello probe when the CLI is too old (`codex_hello_probe_skipped_cli_version`). When the probe still fails with the backend sentence, report `codex_hello_probe_model_rejected` instead of the generic `codex_hello_probe_failed`. The new codes do not match the auth-failure patterns, so a managed connection is not invalidated. - `packages/adapters/codex-local/src/server/acp.ts` (ACP lane Test): for remote targets, run the same version check against the shared `codex` the ACP server spawns. - `server/src/services/native-runtime/native-session-executor.ts`: after the compatibility-window check, compare the remote Codex with the configured model's floor and fail with `runner_remote_provider_artifact_incompatible: requires Codex or newer with ChatGPT sign-in, received from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=...`. When a preinstalled Codex fails this check and an npm spec is configured, the existing fallback installs the pinned release. - Tests: adapter metadata, CLI-lane Test (too old, compatible, no floor, backend rejection), ACP-lane Test (too old, compatible, no floor), and the remote runner floor (nine version/model cases). - `doc/adapter-model-audit-2026-10-02.md`: record the floors and the reason. No catalog entry, default model, saved agent configuration, pin, lockfile, or image definition changes. ## Verification Local (Node 25.9, pnpm 9.15.4 via corepack): ```sh pnpm --filter @paperclipai/adapter-codex-local typecheck pnpm --filter @paperclipai/adapter-codex-local exec vitest run # 81 passed pnpm --filter @paperclipai/server exec vitest run \ src/services/native-runtime/native-session-executor.test.ts \ src/services/native-runtime/codex-runtime-compatibility.test.ts -t Codex # 84 passed ``` Also run locally: the full `native-session-executor.test.ts` file (533 passed) and the full Codex adapter suite (81 passed). Follow-up commit `9fd4d3b25` (Greptile P2: the ACP-lane version probe ignored the agent's configured env): the probe now receives the adapter's string-valued `env` entries, the same ones `buildCodexAcpConfig` hands to remote ACP runs, so a `PATH` override selects the same `codex` for the Test as for the run. New test covers a `PATH` + `CODEX_HOME` override and a dropped non-string entry. Re-run on that head: `pnpm --filter @paperclipai/adapter-codex-local typecheck` clean; full Codex adapter suite 503 passed (31 files). Not run here: `pnpm -r typecheck` for `server` (the direct `tsc --noEmit` was killed by the sandbox memory cap; the adapter package typecheck passes and CI covers the server), `pnpm build`, browser suites, and a live sandbox probe. No UI files changed, so token gates do not apply. Manual check for a reviewer: set an agent's Codex model to `gpt-6.1-sol`, point its environment at a sandbox whose `codex --version` prints `codex-cli 0.156.0`, and run the environment Test. The result contains `codex_cli_version_incompatible` with `Detected Codex CLI 0.156.0.` and no hello probe check. With Codex 0.160.0 the Test contains `codex_cli_version_compatible` and runs the hello probe. ## Risks - A floor is enforced for all authentication modes, because the runner does not know the credential method at verification time. An OpenAI API key on Codex 0.157.0 or 0.158.0 with `gpt-6.1-sol` is now rejected before launch. Paperclip pins Codex 0.160.0 everywhere, so this only affects installs that lag the pin, and the message names the fix. - The 0.159.0 floor for `gpt-6.1-sol` is the oldest stable release verified to work; 0.157.0 and 0.158.0 were not verified either way. If OpenAI accepts an older client, the floor can be lowered in one table. - The `codex --version` probe adds one short process run to the Test only for models with a floor. - Rollback: revert this pull request. No data or configuration migrates. ## Model Used Claude Fable 5.1 (`claude-fable-5-1`), Anthropic, with extended thinking, tool use, and web research, operating as a Paperclip agent through Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Bender (Fable) Co-authored-by: Claude Fable 5.1 --- doc/adapter-model-audit-2026-10-02.md | 2 +- .../adapters/codex-local/src/index.test.ts | 46 +++++ packages/adapters/codex-local/src/index.ts | 55 ++++++ .../src/server/acp.cli-version.test.ts | 182 ++++++++++++++++++ .../adapters/codex-local/src/server/acp.ts | 27 +++ .../codex-local/src/server/cli-version.ts | 125 ++++++++++++ .../src/server/test.remote.test.ts | 119 ++++++++++++ .../adapters/codex-local/src/server/test.ts | 37 +++- .../native-session-executor.test.ts | 89 +++++++++ .../native-runtime/native-session-executor.ts | 20 ++ 10 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 packages/adapters/codex-local/src/server/acp.cli-version.test.ts create mode 100644 packages/adapters/codex-local/src/server/cli-version.ts diff --git a/doc/adapter-model-audit-2026-10-02.md b/doc/adapter-model-audit-2026-10-02.md index 5c86e25bd68..133e81b2e3c 100644 --- a/doc/adapter-model-audit-2026-10-02.md +++ b/doc/adapter-model-audit-2026-10-02.md @@ -10,7 +10,7 @@ No agent defaults or saved model selections are migrated. | Adapter | Changes from the audit | | --- | --- | -| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. | +| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. Follow-up (October 6, 2026): with ChatGPT sign-in the Codex backend accepts `gpt-6.1-sol` only from Codex CLI 0.159.0 or newer (0.156.1 and older are rejected with "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."), and `gpt-6-sol` / `gpt-6-luna` entered the bundled catalog in 0.157.0. The adapter now records these floors; the environment Test and the remote runner compare the installed `codex --version` against them, so a sandbox image baked before the pin moved reports the stale CLI instead of an account error. | | Claude on Bedrock | Add Sonnet 5.5 (`us.anthropic.claude-sonnet-5-5`), using the catalog's existing US inference-profile convention. Sonnet 5.5 IDs (direct or Bedrock-qualified) get the documented `xhigh` and `max` efforts and require Claude Code 2.1.284 or later on the CLI lane. | | OpenCode | Add `openai/gpt-6.1-sol` and `anthropic/claude-sonnet-5-5` to the static fallback used by remote environments. Both IDs are present in the OpenCode model registry. | | Claude Code (direct) | No change in this audit. [#14993](https://github.com/paperclipai/paperclip/pull/14993) (merged October 5, 2026, superseding #14816) adds Sonnet 5.5 (`claude-sonnet-5-5`, Claude Code 2.1.284 or later) and refreshes the Claude runtime. | diff --git a/packages/adapters/codex-local/src/index.test.ts b/packages/adapters/codex-local/src/index.test.ts index 5269c9b0174..fcb53825d60 100644 --- a/packages/adapters/codex-local/src/index.test.ts +++ b/packages/adapters/codex-local/src/index.test.ts @@ -1,12 +1,58 @@ import { describe, expect, it } from "vitest"; import { + CODEX_CHATGPT_MODEL_REJECTION_RE, + codexCliVersionAtLeast, codexLocalReasoningEffortsForModel, DEFAULT_CODEX_LOCAL_MODEL, isCodexLocalFastModeSupported, + minimumCodexCliVersionForModel, models, normalizeCodexModel, + parseCodexCliVersionOutput, } from "./index.js"; +describe("codex model CLI floors", () => { + it("records the oldest Codex CLI verified to run each gated model with ChatGPT sign-in", () => { + expect(minimumCodexCliVersionForModel("gpt-6.1-sol")).toBe("0.159.0"); + expect(minimumCodexCliVersionForModel(" gpt-6.1-sol ")).toBe("0.159.0"); + expect(minimumCodexCliVersionForModel("gpt-6-sol")).toBe("0.157.0"); + expect(minimumCodexCliVersionForModel("gpt-6-luna")).toBe("0.157.0"); + }); + + it.each([DEFAULT_CODEX_LOCAL_MODEL, "gpt-5.6", "gpt-6-astra", "gpt-5.5", "custom-model", "", null, undefined])( + "has no floor for %s so callers skip the version probe", (model) => { + expect(minimumCodexCliVersionForModel(model)).toBeNull(); + }, + ); + + it("compares stable versions numerically and rejects prerelease or ambiguous input", () => { + expect(codexCliVersionAtLeast("0.159.0", "0.159.0")).toBe(true); + expect(codexCliVersionAtLeast("0.160.1", "0.159.0")).toBe(true); + expect(codexCliVersionAtLeast("1.0.0", "0.159.0")).toBe(true); + expect(codexCliVersionAtLeast("0.156.1", "0.159.0")).toBe(false); + expect(codexCliVersionAtLeast("0.16.0", "0.159.0")).toBe(false); + expect(codexCliVersionAtLeast("0.160.0-alpha.1", "0.159.0")).toBe(false); + expect(codexCliVersionAtLeast("", "0.159.0")).toBe(false); + }); + + it("parses only a single stable `codex-cli x.y.z` line from --version output", () => { + expect(parseCodexCliVersionOutput("codex-cli 0.160.0\n")).toBe("0.160.0"); + expect(parseCodexCliVersionOutput("WARNING: PATH unchanged\r\ncodex-cli 0.156.0\r\n")).toBe("0.156.0"); + expect(parseCodexCliVersionOutput("codex-cli 0.162.0-alpha.17")).toBeNull(); + expect(parseCodexCliVersionOutput("codex 0.160.0")).toBeNull(); + expect(parseCodexCliVersionOutput("codex-cli 0.149.0\ncodex-cli 0.160.0")).toBeNull(); + expect(parseCodexCliVersionOutput("")).toBeNull(); + }); + + it("matches the backend's ChatGPT model rejection and captures the model", () => { + const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec( + "{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}", + ); + expect(match?.[1]).toBe("gpt-6.1-sol"); + expect(CODEX_CHATGPT_MODEL_REJECTION_RE.test("Model metadata for `gpt-6.1-sol` not found")).toBe(false); + }); +}); + describe("codex local adapter metadata", () => { it("advertises current Codex-capable OpenAI models without changing the default", () => { const modelIds = models.map((model) => model.id); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index f1036a67828..0ffe729b928 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -85,6 +85,60 @@ export function codexLocalReasoningEffortsForModel( } } +// The Codex backend accepts a model for ChatGPT sign-in only from a client that identifies as a +// recent enough CLI (the request carries the CLI version). An older `codex` fails every turn with +// "The '' model is not supported when using Codex with a ChatGPT account.", which reads +// like an account problem when it is really a stale install, typically a sandbox image baked +// before the catalog gained the model. Record the oldest stable release verified to work so the +// Test and the remote runner can name the actual gap. Omit models with no verified floor; the +// install pin in the runner and images is always at or above every floor listed here. +const CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS: Readonly> = { + // 0.156.1 and older are rejected (openai/codex#49396); 0.159.0 and 0.159.2 are accepted + // (openai/codex#49464). No stable release between those was verified either way. + "gpt-6.1-sol": "0.159.0", + // Codex 0.157.0 release notes: "Add GPT-6 Sol and Luna to the model catalog". + "gpt-6-sol": "0.157.0", + "gpt-6-luna": "0.157.0", +}; + +export function minimumCodexCliVersionForModel(model: string | null | undefined): string | null { + return CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS[normalizeCodexModel(model)] ?? null; +} + +const STABLE_CODEX_CLI_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function stableCodexCliVersionParts(version: string): number[] | null { + const match = STABLE_CODEX_CLI_VERSION.exec(version.trim()); + if (!match) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +/** Read the version from `codex --version` output; prerelease or ambiguous output yields null. */ +export function parseCodexCliVersionOutput(output: string): string | null { + const versions = output + .split(/\r?\n/) + .map((line) => /^codex-cli (\S+)$/.exec(line.trim())?.[1]) + .filter((version): version is string => version !== undefined); + if (versions.length !== 1 || !stableCodexCliVersionParts(versions[0]!)) return null; + return versions[0]!; +} + +export function codexCliVersionAtLeast(version: string, minimum: string): boolean { + const parsedVersion = stableCodexCliVersionParts(version); + const parsedMinimum = stableCodexCliVersionParts(minimum); + if (!parsedVersion || !parsedMinimum) return false; + for (let index = 0; index < parsedMinimum.length; index += 1) { + if (parsedVersion[index] !== parsedMinimum[index]) { + return parsedVersion[index]! > parsedMinimum[index]!; + } + } + return true; +} + +export const CODEX_CHATGPT_MODEL_REJECTION_RE = + /The '([^']+)' model is not supported when using Codex with a ChatGPT account\./; + export function isCodexLocalKnownModel(model: string | null | undefined): boolean { const normalizedModel = normalizeModelId(model); if (!normalizedModel) return false; @@ -175,6 +229,7 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances//companies//agents//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). +- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins. - Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn. diff --git a/packages/adapters/codex-local/src/server/acp.cli-version.test.ts b/packages/adapters/codex-local/src/server/acp.cli-version.test.ts new file mode 100644 index 00000000000..0ed7dce3a5f --- /dev/null +++ b/packages/adapters/codex-local/src/server/acp.cli-version.test.ts @@ -0,0 +1,182 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; + +const { ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess } = vi.hoisted(() => ({ + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "codex-cli 0.156.0\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + })), +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { ...actual, ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess }; +}); + +import { testCodexAcpEnvironment } from "./acp.js"; + +describe("codex ACP lane model CLI floor", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-version-")); + vi.stubEnv("CODEX_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-home-"))); + vi.stubEnv("PAPERCLIP_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-instance-"))); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default"); + }); + + afterEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(cwd, { recursive: true, force: true }).catch(() => undefined); + }); + + function sandboxTarget(): AdapterExecutionTarget { + return { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd: "/remote/workspace", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }; + } + + it("fails the sandbox Test when the shared codex is older than the model's floor", async () => { + const result = await testCodexAcpEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("fail"); + expect(result.checks.find((check) => check.code === "codex_cli_version_incompatible")).toMatchObject({ + level: "error", + message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.", + detail: "Detected Codex CLI 0.156.0.", + }); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [ + string, + AdapterExecutionTarget, + string, + string[], + { env: Record }, + ]; + expect(call[1]).toMatchObject({ kind: "remote", transport: "sandbox" }); + expect(call[2]).toBe("codex"); + expect(call[3]).toEqual(["--version"]); + expect(call[4].env).toEqual({ OPENAI_API_KEY: "sk-test" }); + }); + + it("probes the codex the agent's configured env selects, not the image default", async () => { + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "codex-cli 0.160.0\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }); + + const result = await testCodexAcpEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { + cwd, + model: "gpt-6.1-sol", + env: { + OPENAI_API_KEY: "sk-test", + PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin", + CODEX_HOME: "/remote/codex-home", + PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: false, + }, + }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("pass"); + expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({ + message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.", + }); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [ + string, + AdapterExecutionTarget, + string, + string[], + { env: Record }, + ]; + expect(call[3]).toEqual(["--version"]); + // String-valued entries are forwarded exactly as the ACP run receives them; + // non-string values are dropped the same way buildCodexAcpConfig consumers do. + expect(call[4].env).toEqual({ + OPENAI_API_KEY: "sk-test", + PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin", + CODEX_HOME: "/remote/codex-home", + }); + }); + + it("passes the sandbox Test when the shared codex satisfies the floor", async () => { + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "codex-cli 0.160.0\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }); + + const result = await testCodexAcpEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("pass"); + expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({ + message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.", + }); + }); + + it("does not probe the sandbox for models without a verified floor", async () => { + const result = await testCodexAcpEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { cwd, model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "sk-test" } }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("pass"); + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); + expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false); + }); +}); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 7eb2b87bfd6..ed57d8aa983 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -49,6 +49,7 @@ import { stageCodexHomeForSync, } from "./codex-home.js"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js"; +import { checkCodexCliVersionForModel } from "./cli-version.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const packageRootDir = path.resolve(moduleDir, "../.."); @@ -648,6 +649,32 @@ export async function testCodexAcpEnvironment( } } + if (targetIsRemote && commandResolvable) { + // The ACP server spawns the shared `codex` installed in the environment. + // Models with a verified CLI floor are checked against that executable so + // a stale sandbox image is named here, before a run fails on every turn + // with the backend's "not supported when using Codex with a ChatGPT + // account" rejection. Models without a floor skip the probe. + // + // Remote ACP runs receive the adapter's string-valued env entries (see + // buildCodexAcpConfig), so the probe gets the same ones. An agent that + // selects its Codex install through PATH is then measured against that + // install rather than the image default. + const probeEnv: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") probeEnv[key] = value; + } + const versionCheck = await checkCodexCliVersionForModel({ + runId: `codex-acp-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + model: asString(config.model, ""), + command: "codex", + target, + cwd: resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()), + env: probeEnv, + }); + if (versionCheck) checks.push(versionCheck.check); + } + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; const warmHandleIdleMs = asNumber( config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs, diff --git a/packages/adapters/codex-local/src/server/cli-version.ts b/packages/adapters/codex-local/src/server/cli-version.ts new file mode 100644 index 00000000000..aaafc9479af --- /dev/null +++ b/packages/adapters/codex-local/src/server/cli-version.ts @@ -0,0 +1,125 @@ +import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target"; +import { + CODEX_CHATGPT_MODEL_REJECTION_RE, + codexCliVersionAtLeast, + minimumCodexCliVersionForModel, + normalizeCodexModel, + parseCodexCliVersionOutput, +} from "../index.js"; + +export const CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE = "codex_cli_version_incompatible"; +export const CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE = "codex_cli_version_compatible"; +export const CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE = "codex_hello_probe_model_rejected"; + +/** + * Run `codex --version` where the run would execute it. Never cached: an + * operator may upgrade Codex, or promote a new sandbox image, while the + * Paperclip server keeps running, and the next Test should see the change. + */ +export async function readCodexCommandVersion(input: { + runId: string; + command: string; + target: AdapterExecutionTarget | null | undefined; + cwd: string; + env: Record; +}): Promise { + try { + const result = await runAdapterExecutionTargetProcess( + input.runId, + input.target, + input.command, + ["--version"], + { + cwd: input.cwd, + env: input.env, + timeoutSec: 20, + graceSec: 5, + onLog: async () => {}, + }, + ); + if (result.timedOut || result.exitCode !== 0) return null; + return parseCodexCliVersionOutput(`${result.stdout}\n${result.stderr}`); + } catch { + return null; + } +} + +function sandboxUpgradeHint(targetIsSandbox: boolean): string { + return targetIsSandbox + ? "Promote a sandbox image that ships the Codex CLI version Paperclip pins, then retry the Test. Adding a model to the catalog does not update the Codex CLI baked into existing sandbox images." + : "Upgrade Codex (for example `npm install -g @openai/codex@latest`) where this agent runs, then retry the Test."; +} + +/** + * Compare the installed Codex CLI with the floor the configured model needs + * for ChatGPT sign-in. Returns null when the model has no verified floor, so + * callers skip the version probe entirely for such models. + */ +export async function checkCodexCliVersionForModel(input: { + runId: string; + model: string | null | undefined; + command: string; + target: AdapterExecutionTarget | null | undefined; + cwd: string; + env: Record; +}): Promise<{ check: AdapterEnvironmentCheck; compatible: boolean; detectedVersion: string | null } | null> { + const minimum = minimumCodexCliVersionForModel(input.model); + if (!minimum) return null; + const model = normalizeCodexModel(input.model); + const targetIsSandbox = input.target?.kind === "remote" && input.target.transport === "sandbox"; + const detected = await readCodexCommandVersion(input); + if (detected && codexCliVersionAtLeast(detected, minimum)) { + return { + compatible: true, + detectedVersion: detected, + check: { + code: CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE, + level: "info", + message: `Codex CLI ${detected} satisfies the ${minimum} minimum for ${model}.`, + }, + }; + } + return { + compatible: false, + detectedVersion: detected, + check: { + code: CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE, + level: "error", + message: `${model} requires Codex CLI ${minimum} or newer with ChatGPT sign-in.`, + detail: detected + ? `Detected Codex CLI ${detected}.` + : "Could not determine the installed Codex CLI version from `codex --version`.", + hint: sandboxUpgradeHint(targetIsSandbox), + }, + }; +} + +/** + * The backend rejects a model for ChatGPT sign-in with one fixed sentence. + * Map it to a dedicated check so the cause (stale CLI, or a plan that does + * not include the model) is named instead of a generic probe failure. + */ +export function codexHelloProbeModelRejectionCheck(input: { + evidence: string; + detectedVersion: string | null; + targetIsSandbox: boolean; +}): AdapterEnvironmentCheck | null { + const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec(input.evidence); + if (!match) return null; + const model = match[1]!; + const minimum = minimumCodexCliVersionForModel(model); + const versionNote = input.detectedVersion + ? ` Detected Codex CLI ${input.detectedVersion}.` + : ""; + return { + code: CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE, + level: "error", + message: `The Codex backend rejected ${model} for this ChatGPT account.`, + detail: `${match[0]}${versionNote}`, + hint: minimum + ? `${model} needs Codex CLI ${minimum} or newer with ChatGPT sign-in. ${sandboxUpgradeHint(input.targetIsSandbox)} If the CLI is already new enough, the plan does not include this model (Enterprise and Edu administrators must enable it) or the rollout has not reached the account; choose another model or use an OpenAI API key.` + : `The ChatGPT plan does not include ${model}, the rollout has not reached the account, or the installed Codex CLI is older than the model requires. ${sandboxUpgradeHint(input.targetIsSandbox)} Otherwise choose another model or use an OpenAI API key.`, + }; +} diff --git a/packages/adapters/codex-local/src/server/test.remote.test.ts b/packages/adapters/codex-local/src/server/test.remote.test.ts index 6376a001ae8..9efe9ebfd90 100644 --- a/packages/adapters/codex-local/src/server/test.remote.test.ts +++ b/packages/adapters/codex-local/src/server/test.remote.test.ts @@ -253,6 +253,125 @@ describe("codex remote environment diagnostics", () => { expect(probeCall?.[3]).toContain("--skip-git-repo-check"); }); + // `sandboxTarget()` is declared later in this describe block; function + // declarations hoist, so the version-floor cases below reuse it. + function versionProbeResult(stdout: string) { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout, + stderr: "", + pid: 111, + startedAt: new Date().toISOString(), + }; + } + + it("fails before the hello probe when the sandbox Codex CLI is older than the model's floor", async () => { + // A sandbox image baked before gpt-6.1-sol reached the catalog still runs + // Codex 0.156.0. The backend would reject every turn with "not supported + // when using Codex with a ChatGPT account"; the Test must name the real + // gap (the CLI in the image) instead of running that doomed probe. + runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.156.0\n")); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("fail"); + const incompatible = result.checks.find((check) => check.code === "codex_cli_version_incompatible"); + expect(incompatible).toMatchObject({ + level: "error", + message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.", + detail: "Detected Codex CLI 0.156.0.", + }); + expect(incompatible?.hint).toContain("sandbox image"); + expect(result.checks.some((check) => check.code === "codex_hello_probe_skipped_cli_version")).toBe(true); + expect(result.checks.some((check) => check.code.startsWith("codex_hello_probe_") && !check.code.includes("skipped"))).toBe(false); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]]; + expect(versionCall[2]).toBe("codex"); + expect(versionCall[3]).toEqual(["--version"]); + expect(prepareAdapterExecutionTargetRuntime).not.toHaveBeenCalled(); + }); + + it("runs the hello probe when the sandbox Codex CLI satisfies the model's floor", async () => { + runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n")); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("pass"); + expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({ + level: "info", + message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.", + }); + expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(2); + }); + + it("skips the version probe for models without a verified floor", async () => { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { engine: "cli", command: "codex", model: "gpt-5.6-sol" }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("pass"); + expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + const helloCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]]; + expect(helloCall[3]).not.toEqual(["--version"]); + }); + + it("names the backend's ChatGPT model rejection instead of a generic probe failure", async () => { + // The CLI is new enough, but the account's plan or rollout does not include + // the model. The backend answers with one fixed sentence; surface it with + // the detected CLI version so the operator can tell the two causes apart. + runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n")); + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "ERROR: {\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}", + pid: 222, + startedAt: new Date().toISOString(), + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" }, + executionTarget: sandboxTarget(), + environmentName: "QA Daytona", + }); + + expect(result.status).toBe("fail"); + const rejected = result.checks.find((check) => check.code === "codex_hello_probe_model_rejected"); + expect(rejected).toMatchObject({ + level: "error", + message: "The Codex backend rejected gpt-6.1-sol for this ChatGPT account.", + detail: "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account. Detected Codex CLI 0.160.0.", + }); + expect(rejected?.hint).toContain("0.159.0"); + expect(rejected?.hint).toContain("plan"); + expect(result.checks.some((check) => check.code === "codex_hello_probe_failed")).toBe(false); + // Not an auth failure: the managed-connection flow must not invalidate the credential. + expect(result.checks.some((check) => check.code === "codex_hello_probe_auth_required")).toBe(false); + }); + it("emits the canonical adapter_auth_missing check when a sandbox hello probe reports missing auth", async () => { // The sandbox has no seedable credentials, so the hello probe returns an // authentication-required error. The Test must emit the neutral canonical diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 0679b0c7b90..10cbcb8d395 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -37,6 +37,7 @@ import { } from "./codex-auth-cache.js"; import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js"; +import { checkCodexCliVersionForModel, codexHelloProbeModelRejectionCheck } from "./cli-version.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -383,7 +384,34 @@ export async function testEnvironment( const canRunProbe = checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable"); - if (canRunProbe) { + // Models with a verified CLI floor are compared against the installed Codex + // before the hello probe. A probe on an older CLI only produces the backend's + // "not supported when using Codex with a ChatGPT account" rejection, which + // hides the real gap: the CLI (often a stale sandbox image), not the account. + let detectedCliVersion: string | null = null; + let configuredModelIsCompatible = true; + if (canRunProbe && commandLooksLike(command, "codex")) { + const versionCheck = await checkCodexCliVersionForModel({ + runId, + model: asString(config.model, ""), + command, + target, + cwd, + env, + }); + if (versionCheck) { + checks.push(versionCheck.check); + configuredModelIsCompatible = versionCheck.compatible; + detectedCliVersion = versionCheck.detectedVersion; + } + } + if (canRunProbe && !configuredModelIsCompatible) { + checks.push({ + code: "codex_hello_probe_skipped_cli_version", + level: "info", + message: "Skipped hello probe because the installed Codex CLI cannot use the configured model.", + }); + } else if (canRunProbe) { if (!commandLooksLike(command, "codex")) { checks.push({ code: "codex_hello_probe_skipped_custom_command", @@ -525,7 +553,12 @@ export async function testEnvironment( }); } } else { - checks.push({ + const modelRejection = codexHelloProbeModelRejectionCheck({ + evidence: `${parsed.errorMessage ?? ""}\n${providerStderr}\n${probe.stdout}`, + detectedVersion: detectedCliVersion, + targetIsSandbox, + }); + checks.push(modelRejection ?? { code: "codex_hello_probe_failed", level: "error", message: "Codex hello probe failed.", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index ba758de838b..3fbe173df1e 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -11968,6 +11968,95 @@ describe("runnerd provider runtime wiring", () => { ).toBe(false); }); + it.each([ + { version: "0.156.0", model: "gpt-6.1-sol", floor: "0.159.0" }, + { version: "0.158.0", model: "gpt-6.1-sol", floor: "0.159.0" }, + { version: "0.156.0", model: "gpt-6-sol", floor: "0.157.0" }, + { version: "0.156.0", model: "gpt-6-luna", floor: "0.157.0" }, + { version: "0.159.0", model: "gpt-6.1-sol", floor: null }, + { version: "0.160.0", model: "gpt-6.1-sol", floor: null }, + { version: "0.157.0", model: "gpt-6-sol", floor: null }, + { version: "0.156.0", model: "gpt-5.6-sol", floor: null }, + { version: "0.156.0", model: null, floor: null }, + ])("rejects a sandbox Codex $version below the ChatGPT floor for $model before launch (floor=$floor)", async ({ version, model, floor }) => { + // A preinstalled Codex inside the compatibility window can still be too + // old for the configured model: the ChatGPT backend rejects every turn + // with "not supported when using Codex with a ChatGPT account". The + // verifier names the stale image instead of letting the run fail as an + // account problem. + const gatedExecution = { + ...execution, + provider: { kind: "codex", model, approvalPolicy: "never" }, + binding: { ...execution.binding, runId: `run-codex-floor-${version}-${model ?? "default"}` }, + } as NativeExecutionInputV1; + const onLog = vi.fn(async () => undefined); + const syncIn = vi.fn(async () => undefined); + const remoteExecute = vi.fn( + async (command: { command: string; args?: string[] }) => { + let stdout = ""; + const script = command.args?.[1] ?? ""; + if (command.args?.[0] === "--build-metadata") { + stdout = JSON.stringify({ + schema: "paperclip-runner/runnerd-build-metadata/v1", + binaryName: "paperclip-runnerd", + packageName: "@paperclipai/paperclip-runner", + binaryContractVersion: 2, + durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"], + prpTransportModes: ["listen_ws"], + }); + } else if (command.args?.[0] === "--version") { + if (command.command.endsWith("/.paperclip-runtime/paperclip-runner/bin/codex")) { + throw new Error("reached-preinstalled-codex-verification"); + } + stdout = `codex-cli ${version}`; + } else if (script === "uname -s; uname -m") { + stdout = `${process.platform === "darwin" ? "Darwin" : "Linux"}\n${process.arch === "arm64" ? "arm64" : "x86_64"}\n`; + } else if (script.includes("command -v paperclip-runnerd")) { + stdout = "/usr/local/bin/paperclip-runnerd\n"; + } else if (script.includes("command -v codex")) { + stdout = "/opt/paperclip-runner/bin/codex\n"; + } else if (!script.includes("ln -sfn") && !script.includes("paperclip_codex_launcher_tmp")) { + throw new Error(`unexpected command: ${command.command}`); + } + return { exitCode: 0, signal: null, timedOut: false, stderr: "", stdout }; + }, + ); + await createRunnerdBackend({ + db: leaseDb(gatedExecution), + execution: gatedExecution, + runnerInstanceId: "runner-image-runtime", + onLog, + runnerIngressAuthorized: true, + runnerExecutionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + environmentId: "environment", + leaseId: "lease", + providerKey: "daytona", + effectiveCapabilities: { runnerWebSocketIngress: true }, + runner: { execute: remoteExecute, syncIn }, + } as never, + }); + state.createTransport.mockClear(); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + const transport = state.createTransport.mock + .calls[0]![0] as RunnerTransportOptions & { + controlPlaneRegistration: (authority: unknown) => Promise; + }; + await expect(transport.controlPlaneRegistration({})).rejects.toThrow( + floor + ? `runner_remote_provider_artifact_incompatible: ${model} requires Codex ${floor} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@0.160.0` + : "reached-preinstalled-codex-verification", + ); + if (floor) { + // No npm spec is configured, so there is no fallback install; the run + // fails before launch instead of running against the stale CLI. + expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); + expect(remoteExecute.mock.calls.some(([call]) => call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); + } + }); + it("binds a remote launch to the configured controller-owned runner artifact", async () => { const remoteCwd = "/home/daytona/paperclip-workspace"; const controllerArtifact = "/controller/artifacts/paperclip-runnerd"; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index b815f976d2e..fec05347384 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -27,6 +27,11 @@ import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js"; import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; +import { + codexCliVersionAtLeast, + minimumCodexCliVersionForModel, + normalizeCodexModel, +} from "@paperclipai/adapter-codex-local"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; @@ -11333,6 +11338,21 @@ async function createRunnerdBackendWithinSessionClaim( `runner_remote_provider_artifact_incompatible: supported Codex versions ${REMOTE_CODEX_SUPPORTED_RANGE}, received ${version ?? "an unrecognized or prerelease version"}; install a supported stable Codex release or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, ); } + // A Codex inside the compatibility window can still be too old for the + // configured model: the ChatGPT backend rejects a model from clients + // below the model's floor on every turn. Fail before launch with the + // exact gap, so a stale sandbox image is not reported as an account + // problem. When a preinstalled Codex fails here and an npm spec is + // configured, the caller falls back to installing the pinned release. + const configuredModel = input.execution.provider.kind === "codex" + ? input.execution.provider.model + : null; + const modelMinimum = minimumCodexCliVersionForModel(configuredModel); + if (modelMinimum && !codexCliVersionAtLeast(version, modelMinimum)) { + throw new Error( + `runner_remote_provider_artifact_incompatible: ${normalizeCodexModel(configuredModel)} requires Codex ${modelMinimum} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex ${REMOTE_PROVIDER_PACK_PINS.codex} or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, + ); + } if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { reportedCodexVersions.add(version); await input.onLog?.( From db0bee19fa614d0782976d49cad59ccd0232a6fb Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 10:40:09 -0700 Subject: [PATCH 11/55] docs(readme): add Superagent badge and move Star History rank badge to its own line (#15460) - Add the Superagent security badge to the README badge row, with light and dark variants via . - Move the Star History global rank badge into its own centered paragraph below the badge row, with spacing. Co-Authored-By: Amy (Opus) via Paperclip Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a0db4476643..e433acfdc1b 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,12 @@

    MIT License Stars - Star History Rank Discord + Security posture verified by Superagent +

    + +

    + Star History Rank


    From 06484b3c4153b6201f1a07304694793b1fdf36dd Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 11:15:49 -0700 Subject: [PATCH 12/55] fix: preserve conversation retries through execution cleanup (#15463) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Recovery schedules bounded retries after a provider disconnects. > - A stopped run can still hold its environment lease while cleanup runs. > - Retrying before that lease is released cancels the new run before it starts and spends another retry. > - Restoring the task can also send the worker repair instructions from an already resolved recovery action. > - This pull request preserves the waiting retry and removes settled recovery instructions from later wakes. > - The task can continue after cleanup without an operator repairing the same incident again. ## Linked Issues or Issue Description **What happened?** A legacy conversation run disconnected while it was doing ordinary work. Its environment cleanup took longer than the retry delay. Two retries were cancelled before dispatch with `execution_reconciliation_required`. Those cancellations exhausted the failure budget. After an operator restored the task, the wake still told the original worker to repair the runtime and hand the task back to itself. **Expected behavior** Cleanup waits preserve the pending attempt. The same retry can continue after ownership is released, subject to all current gates. Once a recovery action is resolved or cancelled, subsequent task wakes omit its repair instructions. **Steps to reproduce** 1. Fail a legacy conversation run while its environment lease remains in `pending_cleanup`. 2. Schedule a bounded retry and run promotion before cleanup releases that lease. 3. Repeat the scheduler sweep. Before this fix, retries promote and then cancel without starting. 4. Resolve a stranded-task recovery action and build the restored task wake with that action ID. Before this fix, the wake still includes the settled repair instructions. **Paperclip version or commit** Reproduced with database regressions against `ceabc3bc880` on master. Related: #15019 restores a skipped assignment handoff after lease release. #15235 filters stale handoff evidence in the recovery sweep. This change preserves an existing scheduled conversation retry and corrects restored wake content. It does not create a new handoff wake. ## What Changed - Keep an unstarted legacy conversation retry on the same durable row while prior execution ownership remains active. Recheck after 30 seconds without increasing retry accounting. - Return a queued retry to scheduled state if it encounters that hold at the claim gate. Retain its issue claim and publish the status change. - Record one local lifecycle diagnostic per blocking run. Remove that wait marker on promotion. - Include recovery action metadata only while the referenced action is active or escalated. - Return an explicit `waiting` response and the saved schedule when Retry now meets cleanup. Show the wait inline without a false success or disabled button. - Add database, rendered-prompt, route, and UI regressions. Document the execution and run-log contracts. ## Verification - Five cleanup and restored-wake regressions fail against the original production code. The Retry now route and UI regressions also fail before their correction. - Related retry, dispatch, stale-queue, and recovery suites: 321 tests pass across seven files. All ten focused cleanup/restored-wake cases pass after rebase. The final dispatch adjustment passes all 46 adapter tests. - Retry now routes and affected UI suites: all 46 tests pass. The tests cover repeated clicks, the saved schedule, unchanged accounting, promotion after release, and no false success or error state. - `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, and `pnpm check:token-gates` pass. - Full local `pnpm test:run` was started and then stopped after the final commit passed all GitHub CI test shards. No complete local full-suite result is claimed; CI supplies the complete test result for the final commit. - Final head `6f1058a332c039e33c4f002b296d20a5554e760e`: all 55 GitHub checks are green or intentionally skipped. Apex review is 5/5 after two reviews, with no unresolved threads. The PR has no merge conflicts. ## Risks The wait applies only to unstarted legacy conversation retries. Native runs and non-conversation execution keep their existing recovery rules. Cleanup must actually release ownership before execution can resume. The wait does not fix a cleanup service that never finishes. Promotion and dispatch still enforce cancellation, reassignment, pause, budget, and reconciliation gates. No schema change is required. The Retry now response adds a `waiting` outcome; the shared contract and all three UI controls handle it. ## Model Used OpenAI Codex based on GPT-6, with repository analysis, tool use, and local code execution. The runtime does not expose the precise serving model ID or context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused suites; complete final-head test coverage in CI) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 16 +++ doc/run-log-events.md | 8 ++ packages/shared/src/types/issue.ts | 1 + .../__tests__/issue-recovery-actions.test.ts | 24 ++++ .../issue-scheduled-retry-routes.test.ts | 35 +++++- .../run-dispatch/adapters/postgres.test.ts | 111 ++++++++++++++++++ .../modules/run-dispatch/adapters/postgres.ts | 84 ++++++++++++- .../modules/run-dispatch/application/types.ts | 1 + server/src/services/heartbeat.ts | 62 ++++++---- ui/src/components/IssueBlockedNotice.tsx | 4 +- .../IssueScheduledRetryCard.test.tsx | 13 ++ ui/src/components/IssueScheduledRetryCard.tsx | 4 +- .../issue-properties/IssueProperties.tsx | 8 +- ui/src/hooks/useRetryNowMutation.ts | 1 + 14 files changed, 341 insertions(+), 31 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index aac00b02447..1819311080a 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -211,6 +211,22 @@ New comments received during an execution hold retain their individual deferred The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted. +An unstarted legacy conversation retry waits on its existing `scheduled_retry` +row while a previous execution still owns a process or environment lease. Each +sweep moves the next check forward without creating another run or incrementing +retry accounting. The wait survives a controller restart. A queued retry that +encounters the same ownership hold returns to that scheduled state. Once +ownership is released, normal promotion and dispatch gates still apply, +including reassignment, cancellation, budget limits, and no-replay holds. +The **Retry now** action returns `waiting` with the saved retry schedule while +cleanup holds ownership. Its controls show the wait without claiming that a run +started, and another click can check the gate again. + +Recovery instructions in a wake describe only an active or escalated recovery +action. Resolving or cancelling that action removes its repair instructions from +subsequent wakes, so the original worker can resume the task. This prompt change +does not clear any persisted execution reconciliation hold. + Workspace contention (`workspace_busy`) displays **Waiting for workspace** and continues automatically when the workspace is available. Internal scheduling attempts remain in the run log without conversation cancellation markers, diff --git a/doc/run-log-events.md b/doc/run-log-events.md index ed8f723652c..8941314f274 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -280,6 +280,14 @@ warnings and errors remain visible. Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry. +An unstarted legacy conversation retry waiting for execution cleanup retains +`resultJson.executionWait` with the issue, blocking run, and +`execution_owner_active` cause. A local lifecycle event records the wait and the +unchanged scheduled retry attempt once per blocking run. Repeated scheduler +checks update the same retry row without repeating that event. Promotion removes +this ownership-wait marker after cleanup; other execution holds retain their +existing dispatch gates. + If execution-continuation setup finds that a task no longer exists, is closed, or its owner changed, the existing cancellation settlement records `continuation_task_ownership_changed`. The run and wake request become cancelled diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 566269782da..7e0673f378e 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -645,6 +645,7 @@ export interface IssueScheduledRetry { export type IssueRetryNowOutcome = | "promoted" | "already_promoted" + | "waiting" | "no_scheduled_retry" | "gate_suppressed"; diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 02d0a599bf0..f6f3e2149b4 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -6,6 +6,7 @@ import express from "express"; import request from "supertest"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; import { costEvents, agents, @@ -1785,6 +1786,29 @@ describeEmbeddedPostgres("issue recovery actions", () => { }); }); + it.each(["resolved", "cancelled"])("omits %s recovery instructions when the original worker resumes", async (status) => { + const { companyId, coderId, sourceIssueId } = await seedCompany(); + const action = await issueRecoveryActionService(db).upsertSourceScoped({ + companyId, sourceIssueId, kind: "stranded_assigned_issue", ownerType: "board", + returnOwnerAgentId: coderId, cause: "stranded_assigned_issue", fingerprint: "finished-recovery", + evidence: { failureSummary: "Retry budget exhausted during cleanup." }, + nextAction: "Repair the runtime, then retry the original owner.", wakePolicy: null, + }); + await db.update(issueRecoveryActions).set({ status, outcome: "handed_back", resolvedAt: new Date() }) + .where(eq(issueRecoveryActions.id, action.id)); + const payload = await buildPaperclipWakePayload({ db, companyId, contextSnapshot: { + issueId: sourceIssueId, wakeReason: "issue_recovery_action_restored", + recoveryActionId: action.id, recoveryCause: action.cause, + } }); + expect(payload?.issue?.id).toBe(sourceIssueId); + expect(payload?.recovery).toBeNull(); + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("Implement backend recovery"); + expect(prompt).not.toContain("Recovery contract:"); + expect(prompt).not.toContain(action.nextAction); + expect(prompt).not.toContain("Do not produce the deliverable"); + }); + it("accepts new verified evidence after an automatic no-replay disposition without reopening on duplicate requests", async () => { const { companyId, coderId, sourceIssueId } = await seedCompany(); const runId = randomUUID(); diff --git a/server/src/__tests__/issue-scheduled-retry-routes.test.ts b/server/src/__tests__/issue-scheduled-retry-routes.test.ts index c5d477d659f..bd5e31432c7 100644 --- a/server/src/__tests__/issue-scheduled-retry-routes.test.ts +++ b/server/src/__tests__/issue-scheduled-retry-routes.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, @@ -9,6 +9,7 @@ import { agentWakeupRequests, companies, createDb, + environmentLeases, heartbeatRunEvents, heartbeatRuns, issueComments, @@ -44,6 +45,7 @@ describeEmbeddedPostgres("issue scheduled retry routes", () => { }, 20_000); afterEach(async () => { + await db.delete(environmentLeases); await db.delete(issueComments); await db.delete(issueRelations); await db.delete(issueTreeHolds); @@ -304,6 +306,37 @@ describeEmbeddedPostgres("issue scheduled retry routes", () => { expect(retryRuns[0]).toMatchObject({ id: retryRunId, status: "queued" }); }); + it("reports cleanup waits with the saved schedule and promotes the same retry after release", async () => { + const f = await seedIssueWithRetry(); + await db.update(heartbeatRuns) + .set({ resultJson: { conversationContinuation: "continue_conversation_v1" } }) + .where(inArray(heartbeatRuns.id, [f.sourceRunId, f.retryRunId])); + const [lease] = await db.insert(environmentLeases).values({ + companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.sourceRunId, + status: "pending_cleanup", cleanupStatus: "failed", + }).returning(); + const app = createApp(boardActor(f.companyId)); + for (let click = 0; click < 2; click++) { + const before = Date.now(); + const res = await request(app).post(`/api/issues/${f.issueId}/scheduled-retry/retry-now`).send({}); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ outcome: "waiting", scheduledRetry: { + runId: f.retryRunId, status: "scheduled_retry", scheduledRetryAttempt: 2, + } }); + expect(res.body.message).toContain("Waiting for execution cleanup"); + const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.retryRunId)); + expect(res.body.scheduledRetry.scheduledRetryAt).toBe(saved!.scheduledRetryAt!.toISOString()); + expect(saved!.scheduledRetryAt!.getTime()).toBeGreaterThanOrEqual(before + 30_000); + } + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, f.sourceRunId))).toHaveLength(1); + await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success", releasedAt: new Date() }) + .where(eq(environmentLeases.id, lease!.id)); + const resumed = await request(app).post(`/api/issues/${f.issueId}/scheduled-retry/retry-now`).send({}); + expect(resumed.body).toMatchObject({ outcome: "promoted", scheduledRetry: { + runId: f.retryRunId, status: "queued", scheduledRetryAttempt: 2, + } }); + }); + it("returns a clear no-op response when there is no scheduled retry", async () => { const companyId = randomUUID(); const issueId = randomUUID(); diff --git a/server/src/modules/run-dispatch/adapters/postgres.test.ts b/server/src/modules/run-dispatch/adapters/postgres.test.ts index 812eeb43529..26a7e0e963b 100644 --- a/server/src/modules/run-dispatch/adapters/postgres.test.ts +++ b/server/src/modules/run-dispatch/adapters/postgres.test.ts @@ -8,6 +8,7 @@ import { createDb, documentRevisions, documents, + environmentLeases, heartbeatRunEvents, heartbeatRuns, issueDocuments, @@ -51,6 +52,7 @@ describeEmbeddedPostgres("run-dispatch postgres adapter", () => { }, 20_000); afterEach(async () => { + await db.delete(environmentLeases); await db.delete(activityLog); await db.delete(issueDocuments); await db.delete(documentRevisions); @@ -154,6 +156,115 @@ describeEmbeddedPostgres("run-dispatch postgres adapter", () => { return runId; } + async function seedCleanupRetry(status: "scheduled_retry" | "queued" = "scheduled_retry") { + const { companyId, agentId } = await seedCompanyAndAgent(); + const issueId = randomUUID(); + const now = new Date(); + await seedIssue({ companyId, issueId, assigneeAgentId: agentId, status: "in_progress" }); + const sourceId = await seedRun({ companyId, agentId, contextSnapshot: { issueId } }); + await db.update(heartbeatRuns).set({ status: "failed", finishedAt: now, + resultJson: { conversationContinuation: "continue_conversation_v1", workspaceRestoreFailure: "restore_failed" }, + }).where(eq(heartbeatRuns.id, sourceId)); + const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, + heartbeatRunId: sourceId, status: "pending_cleanup", cleanupStatus: "failed", + }).returning(); + const runId = await seedRun({ companyId, agentId, status, now, + contextSnapshot: { issueId, retryOfRunId: sourceId, executionRetryAccounting: { version: 1, failureRetries: 1, maxTurnContinuations: 0 } }, + scheduledRetryReason: "transient_failure", + }); + await db.update(heartbeatRuns).set({ retryOfRunId: sourceId, scheduledRetryAttempt: 1, + resultJson: { conversationContinuation: "continue_conversation_v1" }, + }).where(eq(heartbeatRuns.id, runId)); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + return { companyId, agentId, issueId, sourceId, leaseId: lease!.id, runId, now }; + } + + it("keeps one scheduled retry through cleanup and promotes it once after release", async () => { + const f = await seedCleanupRetry(); + let adapter = createPostgresRunDispatchAdapter(db); + for (let attempt = 0; attempt < 4; attempt++) { + expect(await adapter.promoteOrCancelDueRetry({ ...f, now: new Date(f.now.getTime() + attempt * 60_000) })) + .toMatchObject({ outcome: "not_promoted" }); + // Reconstruct the service to prove the wait survives controller restart. + adapter = createPostgresRunDispatchAdapter(db); + } + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run).toMatchObject({ status: "scheduled_retry", startedAt: null, finishedAt: null, + retryOfRunId: f.sourceId, scheduledRetryAttempt: 1, + contextSnapshot: { executionRetryAccounting: { failureRetries: 1 } }, + }); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(2); + expect(await db.select().from(issueRecoveryActions)).toHaveLength(0); + const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, f.runId)); + expect(events.filter(e => e.message?.includes("waiting for execution cleanup"))).toHaveLength(1); + await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success", releasedAt: new Date() }) + .where(eq(environmentLeases.id, f.leaseId)); + const now = new Date(f.now.getTime() + 300_000); + const outcomes = await Promise.all([ + adapter.promoteOrCancelDueRetry({ ...f, now }), adapter.promoteOrCancelDueRetry({ ...f, now }), + ]); + expect(outcomes.filter(o => o.outcome === "promoted")).toHaveLength(1); + const [resumed] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(resumed).toMatchObject({ status: "queued", scheduledRetryAttempt: 1 }); + expect(resumed.resultJson).not.toHaveProperty("executionWait"); + expect(await adapter.cancelStaleQueuedRun({ ...f, now, expectedStatus: "queued" })) + .toMatchObject({ outcome: "not_stale" }); + }); + + it("parks the same unstarted retry if cleanup becomes pending after promotion", async () => { + const f = await seedCleanupRetry("queued"); + const adapter = createPostgresRunDispatchAdapter(db); + expect(await adapter.cancelStaleQueuedRun({ ...f, expectedStatus: "queued" })) + .toMatchObject({ outcome: "deferred" }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run).toMatchObject({ status: "scheduled_retry", scheduledRetryAttempt: 1, startedAt: null, finishedAt: null }); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBe(f.runId); + }); + + it("still suppresses a cleanup-waiting retry when the task is cancelled", async () => { + const f = await seedCleanupRetry(); + await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId)); + expect(await createPostgresRunDispatchAdapter(db).promoteOrCancelDueRetry(f)) + .toMatchObject({ outcome: "gate_suppressed", errorCode: "issue_cancelled" }); + }); + + it("still suppresses a cleanup-waiting retry after reassignment", async () => { + const f = await seedCleanupRetry(); + const newOwnerId = randomUUID(); + await seedAgent({ id: newOwnerId, companyId: f.companyId, name: "New owner" }); + await db.update(issues).set({ assigneeAgentId: newOwnerId }).where(eq(issues.id, f.issueId)); + expect(await createPostgresRunDispatchAdapter(db).promoteOrCancelDueRetry(f)) + .toMatchObject({ outcome: "gate_suppressed", errorCode: "issue_reassigned" }); + }); + + it("retains a no-replay hold after cleanup releases ownership", async () => { + const f = await seedCleanupRetry(); + const adapter = createPostgresRunDispatchAdapter(db); + await db.insert(issueRecoveryActions).values({ + companyId: f.companyId, sourceIssueId: f.issueId, kind: "active_run_watchdog", + ownerType: "board", cause: "uncertain_external_action", status: "resolved", + evidence: { automaticRecovery: { replay: "blocked" } }, fingerprint: f.sourceId, + nextAction: "Verify the external action before continuing.", + }); + expect(await adapter.promoteOrCancelDueRetry(f)).toMatchObject({ outcome: "not_promoted" }); + await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success", releasedAt: new Date() }) + .where(eq(environmentLeases.id, f.leaseId)); + const now = new Date(f.now.getTime() + 60_000); + expect(await adapter.promoteOrCancelDueRetry({ ...f, now })).toMatchObject({ outcome: "promoted" }); + expect(await adapter.cancelStaleQueuedRun({ ...f, now, expectedStatus: "queued" })) + .toMatchObject({ outcome: "cancelled", errorCode: "execution_reconciliation_required" }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ cause: "uncertain_external_action" }); + }); + + it.each(["native", "started", "non-conversation"])("does not park a %s run behind cleanup", async (mode) => { + const f = await seedCleanupRetry("queued"); + await db.update(heartbeatRuns).set(mode === "native" ? { runtimeMode: "native" } + : mode === "started" ? { startedAt: f.now } : { resultJson: {} }) + .where(eq(heartbeatRuns.id, f.runId)); + expect(await createPostgresRunDispatchAdapter(db).cancelStaleQueuedRun({ ...f, expectedStatus: "queued" })) + .toMatchObject({ outcome: "cancelled", errorCode: "execution_reconciliation_required" }); + }); + async function seedContinuationSummary(input: { companyId: string; issueId: string; diff --git a/server/src/modules/run-dispatch/adapters/postgres.ts b/server/src/modules/run-dispatch/adapters/postgres.ts index 570784d2e98..693a68a22b1 100644 --- a/server/src/modules/run-dispatch/adapters/postgres.ts +++ b/server/src/modules/run-dispatch/adapters/postgres.ts @@ -643,7 +643,12 @@ export function createPostgresRunDispatchAdapter( > { const [row] = await tx .update(heartbeatRuns) - .set({ status: "queued", updatedAt: input.now }) + .set({ + status: "queued", + updatedAt: input.now, + resultJson: sql`case when ${heartbeatRuns.resultJson}->'executionWait'->>'cause' = 'execution_owner_active' + then ${heartbeatRuns.resultJson} - 'executionWait' else ${heartbeatRuns.resultJson} end`, + }) .where( and( eq(heartbeatRuns.id, input.runId), @@ -832,6 +837,11 @@ export function createPostgresRunDispatchAdapter( : { outcome: { outcome: "not_promoted" as const }, telemetryRun: null }; } + const issueId = factsResult.facts.issueId; + if (issueId && (await deferRetryForCleanupInTx(tx, run, issueId, now))) { + return { outcome: { outcome: "not_promoted" as const }, telemetryRun: null }; + } + const promoted = await promoteDueRetryInTx(tx as unknown as Db, { runId: input.runId, companyId: input.companyId, @@ -955,9 +965,9 @@ export function createPostgresRunDispatchAdapter( async function decideCurrentRunStaleness(tx: Db, run: HeartbeatRun, now: Date) { const contextSnapshot = parseObject(run.contextSnapshot); const issueId = readNonEmptyString(contextSnapshot.issueId); - if (!issueId) return { issueId: null, facts: null, decision: { stale: false as const } }; + if (!issueId) return { issueId: null, facts: null, executionBlocker: null, decision: { stale: false as const } }; const recovery = await getExecutionBlocker(tx, run.companyId, issueId, { conversationResetCommentId: deriveCommentId(contextSnapshot) }); - if (recovery) return { issueId, facts: null, decision: { stale: true as const, + if (recovery) return { issueId, facts: null, executionBlocker: recovery, decision: { stale: true as const, errorCode: "execution_reconciliation_required" as const, reason: recovery.nextAction, details: { issueId, recoveryActionId: recovery.recoveryActionId }, } }; @@ -973,7 +983,67 @@ export function createPostgresRunDispatchAdapter( now, tx, ); - return { issueId, facts, decision: decideQueuedRunStaleness(facts, now) }; + return { issueId, facts, executionBlocker: null, decision: decideQueuedRunStaleness(facts, now) }; + } + + /** + * Keep the existing attempt durable while the previous execution releases + * ownership. Only an unstarted legacy conversation retry can wait here; + * reconciliation of uncertain actions and native execution retain their gates. + * Both callers hold the issue and run locks, in that order. + */ + async function deferRetryForCleanupInTx( + tx: Db, + run: HeartbeatRun, + issueId: string, + now: Date, + observedBlocker?: Awaited>, + ) { + if ( + run.runtimeMode !== "legacy" || !run.retryOfRunId || run.startedAt || + run.processPid || run.processGroupId || + !hasConversationContinuationPolicy(run.resultJson) || + !["scheduled_retry", "queued"].includes(run.status) + ) return null; + // Use the dispatch gate's observation when supplied. A second read could + // see cleanup finish and accidentally cancel the retry for the old hold. + const blocker = observedBlocker === undefined + ? await getExecutionBlocker(tx, run.companyId, issueId) + : observedBlocker; + if (blocker?.cause !== "execution_owner_active") return null; + + const previousWait = parseObject(run.resultJson?.executionWait); + const scheduledRetryAt = new Date(now.getTime() + 30_000); + const executionWait = { issueId, runId: blocker.runId, cause: blocker.cause }; + const [waiting] = await tx.update(heartbeatRuns) + .set({ + status: "scheduled_retry", + scheduledRetryAt, + updatedAt: now, + contextSnapshot: { ...parseObject(run.contextSnapshot), scheduledRetryAt: scheduledRetryAt.toISOString() }, + resultJson: { ...parseObject(run.resultJson), executionWait }, + }) + .where(and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.status, run.status), + )) + .returning(); + if (!waiting) return null; + // Repeated sweeps retain one wait diagnostic, not a new attempt or event. + if (previousWait.cause !== blocker.cause || previousWait.runId !== blocker.runId) { + await appendHeartbeatRunEvent(tx, { + companyId: run.companyId, + runId: run.id, + agentId: run.agentId, + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Scheduled retry is waiting for execution cleanup; the same attempt will continue after ownership is released", + payload: { ...executionWait, scheduledRetryAttempt: run.scheduledRetryAttempt }, + }); + } + return { outcome: "deferred" as const, postCommitEffects: [statusEffect(waiting, run.status)] }; } async function cancelStaleQueuedRun( @@ -981,7 +1051,7 @@ export function createPostgresRunDispatchAdapter( ): Promise { const cancelLockedRun = async (tx: Db, run: HeartbeatRun) => { if (run.status !== input.expectedStatus) return { outcome: "lost_race" as const }; - const { issueId, facts, decision } = await decideCurrentRunStaleness(tx, run, input.now); + const { issueId, facts, decision, executionBlocker } = await decideCurrentRunStaleness(tx, run, input.now); if (!decision.stale || !issueId) { if (input.expectedStatus === "queued" && facts?.isInteractionWake) { // Preserve the authority accepted under the issue/run locks. Later @@ -996,6 +1066,10 @@ export function createPostgresRunDispatchAdapter( } return { outcome: "not_stale" as const }; } + if (input.expectedStatus === "queued" && decision.errorCode === "execution_reconciliation_required") { + const deferred = await deferRetryForCleanupInTx(tx, run, issueId, input.now, executionBlocker); + if (deferred) return deferred; + } return cancelStaleRunInTx(tx, run, issueId, decision, input.expectedStatus, input.now); }; diff --git a/server/src/modules/run-dispatch/application/types.ts b/server/src/modules/run-dispatch/application/types.ts index c91d51c8ed1..3241977d2e3 100644 --- a/server/src/modules/run-dispatch/application/types.ts +++ b/server/src/modules/run-dispatch/application/types.ts @@ -46,6 +46,7 @@ export type PromoteScheduledRetryOutcome = export type CancelStaleQueuedRunOutcome = | { outcome: "not_stale" } + | { outcome: "deferred"; postCommitEffects: PostCommitEffect[] } /** The run's status no longer matched the phase the caller expected; a concurrent writer already won. */ | { outcome: "lost_race" } | { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 744f1ec293c..22895243838 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8162,9 +8162,11 @@ export async function buildPaperclipWakePayload(input: { .then((rows) => rows[0] ?? null) : null; const recoveryEvidence = parseObject(recoveryAction?.evidence); - const executionAlreadyReconciled = - recoveryAction?.status === "resolved" && - Boolean(recoveryEvidence.executionReconciliation); + // A restored task resumes its deliverable, not the completed repair. A + // referenced settled/missing action must not fall back to a stale cause. + const recoveryStillActive = recoveryAction + ? ["active", "escalated"].includes(recoveryAction.status) + : !recoveryActionId && Boolean(recoveryCause); const originalAssigneeId = recoveryAction?.returnOwnerAgentId ?? recoveryAction?.previousOwnerAgentId ?? @@ -8208,7 +8210,7 @@ export async function buildPaperclipWakePayload(input: { attachmentOmissions, externalChatProvider, recovery: - !executionAlreadyReconciled && (recoveryAction || recoveryCause) + recoveryStillActive ? { cause: recoveryAction?.cause ?? recoveryCause, failureSummary: readNonEmptyString(recoveryEvidence.failureSummary), @@ -16876,17 +16878,25 @@ export function heartbeatService( if (promotion.outcome === "promoted") { applyRunDispatchPostCommitEffects(promotion.postCommitEffects); } - const promotedRow = await getIssueRetryRun(issue.companyId, issue.id, [ - "queued", - "running", - "cancelled", - ]); - const scheduledRetry = promotedRow - ? summarizeIssueScheduledRetryRun(promotedRow) - : summarizeIssueScheduledRetryRun({ - run: updated, + // Promotion can preserve this row as a cleanup wait. Read that exact run, + // not an older cancelled retry or the pre-promotion schedule. + const currentRun = await getRun(updated.id); + const scheduledRetry = currentRun + ? summarizeIssueScheduledRetryRun({ + run: currentRun, agentName: scheduled.agentName, - }); + }) + : null; + + if (currentRun?.status === "scheduled_retry") { + return { + outcome: "waiting" as const, + message: parseObject(currentRun.resultJson?.executionWait).cause === "execution_owner_active" + ? "Waiting for execution cleanup. Paperclip will retry automatically once cleanup finishes." + : "The retry remains scheduled. Paperclip will check again at the scheduled time.", + scheduledRetry, + }; + } if (promotion.outcome === "promoted") { return { @@ -16902,10 +16912,17 @@ export function heartbeatService( scheduledRetry, }; } + if (currentRun && ["queued", "running"].includes(currentRun.status)) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry, + }; + } return { - outcome: "already_promoted" as const, - message: "Scheduled retry was already promoted", - scheduledRetry, + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, }; } @@ -17634,11 +17651,16 @@ export function heartbeatService( companyId: run.companyId, expectedStatus: "queued", }); - if (staleness.outcome === "cancelled") { + if (staleness.outcome === "cancelled" || staleness.outcome === "deferred") { applyRunDispatchPostCommitEffects(staleness.postCommitEffects); logger.info( - { runId: run.id, issueId, errorCode: staleness.errorCode }, - "claimQueuedRun: cancelled stale queued run", + { + runId: run.id, + issueId, + outcome: staleness.outcome, + errorCode: staleness.outcome === "cancelled" ? staleness.errorCode : undefined, + }, + "claimQueuedRun: withheld queued run at the execution gate", ); return null; } diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index f69774556a8..b742aa7b3ed 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -95,7 +95,9 @@ function SuccessfulRunRetryNowControl({
    - Paperclip will ask the assignee to choose the next step {scheduleLabel}. Retry now starts that follow-up immediately. + {retryNow.data?.outcome === "waiting" && retryNow.data.scheduledRetry?.runId === scheduledRetry.runId + ? retryNow.data.message + : <>Paperclip will ask the assignee to choose the next step {scheduleLabel}. Retry now starts that follow-up immediately.}
    diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 5ea44bca681..db6de10d240 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -1542,9 +1542,11 @@ export function IssueProperties({ ? retryNow.data?.outcome === "already_promoted" ? "Already promoted — run starting" : "Promoted — run starting" - : scheduledRetryIsContinuation - ? "Pulls continuation forward immediately" - : "Pulls retry forward immediately"} + : retryNow.data?.outcome === "waiting" && retryNow.data.scheduledRetry?.runId === scheduledRetry.runId + ? retryNow.data.message + : scheduledRetryIsContinuation + ? "Pulls continuation forward immediately" + : "Pulls retry forward immediately"}
    diff --git a/ui/src/hooks/useRetryNowMutation.ts b/ui/src/hooks/useRetryNowMutation.ts index 6fec474677a..234017cf414 100644 --- a/ui/src/hooks/useRetryNowMutation.ts +++ b/ui/src/hooks/useRetryNowMutation.ts @@ -24,6 +24,7 @@ function readErrorMessage(error: unknown): string { export const RETRY_NOW_OUTCOME_HEADLINE: Record = { promoted: "Retry promoted", already_promoted: "Retry already running", + waiting: "Retry waiting", no_scheduled_retry: "No scheduled retry", gate_suppressed: "Couldn't retry now", }; From ae6f95ed7a777069717b90230d651e7ed1060d35 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:53:46 -0500 Subject: [PATCH 13/55] feat: add personal primary agents (#15470) Add a personal primary agent per company and user. Initialize it from the first human-created agent, expose profile-only switching with confirmation, and use it after recent choices for task and Chat defaults. Persist authenticated preferences, preserve lifecycle and membership rules, keep selections out of shared audit events, and document the API contract. Include the reviewed Storybook surfaces and regression coverage for concurrent choices, onboarding, cross-device updates, and browser journeys. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 9 + doc/SPEC-implementation.md | 26 + doc/SPEC.md | 7 + .../2026-10-07-personal-primary-agent.md | 180 ++ .../src/migrations/0315_rapid_emma_frost.sql | 44 + ...{0310_snapshot.json => 0315_snapshot.json} | 2304 ++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 2 + .../db/src/schema/user_company_preferences.ts | 16 + packages/shared/src/index.ts | 2 + packages/shared/src/primary-agent.ts | 14 + .../agent-permissions-routes.test.ts | 13 +- .../src/__tests__/agent-skills-routes.test.ts | 5 +- .../src/__tests__/approvals-service.test.ts | 9 +- .../src/__tests__/company-portability.test.ts | 22 +- server/src/__tests__/openapi-routes.test.ts | 19 + server/src/__tests__/primary-agent.test.ts | 206 ++ server/src/app.ts | 2 + server/src/routes/agents.ts | 2 + server/src/routes/openapi.ts | 32 + server/src/routes/primary-agent.ts | 51 + server/src/services/agents.ts | 8 + server/src/services/approvals.ts | 2 +- server/src/services/company-portability.ts | 2 +- server/src/services/onboarding-seed.ts | 5 +- server/src/services/primary-agent.ts | 82 + server/src/services/resource-memberships.ts | 9 + tests/e2e/agent-chat-sessions.spec.ts | 9 +- ui/src/api/primaryAgent.ts | 7 + ui/src/components/EntityRow.tsx | 3 + ui/src/components/NewIssueDialog.test.tsx | 22 + ui/src/components/NewIssueDialog.tsx | 9 +- ui/src/components/Sidebar.tsx | 2 +- .../components/SidebarAgents.production.tsx | 13 +- ui/src/components/SidebarAgents.tsx | 13 +- .../PrimaryAgentPresentation.tsx | 94 + ui/src/context/LiveUpdatesProvider.test.ts | 12 + ui/src/context/LiveUpdatesProvider.tsx | 6 +- ui/src/context/PrimaryAgentProvider.test.tsx | 129 + ui/src/context/PrimaryAgentProvider.tsx | 54 + ui/src/hooks/usePrimaryAgent.ts | 21 + ui/src/hooks/useResourceMemberships.ts | 1 + ui/src/lib/queryKeys.ts | 3 + ui/src/main.tsx | 3 + ui/src/pages/AgentChats.test.tsx | 29 + ui/src/pages/AgentChats.tsx | 13 +- ui/src/pages/AgentDetail.tsx | 4 +- ui/src/pages/Agents.production.tsx | 2 + ui/src/pages/Agents.tsx | 2 + .../primary-agent/00-review-guide.stories.tsx | 54 + .../primary-agent/01-components.stories.tsx | 20 + .../primary-agent/02-sidebar.stories.tsx | 21 + .../primary-agent/03-agents.stories.tsx | 19 + .../primary-agent/04-new-task.stories.tsx | 14 + .../stories/primary-agent/05-chat.stories.tsx | 13 + .../primary-agent/06-journeys.stories.tsx | 108 + .../primary-agent/07-confirmation.stories.tsx | 20 + .../primary-agent/PrimaryAgentStory.tsx | 269 ++ 58 files changed, 4017 insertions(+), 52 deletions(-) create mode 100644 doc/plans/2026-10-07-personal-primary-agent.md create mode 100644 packages/db/src/migrations/0315_rapid_emma_frost.sql rename packages/db/src/migrations/meta/{0310_snapshot.json => 0315_snapshot.json} (95%) create mode 100644 packages/db/src/schema/user_company_preferences.ts create mode 100644 packages/shared/src/primary-agent.ts create mode 100644 server/src/__tests__/primary-agent.test.ts create mode 100644 server/src/routes/primary-agent.ts create mode 100644 server/src/services/primary-agent.ts create mode 100644 ui/src/api/primaryAgent.ts create mode 100644 ui/src/components/primary-agent/PrimaryAgentPresentation.tsx create mode 100644 ui/src/context/PrimaryAgentProvider.test.tsx create mode 100644 ui/src/context/PrimaryAgentProvider.tsx create mode 100644 ui/src/hooks/usePrimaryAgent.ts create mode 100644 ui/storybook/stories/primary-agent/00-review-guide.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/01-components.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/02-sidebar.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/03-agents.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/04-new-task.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/05-chat.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/06-journeys.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/07-confirmation.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index b47d36a50b4..e53dcafe773 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -30,6 +30,15 @@ Example: A CEO agent's adapter config tells it to "review what your executives a Then you define who reports to the CEO: a CTO managing programmers, a CMO managing the marketing team, and so on. Every agent in the tree gets their own adapter configuration. +Each person also has a personal primary agent per company. Their first human-created +agent becomes primary automatically; they can choose another from its profile. +An existing primary is replaced only after confirmation. The crown appears on the +profile and roster, while the primary stays first in the Agents sidebar without a +sidebar crown. Stars remain independent. Task drafts, explicit assignments, and +recent choices take precedence over the primary; Chat similarly reopens a valid +recent conversation before falling back to the primary. Opening Chat does not +start execution. This preference does not change the org chart or permissions. + ### Agent Execution Paperclip supports several ways to run an agent's heartbeat: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 9fb9c80d7f1..f50252a776d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -543,6 +543,32 @@ Decision-desk triage uses company-scoped sidecars rather than adding queue field - `decision_archive_notification_outbox` records one retry-safe origin-agent notification per source/archive version. The 90-day internal sweeper archives only unkept rows and coalesces delivery per origin agent. - Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts. +## 7.17 Personal primary agent (2026-10-07) + +`user_company_preferences` has a unique company/user key, nullable `primary_agent_id`, +and sticky `primary_agent_initialized` flag. GET/PUT +`/api/companies/:companyId/primary-agent/me` derives the human identity from auth, +checks company access and agent visibility, rejects agent actors, and audits changes. +PUT accepts an approved, non-terminated agent; choosing a previously left agent +rejoins it under the existing membership rules. There is no explicit removal UI or +nullable PUT. Leaving, termination, and deletion clear the reference without +allowing a later creation to initialize it again. Pausing and errors retain it. + +Initialization runs in the human-attributed creation transaction, including +onboarding, with uniqueness arbitrating concurrent creation. System provisioning +and agent-authored hires cannot initialize a human preference. Migration backfill +uses the earliest attributable human creation, preserving empty initialized state +when the original is gone, terminated, or left; unknown ownership stays unset. + +The profile is the only setting surface. Replacing a primary requires the reviewed +avatar-to-avatar confirmation. A first choice has no modal. Only the profile and +roster show the accessible crown; the Agents sidebar pins the primary first without +duplication. Tasks preserve explicit/draft assignments, then choose a recent +eligible assignee, the eligible primary, or the existing fallback. Chat preserves +a valid recent conversation, then opens the primary, then retains its chooser. +Navigation alone does not create an execution. Failed preference mutations restore +the previous state and offer retry. Preferences and caches are company/user scoped. + ## 8. State Machines ## 8.1 Agent Status diff --git a/doc/SPEC.md b/doc/SPEC.md index a346d7abcdb..10dd0c809de 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -91,6 +91,13 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t ## 2. Agent Model [DRAFT] +A human's personal primary agent is a company-scoped navigation and assignment +preference, independent of the org chart, roles, stars, and authority. First human +creation initializes it automatically. Later explicit choices persist across +devices; recent task/chat choices take precedence. The profile owns the setting +and replacement confirmation. See the personal-primary addendum in +`SPEC-implementation.md` for persistence and lifecycle rules. + Every employee is an agent. Agents are the workforce. ### Cryptographic identity diff --git a/doc/plans/2026-10-07-personal-primary-agent.md b/doc/plans/2026-10-07-personal-primary-agent.md new file mode 100644 index 00000000000..9ce1100de55 --- /dev/null +++ b/doc/plans/2026-10-07-personal-primary-agent.md @@ -0,0 +1,180 @@ +# Personal primary agent + +## Current boundary + +The user approved the revised Storybook design on 2026-10-07 and requested full +implementation plus acceptance testing in a real isolated test drive. This branch +now includes persistence, API wiring, and production defaults. + +- Branch: `codex/personal-primary-agent-storybook`. +- Preview: http://localhost:6017/?path=/story/primary-agent-00-review-guide--start-here +- Source: `ui/storybook/stories/primary-agent/` (one feature folder). +- Shared presentation: `ui/src/components/primary-agent/PrimaryAgentPresentation.tsx`. +- Stories keep simulated state as the approved reference. The application now + mounts a separate provider backed by a user/company preference endpoint. +- Delivery includes real test-drive acceptance on desktop/mobile and required + repository checks. After reviewing the working feature, the user requested a + pull request with passing checks and merge into master. + +## Agreed product contract + +Each person has one primary agent per company, marked with an accessible +"My primary agent" crown on the profile and agent list. Neither the Agents +sidebar nor the Chat sidebar shows a crown. Different people can choose different agents; the +preference will persist across devices. Crowning does not change roles, +reporting lines, permissions, tasks, routines, or shared Board Operations. + +- The first human-attributed creation silently initializes the primary, including + onboarding. Later creations, system provisioning, and agent-authored hires do + not replace or establish a person's preference. +- Tasks preserve explicit assignments and restored drafts, then prefer a recent + eligible assignee, then the primary, then the existing fallback. +- Chat reopens a valid recent conversation, otherwise the primary's conversation, + otherwise its existing chooser. Opening Chat does not start execution. +- The individual agent page offers Set as my primary beneath the agent details. + Kebab menus have no primary action, and there is no explicit removal option. + Standalone component examples do not offer this action. The current primary + shows its crown with no redundant set action. When a primary already exists, + switching requires a modal with the two agents' avatars and an arrow above. + The incoming primary has an outlined avatar and a crown beside its name: + "This will make [new name] your primary agent, do you want to do this?" + Cancel leaves the current primary unchanged. When none exists, + choosing one applies immediately without the modal. Stars remain independent. + Failures roll back and offer an actionable retry. These review decisions + supersede the original menu, removal, and immediate-switching design. +- The primary stays first in the Agents sidebar, even when idle; other ordering + is preserved and the primary appears only once in that section. +- Pausing and errors retain the crown and existing execution restrictions. + Leaving or terminating clears it without resetting initialization. Crowning a + previously left agent rejoins it under existing membership rules. + +## Review coverage and implementation notes + +Stories use the actual streamlined and classic Layout components, roster/profile +pages, composer, Chat pages/navigation, existing menus, and shared crown components. +Fixtures include idle/active, first-created result, absent/cleared/unavailable, +paused/error, independent stars, long names, loading, failed mutations, desktop, +mobile, and light theme. Interactive stories cover setting from the profile, +absence of primary menu actions, leaving, starring, rollback/retry, and opening +a task or Chat after switching, cancellation, and first choice without confirmation. + +The streamlined Sidebar now includes the reviewed Agents section by default. +Stories can still supply it through Layout's existing sidebar slot for review. +The icon rail is retired in SidebarContext; collapsed coverage uses the supported +collapsible Agents section plus the mobile drawer. No rail is reintroduced. + +Reloading resets the fixture preference. Task fallback is simulated through the +existing composer's defaults/recent-assignee input. Chat entry routing and the +first-created result are also simulations. These stories are design references, +not evidence of authorization, persistence, concurrency, or real execution. + +## Verification at review handoff + +- UI TypeScript build (`tsc -b ui`). +- 61 existing tests across SidebarAgents, AgentActionButtons, EntityRow, Agents, + and AgentOverview. +- Token gates and palette/character synchronization checks. +- Full static Storybook build. +- Browser walkthroughs: switch crown and sidebar order, independent stars, + rollback and Retry, leave/rejoin, direct profile action, task assignment/draft + precedence, recent Chat history, Chat fallback/chooser, and mobile drawer, + composer, and profile at 390 by 844. + +## Implementation delivered + +1. Added a company/user preference with nullable primaryAgentId and initialization + state; enforce uniqueness. Synchronize db/shared/server/ui contracts. +2. Added authenticated GET/PUT `/api/companies/:companyId/primary-agent/me`, deriving + the user from authentication, enforcing company access and agent visibility, + rejecting agent actors, and recording mutation activity. +3. Initialized transactionally during human-attributed creation. Concurrent + creations establish one primary and cannot overwrite an explicit choice or + lifecycle-cleared preference. Backfill from the earliest attributable human creation; + if that original agent is gone or terminated, leave the crown empty. Unknown + ownership remains unset. +4. Added lifecycle cleanup and existing membership rejoin behavior. Connected a + user/company-scoped query hook to the reviewed presentation and defaults. +5. Verified concurrency, backfill, lifecycle cleanup, user/company isolation + (including local trusted mode), drafts, recent selections, unavailable agents, + and real desktop/mobile task and Chat journeys. + +## Real test-drive acceptance (2026-10-07) + +- Isolated CLI test drive: `/tmp/paperclip-primary-agent-test-drive`, company + **Primary Agent Test Drive**, prefix `PRI`, at http://localhost:3104. +- Maia was crowned automatically through the real creation API. Creating Alex + preserved Maia. The simplified creation wizard's model-connection step rejected + the disposable placeholder credential; Alex was created through the real API + with the existing local Codex login instead. No creation-wizard success is claimed. +- Desktop: profile-only setting, cancel/confirm, persistence after reload, roster + crown, sidebar ordering, absent sidebar crowns and menu actions, leave/clear, + first choice without a modal, rejoin, and paused crown retention passed. +- Fresh browser-origin task composer selected Alex from the primary. After switching + to Maia, the composer preserved recent Alex and Chat reopened Alex's recent + conversation. An existing draft on the other origin remained intact. +- Opening Chat used the primary with no new execution. Sending a message produced + **“Alex received this primary-agent chat.”** from a successful real Codex run. +- Real task **PRI-3, Acknowledge Alex’s task receipt**, completed with + **“Task received by Alex”** and status `done`. The initial probe used an unsupported + `gpt-5.4` subscription model; both agents were changed to `gpt-6.1-sol`, the successful + checks were repeated, and the obsolete probe task was cancelled. +- Mobile at 390 × 844: the reviewed modal fits, confirmation switches the crown, + and the page has no horizontal overflow. Viewport override reset afterward. +- Focused tests cover initialization, simultaneous creation/selection, audit order, + leave races, backfill, visibility boundaries, viewer self-service, auth-derived + user/company isolation, optimistic rollback/retry, session retry, task defaults, + and recent Chat precedence. + +## Verification results + +- Repository typecheck passed with `pnpm -r --workspace-concurrency=1 typecheck`. + Final server/UI typechecks also passed after the last feature refinements. +- `pnpm build`, `pnpm check:token-gates`, and `git diff --check` passed. +- Entire UI suite: **698 files, 7,760 tests passed**. +- Entire DB suite, run serially: **47 files, 166 tests passed**. Parallel attempts + failed to initialize embedded PostgreSQL before migrations; the serial run + resolves that migration-verification concern. +- Focused primary/default/provider checks passed: **106 tests** across final + runs (9 database/API tests, plus provider, composer, and Chat tests). +- Existing onboarding, hire idempotency, resource membership, and company import + suites passed: **125 tests**. Legacy approval plus terminalization checks: + **15 tests passed**. Import/approval expectations now assert original creator + attribution rather than dropping the new creation option. +- **Repository-wide tests are not green.** `pnpm test:run` was stopped after + roughly an hour with failures, rather than being represented as a complete pass. + `adapter-cost-failure-paths.test.ts` still has OpenCode timeout failures in a + fresh rerun. `workspace-git-snapshot-streaming.test.ts` timed out after 300 seconds. + Heartbeat database-setup timeouts passed isolated reruns. The broad run began + before the final edits and also reported primary/approval failures; those suites + passed in fresh processes against final source. Later broad-run groups did not + complete. These local failures remain disclosed; PR checks must pass on the + current head before merge. + +The final-code test drive remains running at http://localhost:3104 with **Alex** +primary and **Maia's profile** open. Choose **Set as my primary**, confirm, and +reload to try persistence. New Task and Chat retain recent Alex choices by design; +the primary is their fallback when no eligible recent choice or draft exists. + +## Pull request verification + +- PR: https://github.com/paperclipai/paperclip/pull/15470. The user authorized + passing PR checks and merging into master. +- The rebased branch passes repository typecheck and build, token gates, and + 114 focused tests. Migration replay now runs twice without replacing choices. +- The earlier OpenCode timeouts pass all 31 tests with an isolated XDG config + directory; the local config copy caused the delay. The large Git streaming test + also passes when run alone (296 seconds). +- Initial CI identified stale creation-option assertions and app remounts on + company changes. Preserve mounted app children and bind each pending mutation + to its original user/company. Refresh membership caches for primary changes + received from other devices. +- Security review identified agent mappings in company activity. Preference + events now record only the affected user and action, with no selected agent IDs. + An authenticated cross-user activity endpoint test covers this boundary. +- Review fixes pass 96 database/permission-route tests and 127 provider, + live-update, and skill-route tests. Final merge requires fresh-head CI and review. +- The affected browser suites pass all 10 tests, including onboarding, archived + company navigation, Chat fallback without execution, and slow-CPU reloads. +- The second PR run passed 52 checks and Greptile 5/5, with one OpenAPI inventory + failure. Both personal endpoints now have board-only request/response contracts + in OpenAPI. Its 14 tests and the remaining 28 tests in that shard pass locally. diff --git a/packages/db/src/migrations/0315_rapid_emma_frost.sql b/packages/db/src/migrations/0315_rapid_emma_frost.sql new file mode 100644 index 00000000000..95330211675 --- /dev/null +++ b/packages/db/src/migrations/0315_rapid_emma_frost.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS "user_company_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "primary_agent_id" uuid, + "primary_agent_initialized" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_primary_agent_id_agents_id_fk" FOREIGN KEY ("primary_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_company_preferences_company_user_uq" ON "user_company_preferences" USING btree ("company_id","user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_company_preferences_primary_agent_idx" ON "user_company_preferences" USING btree ("primary_agent_id");--> statement-breakpoint +-- Attribute only explicit human creation events. Select the original event +-- before joining the agent: deletion/termination must not promote a later hire. +WITH first_creations AS ( + SELECT DISTINCT ON (company_id, actor_id) + company_id, actor_id AS user_id, entity_id AS agent_id + FROM activity_log + WHERE actor_type = 'user' + AND actor_id NOT IN ('', 'board', 'system', 'unknown', 'unknown-user') + AND entity_type = 'agent' + AND action IN ('agent.created', 'agent.hire_created') + ORDER BY company_id, actor_id, created_at, id +) +INSERT INTO user_company_preferences (company_id, user_id, primary_agent_id, primary_agent_initialized) +SELECT c.company_id, c.user_id, + CASE WHEN a.status != 'terminated' AND m.state IS DISTINCT FROM 'left' + THEN a.id ELSE NULL END, + true +FROM first_creations c +LEFT JOIN agents a ON a.id::text = c.agent_id AND a.company_id = c.company_id +LEFT JOIN agent_memberships m ON m.company_id = c.company_id AND m.user_id = c.user_id AND m.agent_id = a.id +WHERE a.metadata->'paperclipBuiltInAgent' IS NULL +ON CONFLICT (company_id, user_id) DO NOTHING; diff --git a/packages/db/src/migrations/meta/0310_snapshot.json b/packages/db/src/migrations/meta/0315_snapshot.json similarity index 95% rename from packages/db/src/migrations/meta/0310_snapshot.json rename to packages/db/src/migrations/meta/0315_snapshot.json index 8adbf57d9bb..69131296242 100644 --- a/packages/db/src/migrations/meta/0310_snapshot.json +++ b/packages/db/src/migrations/meta/0315_snapshot.json @@ -1,9 +1,1079 @@ { - "id": "8a60ce2b-6ab6-435d-90b9-7dc7fc67d53f", - "prevId": "593431cc-d6a8-4daf-a3e4-9e3c892d9419", + "id": "26691ae0-39a8-42ac-868d-e36ab7219073", + "prevId": "a1aefefe-8911-4583-9c52-7c658e46e9b4", "version": "7", "dialect": "postgresql", "tables": { + "public.accounting_runtime_baselines": { + "name": "accounting_runtime_baselines", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_cents": { + "name": "cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_runtime_baselines_company_idx": { + "name": "accounting_runtime_baselines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounting_runtime_baselines_agent_id_agents_id_fk": { + "name": "accounting_runtime_baselines_agent_id_agents_id_fk", + "tableFrom": "accounting_runtime_baselines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounting_runtime_baselines_company_id_companies_id_fk": { + "name": "accounting_runtime_baselines_company_id_companies_id_fk", + "tableFrom": "accounting_runtime_baselines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_invoice_lines": { + "name": "billing_invoice_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "billing_invoice_lines_identity_idx": { + "name": "billing_invoice_lines_identity_idx", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_invoice_lines_company_idx": { + "name": "billing_invoice_lines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_invoice_lines_company_id_companies_id_fk": { + "name": "billing_invoice_lines_company_id_companies_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_invoice_lines_invoice_id_billing_invoices_id_fk": { + "name": "billing_invoice_lines_invoice_id_billing_invoices_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "billing_invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_invoice_lines_cost_event_id_cost_events_id_fk": { + "name": "billing_invoice_lines_cost_event_id_cost_events_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_invoice_lines_amount_check": { + "name": "billing_invoice_lines_amount_check", + "value": "\"billing_invoice_lines\".\"amount_cents\" >= 0" + }, + "billing_invoice_lines_kind_check": { + "name": "billing_invoice_lines_kind_check", + "value": "\"billing_invoice_lines\".\"kind\" in ('inference', 'fee', 'credit')" + } + }, + "isRLSEnabled": false + }, + "public.billing_invoices": { + "name": "billing_invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imported_by": { + "name": "imported_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_invoices_identity_idx": { + "name": "billing_invoices_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_invoices_company_id_companies_id_fk": { + "name": "billing_invoices_company_id_companies_id_fk", + "tableFrom": "billing_invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_reservations": { + "name": "budget_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'held'" + }, + "provider_started_at": { + "name": "provider_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_reservations_run_idx": { + "name": "budget_reservations_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_reservations_active_idx": { + "name": "budget_reservations_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"budget_reservations\".\"state\" = 'held'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_reservations_company_id_companies_id_fk": { + "name": "budget_reservations_company_id_companies_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_reservations_run_id_heartbeat_runs_id_fk": { + "name": "budget_reservations_run_id_heartbeat_runs_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_reservations_agent_id_agents_id_fk": { + "name": "budget_reservations_agent_id_agents_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "budget_reservations_state_check": { + "name": "budget_reservations_state_check", + "value": "\"budget_reservations\".\"state\" in ('held', 'settled', 'released')" + }, + "budget_reservations_amount_check": { + "name": "budget_reservations_amount_check", + "value": "\"budget_reservations\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cost_adjustments": { + "name": "cost_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_line_id": { + "name": "invoice_line_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_cents": { + "name": "previous_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "corrected_cents": { + "name": "corrected_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "previous_status": { + "name": "previous_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_pricing": { + "name": "previous_pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_adjustments_receipt_idx": { + "name": "cost_adjustments_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_adjustments_event_idx": { + "name": "cost_adjustments_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_adjustments_invoice_line_idx": { + "name": "cost_adjustments_invoice_line_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_adjustments_company_id_companies_id_fk": { + "name": "cost_adjustments_company_id_companies_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_adjustments_cost_event_id_cost_events_id_fk": { + "name": "cost_adjustments_cost_event_id_cost_events_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk": { + "name": "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "billing_invoice_lines", + "columnsFrom": [ + "invoice_line_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cost_adjustments_amount_check": { + "name": "cost_adjustments_amount_check", + "value": "\"cost_adjustments\".\"previous_cents\" >= 0 and \"cost_adjustments\".\"corrected_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.provider_billing_snapshots": { + "name": "provider_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_billing_snapshots_identity_idx": { + "name": "provider_billing_snapshots_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_billing_snapshots_company_id_companies_id_fk": { + "name": "provider_billing_snapshots_company_id_companies_id_fk", + "tableFrom": "provider_billing_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "provider_billing_snapshots_amount_check": { + "name": "provider_billing_snapshots_amount_check", + "value": "\"provider_billing_snapshots\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.run_usage_receipts": { + "name": "run_usage_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_json": { + "name": "receipt_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_usage_receipts_source_sequence_idx": { + "name": "run_usage_receipts_source_sequence_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_usage_receipts_run_latest_idx": { + "name": "run_usage_receipts_run_latest_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_usage_receipts_company_id_companies_id_fk": { + "name": "run_usage_receipts_company_id_companies_id_fk", + "tableFrom": "run_usage_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_usage_receipts_run_id_heartbeat_runs_id_fk": { + "name": "run_usage_receipts_run_id_heartbeat_runs_id_fk", + "tableFrom": "run_usage_receipts", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "run_usage_receipts_positive_sequence": { + "name": "run_usage_receipts_positive_sequence", + "value": "\"run_usage_receipts\".\"sequence\" > 0" + } + }, + "isRLSEnabled": false + }, "public.activity_log": { "name": "activity_log", "schema": "", @@ -1820,7 +2890,7 @@ }, "total_cost_cents": { "name": "total_cost_cents", - "type": "bigint", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -2859,9 +3929,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -5262,7 +6338,7 @@ }, "amount_observed": { "name": "amount_observed", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -5378,7 +6454,7 @@ } ], "isUnique": true, - "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "where": "\"budget_incidents\".\"status\" = 'open'", "concurrently": false, "method": "btree", "with": {} @@ -5480,6 +6556,13 @@ "notNull": true, "default": 0 }, + "reservation_cents": { + "name": "reservation_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, "warn_percent": { "name": "warn_percent", "type": "integer", @@ -5494,6 +6577,13 @@ "notNull": true, "default": true }, + "unpriced_usage_policy": { + "name": "unpriced_usage_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'block'" + }, "notify_enabled": { "name": "notify_enabled", "type": "boolean", @@ -5508,6 +6598,20 @@ "notNull": true, "default": true }, + "enforcement_version": { + "name": "enforcement_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enforcement_delivered_version": { + "name": "enforcement_delivered_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -5654,7 +6758,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "budget_policies_reservation_nonnegative": { + "name": "budget_policies_reservation_nonnegative", + "value": "\"budget_policies\".\"reservation_cents\" >= 0" + } + }, "isRLSEnabled": false }, "public.built_in_managed_resources": { @@ -11306,9 +12415,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -16201,6 +17316,36 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_cost_cents": { + "name": "reported_cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": false + }, + "pricing_provenance": { + "name": "pricing_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "provider": { "name": "provider", "type": "text", @@ -16257,7 +17402,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -16276,6 +17421,109 @@ } }, "indexes": { + "cost_events_company_receipt_idx": { + "name": "cost_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_provider_request_idx": { + "name": "cost_events_provider_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_project_occurred_idx": { + "name": "cost_events_company_project_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_unpriced_idx": { + "name": "cost_events_unpriced_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included'", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -16483,7 +17731,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "cost_events_nonnegative_amounts": { + "name": "cost_events_nonnegative_amounts", + "value": "\"cost_events\".\"cost_cents\" >= 0 and \"cost_events\".\"input_tokens\" >= 0 and \"cost_events\".\"cached_input_tokens\" >= 0 and \"cost_events\".\"output_tokens\" >= 0" + } + }, "isRLSEnabled": false }, "public.decision_archive_notification_outbox": { @@ -23721,6 +24974,18 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, "billing_code": { "name": "billing_code", "type": "text", @@ -23796,7 +25061,7 @@ }, "amount_cents": { "name": "amount_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -23841,6 +25106,27 @@ } }, "indexes": { + "finance_events_company_receipt_idx": { + "name": "finance_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "finance_events_company_occurred_idx": { "name": "finance_events_company_occurred_idx", "columns": [ @@ -24987,6 +26273,19 @@ "primaryKey": false, "notNull": true }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "invocation_source": { "name": "invocation_source", "type": "text", @@ -25067,6 +26366,44 @@ "primaryKey": false, "notNull": false }, + "cost_accounting_pending": { + "name": "cost_accounting_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_accounted_at": { + "name": "cost_accounted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_projection_version": { + "name": "accounting_projection_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_last_attempt_at": { + "name": "accounting_last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_last_error": { + "name": "accounting_last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_attempt_count": { + "name": "accounting_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "usage_json": { "name": "usage_json", "type": "jsonb", @@ -25403,6 +26740,28 @@ } }, "indexes": { + "heartbeat_runs_cost_accounting_pending_idx": { + "name": "heartbeat_runs_cost_accounting_pending_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -25484,6 +26843,33 @@ "method": "btree", "with": {} }, + "heartbeat_runs_company_issue_created_idx": { + "name": "heartbeat_runs_company_issue_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_company_responsible_user_idx": { "name": "heartbeat_runs_company_responsible_user_idx", "columns": [ @@ -25722,6 +27108,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "heartbeat_runs_issue_id_issues_id_fk": { + "name": "heartbeat_runs_issue_id_issues_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", "tableFrom": "heartbeat_runs", @@ -25772,7 +27171,12 @@ } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "heartbeat_runs_scope_binding_check": { + "name": "heartbeat_runs_scope_binding_check", + "value": "(\"heartbeat_runs\".\"scope_kind\" = 'company' AND \"heartbeat_runs\".\"issue_id\" IS NULL)\n OR \"heartbeat_runs\".\"scope_kind\" = 'issue'" + } + }, "isRLSEnabled": false }, "public.inbox_dismissals": { @@ -27215,6 +28619,166 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.issue_access_grants": { + "name": "issue_access_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by_agent_id": { + "name": "granted_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_access_grants_subject_active_issue_idx": { + "name": "issue_access_grants_subject_active_issue_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_access_grants_issue_idx": { + "name": "issue_access_grants_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_access_grants_issue_id_issues_id_fk": { + "name": "issue_access_grants_issue_id_issues_id_fk", + "tableFrom": "issue_access_grants", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_access_grants_granted_by_agent_id_agents_id_fk": { + "name": "issue_access_grants_granted_by_agent_id_agents_id_fk", + "tableFrom": "issue_access_grants", + "tableTo": "agents", + "columnsFrom": [ + "granted_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "issue_access_grants_subject_type_check": { + "name": "issue_access_grants_subject_type_check", + "value": "\"issue_access_grants\".\"subject_type\" in ('user', 'agent')" + }, + "issue_access_grants_source_check": { + "name": "issue_access_grants_source_check", + "value": "\"issue_access_grants\".\"source\" in ('explicit', 'assignment', 'project')" + } + }, + "isRLSEnabled": false + }, "public.issue_approvals": { "name": "issue_approvals", "schema": "", @@ -32087,6 +33651,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_root_issue_id": { + "name": "privacy_root_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "privacy_parent_issue_id": { + "name": "privacy_parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "title": { "name": "title", "type": "text", @@ -32571,6 +34154,48 @@ "method": "btree", "with": {} }, + "issues_company_privacy_parent_idx": { + "name": "issues_company_privacy_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_parent_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_privacy_root_idx": { + "name": "issues_company_privacy_root_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_root_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "issues_company_project_idx": { "name": "issues_company_project_idx", "columns": [ @@ -33140,6 +34765,32 @@ "onDelete": "no action", "onUpdate": "no action" }, + "issues_privacy_root_issue_id_issues_id_fk": { + "name": "issues_privacy_root_issue_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "privacy_root_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_privacy_parent_issue_id_issues_id_fk": { + "name": "issues_privacy_parent_issue_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "privacy_parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "issues_assignee_agent_id_agents_id_fk": { "name": "issues_assignee_agent_id_agents_id_fk", "tableFrom": "issues", @@ -33219,6 +34870,10 @@ }, "policies": {}, "checkConstraints": { + "issues_visibility_check": { + "name": "issues_visibility_check", + "value": "\"issues\".\"visibility\" in ('open', 'private')" + }, "issues_conversation_identity_check": { "name": "issues_conversation_identity_check", "value": "(\n \"issues\".\"conversation_agent_id\" is null and \"issues\".\"conversation_user_id\" is null and \"issues\".\"conversation_state\" is null\n ) or (\n \"issues\".\"conversation_agent_id\" is not null and \"issues\".\"conversation_user_id\" is not null\n and \"issues\".\"assignee_agent_id\" = \"issues\".\"conversation_agent_id\" and \"issues\".\"assignee_agent_id\" is not null\n and \"issues\".\"assignee_user_id\" is null and \"issues\".\"conversation_state\" is not null\n and \"issues\".\"conversation_state\" in ('active', 'waiting')\n and \"issues\".\"status\" not in ('done', 'cancelled')\n )" @@ -38508,6 +40163,150 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.project_access_members": { + "name": "project_access_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_access_members_project_subject_uq": { + "name": "project_access_members_project_subject_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_access_members_subject_lookup_idx": { + "name": "project_access_members_subject_lookup_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_members_company_id_companies_id_fk": { + "name": "project_access_members_company_id_companies_id_fk", + "tableFrom": "project_access_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_access_members_project_id_projects_id_fk": { + "name": "project_access_members_project_id_projects_id_fk", + "tableFrom": "project_access_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_members_subject_type_check": { + "name": "project_access_members_subject_type_check", + "value": "\"project_access_members\".\"subject_type\" in ('user', 'agent')" + } + }, + "isRLSEnabled": false + }, "public.project_goals": { "name": "project_goals", "schema": "", @@ -39137,6 +40936,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_owner_user_id": { + "name": "privacy_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_owner_user_id": { + "name": "personal_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -39228,6 +41046,28 @@ "concurrently": false, "method": "btree", "with": {} + }, + "projects_company_personal_owner_uq": { + "name": "projects_company_personal_owner_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "personal_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"personal_owner_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -39274,7 +41114,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "projects_visibility_check": { + "name": "projects_visibility_check", + "value": "\"projects\".\"visibility\" in ('open', 'private')" + } + }, "isRLSEnabled": false }, "public.provider_trace_records": { @@ -42390,7 +44235,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -49809,6 +51654,129 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_company_preferences": { + "name": "user_company_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_agent_id": { + "name": "primary_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "primary_agent_initialized": { + "name": "primary_agent_initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_company_preferences_company_user_uq": { + "name": "user_company_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_preferences_primary_agent_idx": { + "name": "user_company_preferences_primary_agent_idx", + "columns": [ + { + "expression": "primary_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_company_preferences_company_id_companies_id_fk": { + "name": "user_company_preferences_company_id_companies_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_company_preferences_primary_agent_id_agents_id_fk": { + "name": "user_company_preferences_primary_agent_id_agents_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "agents", + "columnsFrom": [ + "primary_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.user_inbox_agent_policies": { "name": "user_inbox_agent_policies", "schema": "", @@ -51493,6 +53461,187 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_attachment_uploads": { + "name": "mcp_attachment_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_attachment_upload_request_uq": { + "name": "mcp_attachment_upload_request_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_attachment_upload_expiry_idx": { + "name": "mcp_attachment_upload_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_attachment_uploads_company_id_companies_id_fk": { + "name": "mcp_attachment_uploads_company_id_companies_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_attachment_uploads_user_id_user_id_fk": { + "name": "mcp_attachment_uploads_user_id_user_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_event_admissions": { "name": "mcp_event_admissions", "schema": "", @@ -51889,6 +54038,133 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_file_tickets": { + "name": "mcp_file_tickets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_file_ticket_expiry_idx": { + "name": "mcp_file_ticket_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_file_ticket_grant_idx": { + "name": "mcp_file_ticket_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_file_tickets_company_id_companies_id_fk": { + "name": "mcp_file_tickets_company_id_companies_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk": { + "name": "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_attachment_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_mutation_receipts": { "name": "mcp_mutation_receipts", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4319e115911..945a790a400 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2192,6 +2192,13 @@ "when": 1791371819805, "tag": "0314_private_task_draft_assets", "breakpoints": true + }, + { + "idx": 315, + "version": "7", + "when": 1791390905429, + "tag": "0315_rapid_emma_frost", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 466ea7321f1..1750dec1efd 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -233,3 +233,5 @@ export { agentCommentary } from "./agent_commentary.js"; export { agentIdentityKeys } from "./agent_identity_keys.js"; + +export { userCompanyPreferences } from "./user_company_preferences.js"; diff --git a/packages/db/src/schema/user_company_preferences.ts b/packages/db/src/schema/user_company_preferences.ts new file mode 100644 index 00000000000..40c6a679fdc --- /dev/null +++ b/packages/db/src/schema/user_company_preferences.ts @@ -0,0 +1,16 @@ +import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; + +export const userCompanyPreferences = pgTable("user_company_preferences", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + primaryAgentId: uuid("primary_agent_id").references(() => agents.id, { onDelete: "set null" }), + primaryAgentInitialized: boolean("primary_agent_initialized").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, table => ({ + companyUserUq: uniqueIndex("user_company_preferences_company_user_uq").on(table.companyId, table.userId), + primaryAgentIdx: index("user_company_preferences_primary_agent_idx").on(table.primaryAgentId), +})); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 232f447caa0..b1e36a5aae3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2850,3 +2850,5 @@ export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, agg export * from "./connection-instructions.js"; export * from "./customer-success.js"; + +export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPreference } from "./primary-agent.js"; diff --git a/packages/shared/src/primary-agent.ts b/packages/shared/src/primary-agent.ts new file mode 100644 index 00000000000..63d7841ed62 --- /dev/null +++ b/packages/shared/src/primary-agent.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export interface PrimaryAgentPreference { + companyId: string; + userId: string; + primaryAgentId: string | null; + initialized: boolean; +} + +export const updatePrimaryAgentSchema = z.object({ + primaryAgentId: z.string().uuid(), +}).strict(); + +export type UpdatePrimaryAgent = z.infer; diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 8428ab4e6f5..72570a9e9dc 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1251,7 +1251,7 @@ describe("agent permission routes", () => { expect.objectContaining({ status: "idle", }), - { claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, ); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( companyId, @@ -1381,7 +1381,7 @@ describe("agent permission routes", () => { }, }, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1417,7 +1417,7 @@ describe("agent permission routes", () => { model: DEFAULT_OPENCODE_LOCAL_MODEL, }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1455,7 +1455,7 @@ describe("agent permission routes", () => { model: "anthropic/claude-sonnet-4-5", }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1495,6 +1495,7 @@ describe("agent permission routes", () => { }, }), { + createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", @@ -1725,7 +1726,7 @@ describe("agent permission routes", () => { expect.objectContaining({ defaultEnvironmentId: environmentId, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1811,7 +1812,7 @@ describe("agent permission routes", () => { adapterType: adapterCase.adapterType, defaultEnvironmentId: environmentId, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); } diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 23722b3abf5..ae267e9fd43 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1005,7 +1005,7 @@ describe("agent skill routes", () => { }), }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1055,7 +1055,7 @@ describe("agent skill routes", () => { expect.objectContaining({ role: "security", }), - { claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1491,6 +1491,7 @@ describe("agent skill routes", () => { }), }), { + createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index d7c6193bbbd..47aedf91695 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -30,6 +30,7 @@ type ApprovalRecord = { status: string; payload: Record; requestedByAgentId: string | null; + requestedByUserId?: string | null; }; function createApproval(status: string): ApprovalRecord { @@ -128,9 +129,14 @@ describe("approvalService resolution idempotency", () => { expect(mockNotifyHireApproved).not.toHaveBeenCalled(); }); - it("creates the agent from payload when approval does not reference a pending agent", async () => { + it.each([ + { requestedByAgentId: "requester-1", requestedByUserId: "on-behalf-user", expectedCreator: null }, + { requestedByAgentId: null, requestedByUserId: "original-creator", expectedCreator: "original-creator" }, + ])("creates a legacy approved hire with its original human attribution ($expectedCreator)", async ({ requestedByAgentId, requestedByUserId, expectedCreator }) => { const approved = { ...createApproval("approved"), + requestedByAgentId, + requestedByUserId, payload: { name: "New Agent", adapterConfig: { @@ -155,6 +161,7 @@ describe("approvalService resolution idempotency", () => { expect.objectContaining({ adapterConfig: approved.payload.adapterConfig, }), + { createdByUserId: expectedCreator }, ); }); }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 70141793f4f..bc6bd49760c 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1897,7 +1897,7 @@ describe("company portability", () => { }, }, }), - })); + }), { createdByUserId: "user-1" }); expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith( "company-1", { targetType: "agent", targetId: "agent-imported" }, @@ -2889,7 +2889,7 @@ describe("company portability", () => { // a scoped resume; "system" stays for platform-managed pauses. pauseReason: "import", pausedAt: expect.any(Date), - })); + }), { createdByUserId: "user-1" }); expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ title: "Monday Review", status: "paused", @@ -3204,7 +3204,7 @@ describe("company portability", () => { expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ name: "ClaudeCoder", adapterType: "process", - })); + }), { createdByUserId: "user-1" }); }); it("preserves agent role from frontmatter when extension block omits it", async () => { @@ -3383,7 +3383,7 @@ describe("company portability", () => { desiredSkills: [paperclipKey], }, }), - })); + }), { createdByUserId: "user-1" }); }); it("imports a packaged company logo and attaches it to the target company", async () => { @@ -3677,7 +3677,7 @@ describe("company portability", () => { maxConcurrentRuns: 20, }, }, - })); + }), { createdByUserId: "user-1" }); expect(result.company.action).toBe("unchanged"); expect(result.agents).toEqual([ { @@ -3748,13 +3748,13 @@ describe("company portability", () => { adapterConfig: expect.objectContaining({ dangerouslyBypassApprovalsAndSandbox: true, }), - })); + }), { createdByUserId: "user-1" }); expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ adapterConfig: expect.not.objectContaining({ instructionsFilePath: expect.anything(), promptTemplate: expect.anything(), }), - })); + }), { createdByUserId: "user-1" }); expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith( expect.objectContaining({ name: "ClaudeCoder" }), expect.objectContaining({ @@ -3856,7 +3856,7 @@ describe("company portability", () => { extraArgs: ["--skip-git-repo-check"], args: ["--legacy-arg"], }), - })); + }), { createdByUserId: "user-1" }); const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record; expect(lastCreateInput?.adapterConfig).toBeTruthy(); expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined(); @@ -5785,7 +5785,7 @@ describe("company portability", () => { normalized: true, }), status: "idle", - })); + }), { createdByUserId: "user-1" }); expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ requireBoardApprovalForNewAgents: false, })); @@ -6080,13 +6080,13 @@ describe("company portability", () => { expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "opencode" }), - })); + }), { createdByUserId: "user-1" }); await portability.importBundle(request, "user-1"); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "codex" }), - })); + }), { createdByUserId: "user-1" }); await portability.importBundle({ ...request, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index e20f4895274..0910d2448f0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -63,6 +63,7 @@ const apiPrefixes: Record = { "plugin-ui-static.ts": "/api", "plugins.ts": "/api", "projects.ts": "/api", + "primary-agent.ts": "/api", "public-mcp.ts": "/api", "project-tools.ts": "/api", "resource-memberships.ts": "/api", @@ -809,6 +810,24 @@ describe("openapi routes", () => { }); }); + it("documents the authenticated personal primary-agent contract", () => { + const { spec } = loadSpecRoutes(); + const path = spec.paths["/api/companies/{companyId}/primary-agent/me"]; + for (const operation of [path.get, path.put]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.responses["200"].content["application/json"].schema.required).toEqual([ + "companyId", "userId", "primaryAgentId", "initialized", + ]); + } + expect(path.put.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, + required: ["primaryAgentId"], + properties: { primaryAgentId: { type: "string", format: "uuid" } }, + }); + expect(path.put.responses["422"]).toBeDefined(); + }); + it("documents board-only repository discovery and selection", () => { const { spec } = loadSpecRoutes(); const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get; diff --git a/server/src/__tests__/primary-agent.test.ts b/server/src/__tests__/primary-agent.test.ts new file mode 100644 index 00000000000..1f4f6f6a514 --- /dev/null +++ b/server/src/__tests__/primary-agent.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import express from "express"; +import request from "supertest"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, companies, companyMemberships, createDb, userCompanyPreferences } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { primaryAgentRoutes } from "../routes/primary-agent.js"; +import { agentService } from "../services/agents.js"; +import { primaryAgentService } from "../services/primary-agent.js"; +import { resourceMembershipService } from "../services/resource-memberships.js"; +import { activityRoutes } from "../routes/activity.js"; + +const support = await getEmbeddedPostgresTestSupport(); +if (!support.supported) console.warn(`Primary agent database tests unavailable: ${support.reason}`); + +(support.supported ? describe : describe.skip)("personal primary agent", () => { + let db: ReturnType; + let temp: Awaited>; + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-primary-agent-"); + db = createDb(temp.connectionString); + }, 30_000); + afterAll(async () => { await temp?.cleanup(); }); + + const actor = (companyId: string, userId = "user-a"): Express.Request["actor"] => ({ + type: "board", source: "session", userId, companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }); + const app = (identity: Express.Request["actor"]) => { + const result = express(); result.use(express.json()); + result.use((req, _res, next) => { req.actor = identity; next(); }); + result.use("/api", primaryAgentRoutes(db)); + result.use("/api", activityRoutes(db)); result.use(errorHandler); + return result; + }; + async function company() { + const id = randomUUID(); + await db.insert(companies).values({ id, name: "Primary test", issuePrefix: `P${id.slice(0, 7)}` }); + await db.insert(companyMemberships).values(["user-a", "user-b", "explicit"].map(principalId => ({ companyId: id, principalType: "user", principalId, status: "active", membershipRole: "admin" }))); + return id; + } + const create = (companyId: string, name: string, userId?: string) => agentService(db).create(companyId, { + name, adapterType: "process", status: "idle", + }, { createdByUserId: userId }); + const get = (companyId: string, userId = "user-a") => primaryAgentService(db).get(companyId, userId); + const url = (companyId: string) => `/api/companies/${companyId}/primary-agent/me`; + + it("automatically crowns the first human creation, ignoring provisioned and agent-authored creations", async () => { + const c = await company(); + await create(c, "System"); await create(c, "Agent hire"); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: false }); + const first = await create(c, "Maia", "user-a"); + await create(c, "Alex", "user-a"); + expect(await get(c)).toMatchObject({ primaryAgentId: first.id, initialized: true }); + }); + + it("arbitrates simultaneous human creations and cannot overwrite an explicit choice", async () => { + const c = await company(); + const created = await Promise.all([create(c, "Maia", "user-a"), create(c, "Alex", "user-a")]); + const preference = await get(c); + expect(created.map(a => a.id)).toContain(preference.primaryAgentId); + expect(await db.select().from(userCompanyPreferences).where(and(eq(userCompanyPreferences.companyId, c), eq(userCompanyPreferences.userId, "user-a")))).toHaveLength(1); + const selected = created.find(a => a.id !== preference.primaryAgentId)!; + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: selected.id }).expect(200); + await create(c, "Later", "user-a"); + expect((await get(c)).primaryAgentId).toBe(selected.id); + }); + + it("isolates company/user preferences, including trusted local board, and rejects untrusted identity fields", async () => { + const c = await company(), other = await company(); + const a = await create(c, "Maia", "user-a"), b = await create(c, "Alex", "user-b"); + const elsewhere = await create(other, "Elsewhere", "user-a"); + expect((await request(app(actor(c))).get(url(c)).expect(200)).body.primaryAgentId).toBe(a.id); + expect((await request(app(actor(c, "user-b"))).get(url(c)).expect(200)).body.primaryAgentId).toBe(b.id); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: elsewhere.id }).expect(404); + await request(app(actor(c))).get(url(other)).expect(403); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id, userId: "user-b" }).expect(400); + await request(app({ type: "agent", source: "agent_key", agentId: a.id, companyId: c })).put(url(c)).send({ primaryAgentId: b.id }).expect(403); + const local: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true }; + await request(app(local)).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + expect((await get(c, "local-board")).primaryAgentId).toBe(b.id); + expect((await get(other, "local-board")).primaryAgentId).toBeNull(); + expect((await get(c)).primaryAgentId).toBe(a.id); + }); + + it("serializes competing explicit choices and leave races", async () => { + const c = await company(); + await create(c, "Maia", "user-a"); + const a = await create(c, "Alex"), b = await create(c, "River"); + const service = primaryAgentService(db); + await Promise.all([service.set(c, "user-a", a.id, actor(c)), service.set(c, "user-a", b.id, actor(c))]); + const current = (await get(c)).primaryAgentId; + const updates = await db.select().from(activityLog).where(and(eq(activityLog.companyId, c), eq(activityLog.action, "primary_agent.updated"))); + expect(updates).toHaveLength(2); + expect([a.id, b.id]).toContain(current); + await Promise.all([ + service.set(c, "user-a", a.id, actor(c)), + resourceMembershipService(db).updateAgent({ companyId: c, userId: "user-a", agentId: a.id, state: "left", actor: actor(c) }), + ]); + const memberships = await resourceMembershipService(db).listForUser(c, "user-a", actor(c)); + expect((await get(c)).primaryAgentId).toBe(memberships.agentMemberships[a.id] === "left" ? null : a.id); + }); + + it("audits preference changes without exposing the selection through company activity", async () => { + const c = await company(); + const first = await create(c, "Maia", "user-a"), next = await create(c, "Alex"); + await primaryAgentService(db).set(c, "user-a", next.id, actor(c)); + await resourceMembershipService(db).updateAgent({ companyId: c, userId: "user-a", agentId: next.id, state: "left", actor: actor(c) }); + const response = await request(app(actor(c, "user-b"))).get(`/api/companies/${c}/activity`).expect(200); + const events = response.body.filter((event: { action: string }) => event.action.startsWith("primary_agent.")); + expect(events.map((event: { action: string }) => event.action).sort()).toEqual([ + "primary_agent.cleared", "primary_agent.initialized", "primary_agent.updated", + ]); + for (const event of events) { + expect(event).toMatchObject({ entityType: "user_preference", entityId: "user-a", agentId: null, details: null }); + expect(JSON.stringify(event)).not.toContain(first.id); + expect(JSON.stringify(event)).not.toContain(next.id); + } + }); + + it("retains paused/error primaries, clears on leave, rejoins when chosen, and preserves stars", async () => { + const c = await company(); const a = await create(c, "Maia", "user-a"); + await agentService(db).pause(a.id); + expect((await get(c)).primaryAgentId).toBe(a.id); + await agentService(db).update(a.id, { status: "error" }); + expect((await get(c)).primaryAgentId).toBe(a.id); + const memberships = resourceMembershipService(db); + await memberships.updateAgent({ companyId: c, userId: "user-a", agentId: a.id, state: "left", actor: actor(c) }); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + await create(c, "Later", "user-a"); + expect((await get(c)).primaryAgentId).toBeNull(); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: a.id }).expect(200); + expect((await memberships.listForUser(c, "user-a", actor(c))).agentMemberships[a.id]).toBe("joined"); + await memberships.updateAgent({ companyId: c, userId: "user-a", agentId: a.id, starred: true, actor: actor(c) }); + const b = await create(c, "Alex"); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + expect((await memberships.listForUser(c, "user-a", actor(c))).starredAgentIds).toContain(a.id); + }); + + it("allows viewers to choose their own primary without granting company write permissions", async () => { + const c = await company(); const a = await create(c, "Maia"); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, c), eq(companyMemberships.principalId, "user-a"))); + const viewer = { ...actor(c), memberships: [{ companyId: c, membershipRole: "viewer" as const, status: "active" as const }] }; + await request(app(viewer)).put(url(c)).send({ primaryAgentId: a.id }).expect(200); + expect((await get(c)).primaryAgentId).toBe(a.id); + const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, c), eq(companyMemberships.principalId, "user-a"))); + expect(membership.membershipRole).toBe("viewer"); + await request(app({ ...viewer, memberships: [] })).get(url(c)).expect(403); + }); + + it("clears terminated/deleted primaries and rejects terminated targets and explicit removal", async () => { + const c = await company(); const a = await create(c, "Maia", "user-a"); + await agentService(db).terminate(a.id); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: a.id }).expect(422); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: null }).expect(400); + const b = await create(c, "Alex", "user-a"); + expect((await get(c)).primaryAgentId).toBeNull(); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + await agentService(db).remove(b.id); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + }); + + it("rolls creation and initialization back together", async () => { + const c = await company(); + await expect(db.transaction(async tx => { + await agentService(tx as unknown as typeof db).create(c, { name: "Rolled back", adapterType: "process" }, { createdByUserId: "user-a" }); + throw new Error("rollback"); + })).rejects.toThrow("rollback"); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: false }); + expect(await db.select().from(agents).where(eq(agents.companyId, c))).toHaveLength(0); + }); + + it("backfills the earliest human event, leaves gone/terminated originals empty, and preserves explicit preferences", async () => { + const c = await company(); const first = await create(c, "First"), later = await create(c, "Later"); + const terminated = await create(c, "Terminated"); + await agentService(db).terminate(terminated.id); + const events = [ + ["human", first.id, "user", 1], ["human", later.id, "user", 2], + ["gone", randomUUID(), "user", 1], ["gone", later.id, "user", 2], + ["retired", terminated.id, "user", 1], ["retired", later.id, "user", 2], + ["system", later.id, "system", 0], [first.id, later.id, "agent", 0], + ["board", later.id, "user", 0], ["explicit", first.id, "user", 0], + ] as const; + for (const [actorId, entityId, actorType, day] of events) await db.insert(activityLog).values({ + companyId: c, actorId, actorType, entityId, entityType: "agent", action: "agent.created", createdAt: new Date(2020, 0, day + 1), + }); + await request(app(actor(c, "explicit"))).put(url(c)).send({ primaryAgentId: later.id }).expect(200); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0315_rapid_emma_frost.sql", import.meta.url), "utf8"); + // Replay the whole migration twice: an already migrated preview instance + // must retain its explicit preferences when upgrading to the merged build. + for (let replay = 0; replay < 2; replay++) { + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await db.execute(sql.raw(statement)); + } + } + expect((await get(c, "human")).primaryAgentId).toBe(first.id); + expect(await get(c, "gone")).toMatchObject({ primaryAgentId: null, initialized: true }); + expect(await get(c, "retired")).toMatchObject({ primaryAgentId: null, initialized: true }); + expect((await get(c, "explicit")).primaryAgentId).toBe(later.id); + for (const user of ["system", "board", first.id]) expect((await get(c, user)).initialized).toBe(false); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index b9054137ce9..ec71459ef2b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -107,6 +107,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { announcementRoutes } from "./routes/announcements.js"; import { serverVersion } from "./version.js"; +import { primaryAgentRoutes } from "./routes/primary-agent.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; @@ -824,6 +825,7 @@ export async function createApp( api.use(sidebarPreferenceRoutes(db)); api.use(announcementRoutes(db, { ...opts.announcements, version: opts.hostVersion ?? serverVersion })); api.use(resourceMembershipRoutes(db)); + api.use(primaryAgentRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); if (opts.databaseBackupService) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 96bba2754a8..8ec590b8c74 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -4808,6 +4808,7 @@ export function agentRoutes( lastHeartbeatAt: null, }, { + createdByUserId: req.actor.type === "board" ? req.actor.userId : null, aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: hireStoredSessionId ?? null, @@ -5062,6 +5063,7 @@ export function agentRoutes( lastHeartbeatAt: null, }, { + createdByUserId: req.actor.type === "board" ? req.actor.userId : null, aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: createStoredSessionId ?? null, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5d7c8744b7c..ed3e304ee7b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -31,6 +31,7 @@ import { createAgentSchema, createAgentHireSchema, updateAgentSchema, + updatePrimaryAgentSchema, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, @@ -1406,6 +1407,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/board-api-keys/{keyId}", "POST /api/bootstrap/claim", "GET /api/companies/{companyId}/resource-memberships/me", + "GET /api/companies/{companyId}/primary-agent/me", + "PUT /api/companies/{companyId}/primary-agent/me", "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}", "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", @@ -6362,6 +6365,35 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +const primaryAgentPreferenceResponse = z.object({ + companyId: z.string().uuid(), + userId: z.string(), + primaryAgentId: z.string().uuid().nullable(), + initialized: z.boolean(), +}); + +for (const method of ["get", "put"] as const) { + registry.registerPath({ + method, + path: "/api/companies/{companyId}/primary-agent/me", + tags: ["agents"], + summary: method === "get" ? "Get my primary agent" : "Set my primary agent", + description: "Uses the authenticated board user's personal company preference. Active company viewers may manage their own preference. Agent credentials cannot access it. Setting a primary requires a visible, approved, non-terminated agent and rejoins that agent under existing membership rules. A cleared preference retains its initialization state.", + request: { + params: z.object({ companyId: z.string().uuid() }), + ...(method === "put" ? { body: jsonBody(updatePrimaryAgentSchema) } : {}), + }, + responses: { + 200: r.ok(primaryAgentPreferenceResponse), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + ...(method === "put" ? { 422: r.unprocessable } : {}), + }, + }); +} + // ─── Announcements ─────────────────────────────────────────────────────────── const announcementResponseHeaders = { diff --git a/server/src/routes/primary-agent.ts b/server/src/routes/primary-agent.ts new file mode 100644 index 00000000000..77d29f9abf7 --- /dev/null +++ b/server/src/routes/primary-agent.ts @@ -0,0 +1,51 @@ +import { Router } from "express"; +import { agents, companies, type Db } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { updatePrimaryAgentSchema } from "@paperclipai/shared"; +import { forbidden, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService } from "../services/access.js"; +import { primaryAgentService } from "../services/primary-agent.js"; +import { assertBoard, hasCompanyAccess } from "./authz.js"; + +export function primaryAgentRoutes(db: Db) { + const router = Router(); + const svc = primaryAgentService(db); + const access = accessService(db); + router.use("/companies/:companyId/primary-agent/me", async (req, _res, next) => { + assertBoard(req); + const companyId = req.params.companyId as string; + if (!hasCompanyAccess(req, companyId)) throw forbidden("User does not have access to this company"); + if (!req.actor.userId) throw forbidden("Board user access required"); + // Personal preferences follow the same self-service rules as stars and + // resource memberships: viewers may choose their own primary too. + if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin + && !req.actor.memberships?.some(membership => membership.companyId === companyId && membership.status === "active")) { + throw forbidden("User does not have active company access"); + } + if (!await db.query.companies.findFirst({ where: eq(companies.id, req.params.companyId as string) })) { + throw notFound("Company not found"); + } + next(); + }); + router.get("/companies/:companyId/primary-agent/me", async (req, res) => { + const preference = await svc.get(req.params.companyId as string, req.actor.userId!); + if (preference.primaryAgentId) { + const decision = await access.decide({ actor: req.actor, action: "agent:read", + resource: { type: "agent", companyId: preference.companyId, agentId: preference.primaryAgentId } }); + if (!decision.allowed) preference.primaryAgentId = null; + } + res.json(preference); + }); + router.put("/companies/:companyId/primary-agent/me", validate(updatePrimaryAgentSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const agentId = req.body.primaryAgentId as string; + const agent = await db.query.agents.findFirst({ where: and(eq(agents.companyId, companyId), eq(agents.id, agentId)) }); + if (!agent) throw notFound("Agent not found"); + const decision = await access.decide({ actor: req.actor, action: "agent:read", + resource: { type: "agent", companyId, agentId } }); + if (!decision.allowed) throw forbidden("Agent is outside this actor's authorization boundary"); + res.json(await svc.set(companyId, req.actor.userId!, agentId, req.actor)); + }); + return router; +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 38ad38a81a6..208b3c78c6d 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -61,6 +61,7 @@ import { } from "./built-in-agent-metadata.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; +import { clearPrimaryAgent, initializePrimaryAgent } from "./primary-agent.js"; import { agentIdentityService } from "./agent-identity.js"; function hashToken(token: string) { @@ -133,6 +134,7 @@ interface UpdateAgentOptions { } interface CreateAgentOptions { + createdByUserId?: string | null; aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null }; allowBuiltInAgentMetadata?: boolean; claudeLogin?: ClaudeLoginContext; @@ -812,6 +814,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; + if (updated.status === "terminated") { + await clearPrimaryAgent(txDb, updated.companyId, id); + } if (data.status !== undefined) { await recordAgentStatusEvent(txDb, updated.companyId, id, current.status, updated.status); } @@ -986,6 +991,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { }))).onConflictDoNothing(); } await syncAgentSecretBindings(created, txDb); + if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) { + await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id); + } if (created.status !== "pending_approval" && created.status !== "terminated") { await recordResourceCreationEvent(txDb, companyId, "agent", created.id); } diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index d6ec34cf880..f90542846c9 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -189,7 +189,7 @@ export function approvalService(db: Db) { spentMonthlyCents: 0, permissions: undefined, lastHeartbeatAt: null, - }); + }, { createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId }); hireApprovedAgentId = created?.id ?? null; } if (hireApprovedAgentId) { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 701894a7610..dab5cd12e43 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5671,7 +5671,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ...patch, ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", - }); + }, { createdByUserId: actorUserId }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, diff --git a/server/src/services/onboarding-seed.ts b/server/src/services/onboarding-seed.ts index 878b2f05a4a..92399bef344 100644 --- a/server/src/services/onboarding-seed.ts +++ b/server/src/services/onboarding-seed.ts @@ -189,6 +189,7 @@ export function onboardingSeedService(db: Db) { dbx: Db, companyId: string, seed: ApplyOnboardingSeed, + audit?: OnboardingSeedAuditActor, ): Promise { const agentSvc = agentService(dbx); const goalSvc = goalService(dbx); @@ -255,7 +256,7 @@ export function onboardingSeedService(db: Db) { status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, - }); + }, { createdByUserId: audit?.actorType === "user" ? audit.actorId : null }); agentId = created.id; } } @@ -404,7 +405,7 @@ export function onboardingSeedService(db: Db) { sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:onboarding-seed:${companyId}`}, 0))`, ); const dbx = tx as unknown as Db; - const applied = await applyWithin(dbx, companyId, seed); + const applied = await applyWithin(dbx, companyId, seed, audit); if (applied.changed && audit) { await logActivity( diff --git a/server/src/services/primary-agent.ts b/server/src/services/primary-agent.ts new file mode 100644 index 00000000000..f72e97e616e --- /dev/null +++ b/server/src/services/primary-agent.ts @@ -0,0 +1,82 @@ +import { and, eq } from "drizzle-orm"; +import { agents, userCompanyPreferences, type Db } from "@paperclipai/db"; +import type { PrimaryAgentPreference } from "@paperclipai/shared"; +import { notFound, unprocessable } from "../errors.js"; +import { persistActivity, publishActivity } from "./activity-log.js"; +import { resourceMembershipService } from "./resource-memberships.js"; + +const preferenceWhere = (companyId: string, userId: string) => and( + eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.userId, userId), +); + +/** Call inside the transaction that creates the human's agent. The unique key + * arbitrates concurrent first creations; an explicit or cleared choice wins. */ +export async function initializePrimaryAgent(db: Db, companyId: string, userId: string, agentId: string) { + const [row] = await db.insert(userCompanyPreferences).values({ + companyId, userId, primaryAgentId: agentId, primaryAgentInitialized: true, + }).onConflictDoUpdate({ + target: [userCompanyPreferences.companyId, userCompanyPreferences.userId], + set: { primaryAgentId: agentId, primaryAgentInitialized: true, updatedAt: new Date() }, + setWhere: eq(userCompanyPreferences.primaryAgentInitialized, false), + }).returning(); + if (row) await persistActivity(db, { + companyId, actorType: "user", actorId: userId, action: "primary_agent.initialized", + entityType: "user_preference", entityId: userId, + }); +} + +/** Keep initialization sticky when the primary leaves or is terminated. */ +export async function clearPrimaryAgent(db: Db, companyId: string, agentId: string, userId?: string) { + const rows = await db.update(userCompanyPreferences).set({ + primaryAgentId: null, updatedAt: new Date(), + }).where(and( + eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.primaryAgentId, agentId), + userId ? eq(userCompanyPreferences.userId, userId) : undefined, + )).returning(); + for (const row of rows) await persistActivity(db, { + companyId, actorType: "system", actorId: "primary-agent-lifecycle", action: "primary_agent.cleared", + entityType: "user_preference", entityId: row.userId, + }); +} + +export function primaryAgentService(db: Db) { + async function get(companyId: string, userId: string): Promise { + const row = await db.query.userCompanyPreferences.findFirst({ where: preferenceWhere(companyId, userId) }); + return { companyId, userId, primaryAgentId: row?.primaryAgentId ?? null, initialized: row?.primaryAgentInitialized ?? false }; + } + return { + get, + async set(companyId: string, userId: string, agentId: string, actor: Express.Request["actor"]) { + const result = await db.transaction(async tx => { + const txDb = tx as unknown as Db; + // Same lock order as leave/termination: agent, then preference. A racing + // lifecycle change cannot leave a newly crowned unavailable agent. + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!agent) throw notFound("Agent not found"); + if (agent.status === "terminated" || agent.status === "pending_approval") { + throw unprocessable("Choose an active, approved agent as your primary."); + } + await resourceMembershipService(txDb).updateAgent({ companyId, userId, agentId, state: "joined", actor }); + // Serialize personal changes even when they target different agents. + await tx.insert(userCompanyPreferences).values({ companyId, userId }).onConflictDoNothing(); + await tx.select().from(userCompanyPreferences).where(preferenceWhere(companyId, userId)).for("update"); + await tx.insert(userCompanyPreferences).values({ + companyId, userId, primaryAgentId: agentId, primaryAgentInitialized: true, + }).onConflictDoUpdate({ + target: [userCompanyPreferences.companyId, userCompanyPreferences.userId], + set: { primaryAgentId: agentId, primaryAgentInitialized: true, updatedAt: new Date() }, + }); + const { publication } = await persistActivity(txDb, { + companyId, actorType: "user", actorId: userId, action: "primary_agent.updated", + // Activity and its live events are company-visible. Audit the change + // without disclosing the user's private agent selection. + entityType: "user_preference", entityId: userId, + }); + return { preference: await primaryAgentService(txDb).get(companyId, userId), publication }; + }); + publishActivity(result.publication); + return result.preference; + }, + }; +} diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts index 54d02efe2e6..1ff9c5925f4 100644 --- a/server/src/services/resource-memberships.ts +++ b/server/src/services/resource-memberships.ts @@ -15,6 +15,7 @@ import type { ResourceMembershipUpdateResult, } from "@paperclipai/shared"; import { forbidden, notFound } from "../errors.js"; +import { clearPrimaryAgent } from "./primary-agent.js"; import { logger } from "../middleware/logger.js"; type BoardActor = { @@ -47,6 +48,7 @@ export type ResourceMembershipPolicyHook = (input: { }) => Promise | PolicyDecision; type ResourceMembershipServiceOptions = { + inTransaction?: boolean; policyHook?: ResourceMembershipPolicyHook | null; }; @@ -351,6 +353,11 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer starred?: boolean; actor: BoardActor; }): Promise { + if (!options.inTransaction) { + return db.transaction(async tx => resourceMembershipService(tx as unknown as Db, { ...options, inTransaction: true }).updateAgent(input)); + } + await db.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId))).for("update"); const agent = await db.query.agents.findFirst({ where: and( eq(agents.id, input.agentId), @@ -422,6 +429,8 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer }) .returning(); + if (nextState === "left") await clearPrimaryAgent(db, input.companyId, input.agentId, input.userId); + return { resourceType: "agent", resourceId: input.agentId, diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index 44a6084b1bc..6cd5bf1f2d8 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -262,7 +262,14 @@ test("secondary chat navigation preserves layout, unique conversations, history, })); await page.goto(`/${f.company.issuePrefix}/dashboard`); await page.getByRole("link", { name: "Chat", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible(); + // With no recent conversation, Chat opens the first human-created primary. + // Navigation alone must not create a conversation or start execution. + expect(await json(await request.get(`/api/companies/${f.company.id}/primary-agent/me`))).toMatchObject({ + primaryAgentId: f.agents[0].id, initialized: true, + }); + await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + expect(await json(await request.get(f.chatPath))).toBeNull(); + expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0); const sidebar = page.getByRole("complementary", { name: "Chat", exact: true }); // The rail lists every eligible agent before any conversation exists. const nav = sidebar.getByRole("navigation", { name: "Agent conversations" }); diff --git a/ui/src/api/primaryAgent.ts b/ui/src/api/primaryAgent.ts new file mode 100644 index 00000000000..cddb69e0eab --- /dev/null +++ b/ui/src/api/primaryAgent.ts @@ -0,0 +1,7 @@ +import type { PrimaryAgentPreference, UpdatePrimaryAgent } from "@paperclipai/shared"; +import { api } from "./client"; + +export const primaryAgentApi = { + get: (companyId: string) => api.get(`/companies/${companyId}/primary-agent/me`), + set: (companyId: string, body: UpdatePrimaryAgent) => api.put(`/companies/${companyId}/primary-agent/me`, body), +}; diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 43009093d45..7e1a18f211a 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -6,6 +6,7 @@ interface EntityRowProps { leading?: ReactNode; identifier?: string; title: string; + titleAccessory?: ReactNode; subtitle?: string; /** * Optional metadata columns rendered immediately after the title. When set, @@ -44,6 +45,7 @@ export function EntityRow({ leading, identifier, title, + titleAccessory, subtitle, meta, metaSpacerClassName, @@ -91,6 +93,7 @@ export function EntityRow({ {title} + {titleAccessory}
    {(subtitle || reserveSubtitleSpace) && (

    ({ primaryAgentId: null as string | null, loading: false })); +vi.mock("./primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => primaryState })); + const dialogState = vi.hoisted(() => ({ newIssueOpen: true, newIssueDefaults: {} as Record, @@ -351,6 +354,8 @@ describe("NewIssueDialog", () => { let originalInnerHeightDescriptor: PropertyDescriptor | undefined; beforeEach(() => { + primaryState.primaryAgentId = null; + primaryState.loading = false; vi.useRealTimers(); originalResizeObserver = globalThis.ResizeObserver; originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport"); @@ -507,6 +512,23 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it.each([ + { recent: null, explicit: null, draft: null, primary: "worker", expected: "Worker" }, + { recent: "ceo", explicit: null, draft: null, primary: "worker", expected: "CEO" }, + { recent: "worker", explicit: "ceo", draft: null, primary: "worker", expected: "CEO" }, + { recent: "worker", explicit: null, draft: "ceo", primary: "worker", expected: "CEO" }, + { recent: null, explicit: null, draft: null, primary: "missing", expected: "CEO" }, + ])("preserves task selection precedence ($recent, $explicit, $draft, $primary)", async ({ recent, explicit, draft, primary, expected }) => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + primaryState.primaryAgentId = primary; + if (recent) trackRecentAssignee(recent, "company-1"); + if (explicit) dialogState.newIssueDefaults = { assigneeAgentId: explicit }; + if (draft) localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Saved request", description: "", assigneeValue: `agent:${draft}`, projectId: "" })); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe(expected)); + act(() => root.unmount()); + }); + it("ignores another company's remembered human and keeps explicit launch assignees", async () => { mockAgentsApi.list.mockResolvedValue(defaultAgents); trackRecentAssigneeUser("user-1", "company-2"); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index c4eb7c7c9d9..5a893e5780c 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -318,6 +319,7 @@ export function NewIssueDialog() { const defaultProjectPendingRef = useRef(false); const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string } | null>(null); + const primaryAgent = usePrimaryAgentPresentation(effectiveCompanyId); const isSubIssueMode = Boolean(newIssueDefaults.parentId); const parentIssueLabel = newIssueDefaults.parentIdentifier ?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : ""); @@ -979,7 +981,7 @@ export function NewIssueDialog() { // the queries were in flight or replacing an explicit/restored assignee. useEffect(() => { if (!newIssueOpen || !effectiveCompanyId || !defaultAssigneePendingRef.current - || !agents || !sessionFetched || !membersFetched) return; + || !agents || !sessionFetched || !membersFetched || primaryAgent?.loading) return; defaultAssigneePendingRef.current = false; const available = new Set(assigneeOptions.map((option) => option.id)); const scopedRecents = getRecentAssigneeSelectionIds(effectiveCompanyId); @@ -989,9 +991,10 @@ export function NewIssueDialog() { : getRecentAssigneeSelectionIds().filter((value) => value.startsWith("agent:")); const recent = recents.find((value) => available.has(value)); const targets = agents.filter(isAgentTaskTarget); - const fallback = targets.find((agent) => agent.role === "ceo") ?? targets[0]; + const fallback = targets.find((agent) => agent.id === primaryAgent?.primaryAgentId) + ?? targets.find((agent) => agent.role === "ceo") ?? targets[0]; setAssigneeValue(recent ?? (fallback ? assigneeValueFromSelection({ assigneeAgentId: fallback.id }) : "")); - }, [newIssueOpen, effectiveCompanyId, agents, assigneeOptions, sessionFetched, membersFetched]); + }, [newIssueOpen, effectiveCompanyId, agents, assigneeOptions, sessionFetched, membersFetched, primaryAgent?.loading, primaryAgent?.primaryAgentId]); useEffect(() => { if (!newIssueOpen || !effectiveCompanyId || !defaultProjectPendingRef.current || !projects) return; defaultProjectPendingRef.current = false; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index e745e2b0164..53db38dcf06 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -279,7 +279,7 @@ export function Sidebar({ children }: { children?: ReactNode }) { ) : null} - {children} + {children ?? (streamlinedUiEnabled ? : null)} {streamlinedUiEnabled ? ( diff --git a/ui/src/components/SidebarAgents.production.tsx b/ui/src/components/SidebarAgents.production.tsx index c2d0bcfe4d2..08231f68b7d 100644 --- a/ui/src/components/SidebarAgents.production.tsx +++ b/ui/src/components/SidebarAgents.production.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "@/lib/router"; @@ -300,6 +301,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } const lastSeenLiveAtRef = useRef>(new Map()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); + const primary = usePrimaryAgentPresentation(selectedCompanyId); const { openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; @@ -608,6 +610,10 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } [displayedAgents, starredAgentIdSet], ); + const primaryAgent = primary && !primary.loading + ? visibleAgents.find(agent => agent.id === primary.primaryAgentId) + : undefined; + const renderAgentRow = (agent: Agent, isStarredRow: boolean) => ( - {starredAgents.map((agent: Agent) => renderAgentRow(agent, true))} - {dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))} + {[ + ...(primaryAgent ? [primaryAgent] : []), + ...starredAgents.filter(agent => agent.id !== primaryAgent?.id), + ...dedupedDisplayedAgents.filter(agent => agent.id !== primaryAgent?.id), + ].map(agent => renderAgentRow(agent, starredAgentIdSet.has(agent.id)))} {showSeeAllLink && (() => { // Deliberately NOT a SidebarNavItem: this is a quiet muted affordance // (plain Link) that must not adopt nav-row active-route highlighting. diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 0ae054f9eaf..7f5f6a55a6d 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "@/lib/router"; @@ -300,6 +301,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } const lastSeenLiveAtRef = useRef>(new Map()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); + const primary = usePrimaryAgentPresentation(selectedCompanyId); const { openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; @@ -608,6 +610,10 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } [displayedAgents, starredAgentIdSet], ); + const primaryAgent = primary && !primary.loading + ? visibleAgents.find(agent => agent.id === primary.primaryAgentId) + : undefined; + const renderAgentRow = (agent: Agent, isStarredRow: boolean) => ( - {starredAgents.map((agent: Agent) => renderAgentRow(agent, true))} - {dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))} + {[ + ...(primaryAgent ? [primaryAgent] : []), + ...starredAgents.filter(agent => agent.id !== primaryAgent?.id), + ...dedupedDisplayedAgents.filter(agent => agent.id !== primaryAgent?.id), + ].map(agent => renderAgentRow(agent, starredAgentIdSet.has(agent.id)))} {showSeeAllLink && (() => { // Deliberately NOT a SidebarNavItem: this is a quiet muted affordance // (plain Link) that must not adopt nav-row active-route highlighting. diff --git a/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx b/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx new file mode 100644 index 00000000000..4a978f72346 --- /dev/null +++ b/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx @@ -0,0 +1,94 @@ +import { createContext, useContext } from "react"; +import { ArrowRight, Crown, Loader2 } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +/** Shared presentation for the application and the approved review stories. */ +export interface PrimaryAgentPresentation { + companyId: string; + primaryAgentId: string | null; + primaryAgent: Agent | null; + loading?: boolean; + error?: string; + onRetry?: () => void; + pendingAgentId?: string | null; + onChange: (agentId: string) => void; +} + +const PrimaryAgentContext = createContext(null); +export const PrimaryAgentPresentationProvider = PrimaryAgentContext.Provider; + +export function usePrimaryAgentPresentation(companyId?: string | null) { + const value = useContext(PrimaryAgentContext); + return value && (!companyId || value.companyId === companyId) ? value : null; +} + +export function PrimaryAgentIndicator({ agentId, companyId }: { agentId: string; companyId?: string }) { + const primary = usePrimaryAgentPresentation(companyId); + if (!primary || primary.loading || primary.primaryAgentId !== agentId) return null; + return + + + + + My primary agent + ; +} + +export function SetPrimaryAgentButton({ agent }: { + agent: Agent; +}) { + const primary = usePrimaryAgentPresentation(agent.companyId); + if (!primary || agent.status === "terminated" || agent.status === "pending_approval") return null; + if (primary.error) return ; + const selected = primary.primaryAgentId === agent.id; + const pending = primary.pendingAgentId === agent.id; + if (selected && !pending && !primary.loading) return null; + const previous = primary.primaryAgent?.id === primary.primaryAgentId ? primary.primaryAgent : null; + const loading = primary.loading || Boolean(primary.primaryAgentId && !previous); + const disabled = loading || Boolean(primary.pendingAgentId); + const button = ; + if (!previous) return button; + return + {button} + +

    +
    + + {previous.name} +
    +
    + + Change your primary agent? + + This will make {agent.name} your primary agent, do you want to do this? + + + + Cancel + primary.onChange(agent.id)}>Set as my primary + + + ; +} diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index a51d76423cf..059ede5b0ff 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -16,6 +16,18 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; describe("LiveUpdatesProvider issue invalidation", () => { + it("refreshes personal membership state when a primary changes on another device", () => { + const client = new QueryClient(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "user_preference", entityId: "user-1", action: "primary_agent.updated", + actorType: "user", actorId: "user-1", + }, { userId: "user-1", agentId: null }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["primary-agent", "company-1"] }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.resourceMemberships.mine("company-1") }); + client.clear(); + }); + it.each([ ["chat-1", "issue.comment_added", "agent", "agent-1", true], ["task-1", "issue.comment_added", "agent", "agent-1", false], diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 0a8118a39c5..62260aad93b 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1280,7 +1280,11 @@ function invalidateActivityQueries( queryClient.invalidateQueries({ queryKey: ["ai-connections", companyId] }); } - if (action?.startsWith("resource_membership.")) { + if (action?.startsWith("primary_agent.") || action?.startsWith("agent.") || action?.startsWith("resource_membership.")) { + queryClient.invalidateQueries({ queryKey: ["primary-agent", companyId] }); + } + + if (action?.startsWith("resource_membership.") || action?.startsWith("primary_agent.")) { const targetUserId = readString(details?.userId); if (!targetUserId || targetUserId === currentActor.userId) { queryClient.invalidateQueries({ diff --git a/ui/src/context/PrimaryAgentProvider.test.tsx b/ui/src/context/PrimaryAgentProvider.test.tsx new file mode 100644 index 00000000000..82dd4b47296 --- /dev/null +++ b/ui/src/context/PrimaryAgentProvider.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PrimaryAgentProvider } from "./PrimaryAgentProvider"; +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { queryKeys } from "@/lib/queryKeys"; + +const state = vi.hoisted(() => ({ + companyId: "company-a", get: vi.fn(), set: vi.fn(), session: vi.fn(), toast: vi.fn(), +})); +vi.mock("./CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) })); +vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast: state.toast }) })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: state.session } })); +vi.mock("@/api/primaryAgent", () => ({ primaryAgentApi: { get: state.get, set: state.set } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: async (companyId: string) => [ + { id: "maia", name: "Maia", companyId, status: "idle" }, + { id: "alex", name: "Alex", companyId, status: "idle" }, +] } })); + +let root: Root, container: HTMLDivElement, client: QueryClient; +const preference = (companyId = "company-a", userId = "user-a", primaryAgentId: string | null = "maia") => ({ + companyId, userId, primaryAgentId, initialized: true, +}); +function Harness() { + const value = usePrimaryAgentPresentation(state.companyId); + const [onboarding, setOnboarding] = useState(false); + return <> + {value?.loading ? "loading" : value?.error ? "error" : value?.primaryAgentId ?? "none"} + + + + {onboarding ? : null} + {value?.pendingAgentId ?? "none"} + ; +} +async function render() { + await act(async () => root.render()); +} +async function waitForOutput(text: string) { + await vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(container.querySelector("output")?.textContent).toBe(text); + }); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.companyId = "company-a"; + state.get.mockReset().mockResolvedValue(preference()); + state.set.mockReset(); state.toast.mockReset(); + state.session.mockReset().mockResolvedValue({ user: { id: "user-a" } }); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); +}); + +describe("primary agent persistence provider", () => { + it("optimistically switches, restores the previous crown on failure, and offers a working retry", async () => { + let reject!: (error: Error) => void; + state.set.mockImplementationOnce(() => new Promise((_resolve, onReject) => { reject = onReject; })); + await render(); await waitForOutput("maia"); + await act(async () => container.querySelector("button")!.click()); + await waitForOutput("alex"); + await act(async () => reject(new Error("Connection lost"))); + await waitForOutput("maia"); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error", body: "Connection lost" })); + state.get.mockResolvedValue(preference("company-a", "user-a", "alex")); + state.set.mockResolvedValue(preference("company-a", "user-a", "alex")); + await act(async () => state.toast.mock.calls[0][0].action.onClick()); + await waitForOutput("alex"); + expect(state.set).toHaveBeenLastCalledWith("company-a", { primaryAgentId: "alex" }); + }); + + it("keeps cached preferences separate across companies and users", async () => { + await render(); await waitForOutput("maia"); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-a", "alex")); + await render(); await waitForOutput("alex"); + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await waitForOutput("none"); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference()); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-a"))).toEqual(preference("company-b", "user-a", "alex")); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-b"))).toEqual(preference("company-b", "user-b", null)); + }); + + it("preserves app and onboarding state when the company or authenticated user changes", async () => { + await render(); await waitForOutput("maia"); + await act(async () => container.querySelectorAll("button")[2].click()); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-a", "alex")); + await render(); await waitForOutput("alex"); + expect(container.querySelector("aside")?.textContent).toBe("Onboarding is open"); + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await waitForOutput("none"); + expect(container.querySelector("aside")?.textContent).toBe("Onboarding is open"); + }); + + it("keeps a late mutation failure in its original company and user scope", async () => { + let reject!: (error: Error) => void; + state.set.mockImplementationOnce(() => new Promise((_resolve, onReject) => { reject = onReject; })); + await render(); await waitForOutput("maia"); + await act(async () => container.querySelector("button")!.click()); + await waitForOutput("alex"); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await render(); await waitForOutput("none"); + expect(container.querySelector("[data-pending]")?.textContent).toBe("none"); + await act(async () => reject(new Error("Old request failed"))); + await waitForOutput("none"); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference()); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-b"))).toEqual(preference("company-b", "user-b", null)); + expect(state.toast).not.toHaveBeenCalled(); + }); + + it("retries session failures before fetching a personal preference", async () => { + state.session.mockRejectedValueOnce(new Error("Session unavailable")); + await render(); await waitForOutput("error"); + expect(state.get).not.toHaveBeenCalled(); + await act(async () => container.querySelectorAll("button")[1].click()); + await waitForOutput("maia"); + expect(state.session).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/context/PrimaryAgentProvider.tsx b/ui/src/context/PrimaryAgentProvider.tsx new file mode 100644 index 00000000000..71f211586a8 --- /dev/null +++ b/ui/src/context/PrimaryAgentProvider.tsx @@ -0,0 +1,54 @@ +import { useRef, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { PrimaryAgentPreference } from "@paperclipai/shared"; +import { agentsApi } from "@/api/agents"; +import { primaryAgentApi } from "@/api/primaryAgent"; +import { PrimaryAgentPresentationProvider } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { usePrimaryAgent } from "@/hooks/usePrimaryAgent"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "./CompanyContext"; +import { useToastActions } from "./ToastContext"; + +export function PrimaryAgentProvider({ children }: { children: ReactNode }) { + const { selectedCompanyId: companyId } = useCompany(); + const preference = usePrimaryAgent(companyId); + const client = useQueryClient(); + const { pushToast } = useToastActions(); + const scope = useRef({ companyId, userId: preference.userId }); + scope.current = { companyId, userId: preference.userId }; + type Choice = { companyId: string; userId: string; agentId: string }; + const keyFor = (choice: Choice) => queryKeys.primaryAgent.mine(choice.companyId, choice.userId); + const isCurrentScope = (choice: Choice) => choice.companyId === scope.current.companyId && choice.userId === scope.current.userId; + const roster = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId }); + const mutation = useMutation({ + mutationFn: (choice: Choice) => primaryAgentApi.set(choice.companyId, { primaryAgentId: choice.agentId }), + onMutate: async choice => { + const key = keyFor(choice); + await client.cancelQueries({ queryKey: key }); + const previous = client.getQueryData(key); + client.setQueryData(key, { companyId: choice.companyId, userId: choice.userId, primaryAgentId: choice.agentId, initialized: true }); + return { previous }; + }, + onError: (error, choice, context) => { + client.setQueryData(keyFor(choice), context?.previous); + if (!isCurrentScope(choice)) return; + pushToast({ title: "Couldn't change your primary agent.", body: error.message, tone: "error", + action: { label: "Retry", onClick: () => { if (isCurrentScope(choice)) mutation.mutate(choice); } } }); + }, + onSuccess: (data, choice) => client.setQueryData(keyFor(choice), data), + onSettled: (_data, _error, choice) => { + void client.invalidateQueries({ queryKey: keyFor(choice) }); + void client.invalidateQueries({ queryKey: queryKeys.resourceMemberships.mine(choice.companyId) }); + }, + }); + const primaryAgent = roster.data?.find(agent => agent.id === preference.data?.primaryAgentId && agent.status !== "terminated") ?? null; + const error = preference.error ?? roster.error; + return { void preference.retry(); void roster.refetch(); }, + pendingAgentId: mutation.isPending && isCurrentScope(mutation.variables) ? mutation.variables.agentId : null, + onChange: agentId => mutation.mutate({ companyId, userId: preference.userId, agentId }), + } : null}>{children}; +} diff --git a/ui/src/hooks/usePrimaryAgent.ts b/ui/src/hooks/usePrimaryAgent.ts new file mode 100644 index 00000000000..a3d3ca5a025 --- /dev/null +++ b/ui/src/hooks/usePrimaryAgent.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { authApi } from "@/api/auth"; +import { primaryAgentApi } from "@/api/primaryAgent"; +import { queryKeys } from "@/lib/queryKeys"; + +export function usePrimaryAgent(companyId: string | null | undefined) { + const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const userId = session.data?.user?.id ?? session.data?.session?.userId ?? "local-board"; + const query = useQuery({ + queryKey: queryKeys.primaryAgent.mine(companyId ?? "__none__", userId), + queryFn: () => primaryAgentApi.get(companyId!), + enabled: !!companyId && session.isSuccess, + }); + return { + ...query, + userId, + loading: !!companyId && (session.isPending || query.isPending && !session.error), + error: session.error ?? query.error, + retry: () => session.isError ? session.refetch() : query.refetch(), + }; +} diff --git a/ui/src/hooks/useResourceMemberships.ts b/ui/src/hooks/useResourceMemberships.ts index cfc33185246..bbdd0e942ec 100644 --- a/ui/src/hooks/useResourceMemberships.ts +++ b/ui/src/hooks/useResourceMemberships.ts @@ -193,6 +193,7 @@ export function useResourceMembershipMutation(companyId: string | null | undefin ); }, onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["primary-agent", companyId] }); queryClient.invalidateQueries({ queryKey }); }, }); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 085e9ec20fc..81b1ac98387 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -623,6 +623,9 @@ export const queryKeys = { projectOrder: (companyId: string, userId: string) => ["sidebar-preferences", "project-order", companyId, userId] as const, }, + primaryAgent: { + mine: (companyId: string, userId: string) => ["primary-agent", companyId, userId] as const, + }, resourceMemberships: { mine: (companyId: string) => ["resource-memberships", companyId, "me"] as const, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a12e3130d75..7fa23269b09 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -13,6 +13,7 @@ import { PanelProvider } from "./context/PanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext"; +import { PrimaryAgentProvider } from "./context/PrimaryAgentProvider"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -70,6 +71,7 @@ getOrCreatePaperclipReactRoot(window, rootElement).render( + @@ -85,6 +87,7 @@ getOrCreatePaperclipReactRoot(window, rootElement).render( + diff --git a/ui/src/pages/AgentChats.test.tsx b/ui/src/pages/AgentChats.test.tsx index a65f8fa646f..29fc4928c36 100644 --- a/ui/src/pages/AgentChats.test.tsx +++ b/ui/src/pages/AgentChats.test.tsx @@ -7,6 +7,8 @@ import { recordAgentChatVisit } from "@/lib/recent-agent-chats"; import { AgentChats } from "./AgentChats"; const state = vi.hoisted(() => ({ + primaryAgentId: null as string | null, + primaryLoading: false, companyId: "company-a", userId: "user-a", agents: [] as Agent[], @@ -17,6 +19,8 @@ const state = vi.hoisted(() => ({ navigate: vi.fn(), })); +vi.mock("@/components/primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => ({ primaryAgentId: state.primaryAgentId, loading: state.primaryLoading }) })); + vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({ @@ -48,6 +52,8 @@ async function render() { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.primaryAgentId = null; + state.primaryLoading = false; state.companyId = "company-a"; state.userId = "user-a"; state.agents = [agent("alice"), agent("bob")]; @@ -73,6 +79,29 @@ afterEach(async () => { }); describe("Chat landing", () => { + it("uses the primary only when there is no valid recent conversation", async () => { + state.primaryAgentId = "alice"; + recordAgentChatVisit("company-a", "user-a", "removed"); + await render(); + expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true }); + state.navigate.mockClear(); + recordAgentChatVisit("company-a", "user-a", "bob"); + await render(); + expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true }); + }); + + it("waits for the primary without sending anything and preserves the chooser for unavailable primaries", async () => { + state.primaryLoading = true; + await render(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Opening chat"); + state.primaryLoading = false; + state.primaryAgentId = "removed"; + await render(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Who would you like to talk to?"); + }); + it("opens the most recently visited agent and replaces the landing history entry", async () => { recordAgentChatVisit("company-a", "user-a", "alice"); recordAgentChatVisit("company-a", "user-a", "bob"); diff --git a/ui/src/pages/AgentChats.tsx b/ui/src/pages/AgentChats.tsx index 0cb30cd4a10..047d6d1d5be 100644 --- a/ui/src/pages/AgentChats.tsx +++ b/ui/src/pages/AgentChats.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { useEffect, useState } from "react"; import { MessageCircle } from "lucide-react"; import { AgentAvatar } from "@/components/AgentAvatar"; @@ -17,6 +18,7 @@ export function AgentChats() { } function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) { + const primary = usePrimaryAgentPresentation(companyId); const openChat = useOpenAgentChat(companyId, userId); const navigate = useNavigate(); const recentIds = useRecentAgentChats(companyId ?? "", userId); @@ -41,13 +43,18 @@ function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, return null; }).find((path): path is string => path !== null) : undefined; + const primaryAgent = agents.data?.find(agent => agent.id === primary?.primaryAgentId + && agent.status !== "terminated" && agent.status !== "pending_approval"); + const entryPath = recentChatPath ?? (loaded && enabled && !blockingError && !resolvingRecent + && agents.isFetched && session.isFetched && !primary?.loading && primaryAgent + ? `/chats/${encodeURIComponent(agentRouteRef(primaryAgent))}` : undefined); useEffect(() => { - if (recentChatPath) navigate(recentChatPath, { replace: true }); - }, [navigate, recentChatPath]); + if (entryPath) navigate(entryPath, { replace: true }); + }, [navigate, entryPath]); if (!loaded) return

    Loading chat…

    ; if (!enabled) return

    Agent Chat is disabled. Enable it in Experimental settings.

    ; if (!companyId) return

    Select a company to start a conversation.

    ; - if (resolvingRecent || recentChatPath) return

    Opening chat…

    ; + if (resolvingRecent || entryPath || primary?.loading) return

    Opening chat…

    ; const error = blockingError ?? (recentIds.length > 0 ? chats.error : null); return
    diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7d4863bcebc..d81683b6e8a 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator, SetPrimaryAgentButton } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AiConnectionPoolRunDetails } from "@/components/ai-connections/AiConnectionPoolRunDetails"; import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions"; import type { AgentInstructionCandidate, AgentInstructionsBundle } from "@paperclipai/shared"; @@ -1247,7 +1248,7 @@ export function AgentDetail() {
    -

    {agent.name}

    +

    {agent.name}

    {agent.adapterType === "claude_local" || agent.adapterType === "codex_local" ? @@ -1255,6 +1256,7 @@ export function AgentDetail() { {getAdapterDisplay(agent.adapterType).label}· {agent.title || roleLabels[agent.role] || agent.role}
    +
    diff --git a/ui/src/pages/Agents.production.tsx b/ui/src/pages/Agents.production.tsx index 34512e9dc0c..8c20e535e5c 100644 --- a/ui/src/pages/Agents.production.tsx +++ b/ui/src/pages/Agents.production.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useState, useEffect, useMemo, lazy, Suspense } from "react"; import { Link, useNavigate, useLocation } from "@/lib/router"; @@ -373,6 +374,7 @@ export function Agents() { } // Fixed (truncating) title width at xl so the `meta` group starts at a // constant x on every row — that's what makes the model + timestamp // columns line up vertically. Below xl the meta columns are hidden, so diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 1ee474816da..bbf192c674f 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled"; import { useState, useEffect, useMemo, lazy, Suspense } from "react"; @@ -376,6 +377,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = } titleClassName="flex-1 @5xl:flex-none @5xl:w-56" titleTextClassName="truncate" subtitleClassName="truncate" diff --git a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx b/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx new file mode 100644 index 00000000000..229bef555d5 --- /dev/null +++ b/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const surfaces = [ + ["01 Components", "crown-indicator", "Primary indicator", "Identity and crown presentation only; absent primary, loading, long names."], + ["02 Sidebar", "idle-primary", "Agents sidebar", "Pinned idle primary, active colleagues, independent stars, collapsed section, mobile drawer, classic shell."], + ["03 Agents", "specialist-profile", "Agent list and profile", "Set as my primary on the individual page; crown beside the primary’s name, first-created agent, paused and unavailable states."], + ["04 New task", "primary-fallback", "New-task composer", "Primary fallback, recent specialist, explicit assignment, restored draft, mobile."], + ["05 Chat", "primary-fallback", "Chat", "Full production Chat page and navigation; recent conversation, primary fallback, chooser, paused state, mobile."], + ["06 Journeys", "switch-primary", "Interactive journeys", "Switch from an agent’s page, keep stars, recover a failed save, leave a primary, and start work."], + ["07 Confirmation", "replace-primary", "Switch confirmation", "Current and new agent avatars, named warning, cancel/confirm, long names, mobile, and light theme."], +]; +const storyLink = (group: string, story: string) => `/?path=/story/primary-agent-${group.toLowerCase().replaceAll(" ", "-")}--${story}`; + +function ReviewGuide() { + return
    +
    +
    +

    Design review · Primary Agent

    +

    Your main point of contact

    +

    One crown per person, per company. Your first agent receives it automatically. Recent choices take precedence; the crown is your fallback.

    + Start the walkthrough +
    +
    + + + {surfaces.map(([group, id, label, coverage]) => + + + )} +
    SurfaceReview coverage
    {label}{coverage}
    +
    +
    +

    What to try

    +
      +
    1. Open an agent’s page and choose Set as my primary beneath its details. If you already have a primary, review the two avatars and named warning before confirming. Cancel preserves your current primary.
    2. +
    3. When no primary exists, choosing one applies immediately without a confirmation modal. The selected agent’s profile and list row receive the crown; it moves to the top of the Agents sidebar.
    4. +
    5. Inspect both sidebars and the agent kebab menus: they have no crown or primary action. The current primary has no removal option.
    6. +
    7. Star or unstar an agent. Stars remain independent of your primary choice.
    8. +
    9. Compare the new-task and Chat scenarios. A recent specialist wins over Maia; an explicit choice or saved draft is preserved.
    10. +
    11. Try the failed-save journey twice: the first attempt rolls back, the second succeeds.
    12. +
    13. Use the light-theme and mobile stories. Change the editable primary name in Controls to inspect truncation.
    14. +
    +
    +
    +

    Review boundary

    +

    The pages, menus, avatars, composer, and app shell are production components. Preferences, first-creation results, fallback routing, and server responses are isolated Storybook simulations. Reload resets a story; no real agents run and no company data changes.

    +

    The live app has retired the collapsed icon rail. Coverage uses the supported collapsible Agents section and mobile drawer instead. In the streamlined shell, the proposed Agents section is supplied through Layout’s existing sidebar slot; the classic shell uses its existing section.

    +

    Persistence, migration, API authorization, and production default selection come after this review. These stories remain the implementation reference.

    +
    +
    +
    ; +} +export default { title: "Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; +export const StartHere: StoryObj = {}; diff --git a/ui/storybook/stories/primary-agent/01-components.stories.tsx b/ui/storybook/stories/primary-agent/01-components.stories.tsx new file mode 100644 index 00000000000..339a71269fe --- /dev/null +++ b/ui/storybook/stories/primary-agent/01-components.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AgentIdentity } from "@/components/AgentIdentity"; +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "./PrimaryAgentStory"; + +function Components(_args: PrimaryScenario) { + const { agents } = usePrimaryStory(); + return
    + {agents.map(agent =>
    +
    +
    )} +
    ; +} +export default { title: "Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; +type Story = StoryObj; +export const CrownIndicator: Story = {}; +export const NoPrimary: Story = { args: { primaryId: null } }; +export const Loading: Story = { args: { loading: true } }; +export const LongName: Story = { args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx b/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx new file mode 100644 index 00000000000..e392af89f73 --- /dev/null +++ b/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +type Story = StoryObj; +export const IdlePrimary: Story = {}; +export const ActiveColleague: Story = { parameters: { primaryAgent: { active: true } } }; +export const ActivePrimary: Story = { parameters: { primaryAgent: { activePrimary: true } } }; +export const LongName: Story = { args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const PrimaryIsAlsoStarred: Story = { parameters: { primaryAgent: { primaryId: "agent-qa" } } }; +export const SectionCollapsed: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Collapse Agents" })); +} }; +export const MobileDrawer: Story = { globals: mobile, render: () => }; +export const MobileDrawerClosed: Story = { globals: mobile }; +export const ClassicShell: Story = { parameters: { primaryAgent: { classic: true } } }; +export const LoadingPreference: Story = { parameters: { primaryAgent: { loading: true } } }; +export const NoPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/03-agents.stories.tsx b/ui/storybook/stories/primary-agent/03-agents.stories.tsx new file mode 100644 index 00000000000..ae4d8115a3f --- /dev/null +++ b/ui/storybook/stories/primary-agent/03-agents.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +type Story = StoryObj; +export const Roster: Story = {}; +export const Profile: Story = { parameters: { initialEntries: ["/PAP/agents/maia"] } }; +export const SpecialistProfile: Story = { parameters: { initialEntries: ["/PAP/agents/alex"] } }; +export const FirstCreatedAgent: Story = { parameters: { primaryAgent: { firstCreated: true } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const ErrorPrimary: Story = { parameters: { primaryAgent: { error: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const PreviouslyLeftAgent: Story = { parameters: { primaryAgent: { leftPrimary: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const UnavailablePrimary: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const ClearedPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const MobileRoster: Story = { globals: mobile }; +export const MobileProfile: Story = { ...Profile, globals: mobile }; +export const MobileSpecialistProfile: Story = { ...SpecialistProfile, globals: mobile }; +export const LoadingProfile: Story = { ...SpecialistProfile, parameters: { ...SpecialistProfile.parameters, primaryAgent: { loading: true } } }; +export const ClassicProfile: Story = { ...Profile, parameters: { ...Profile.parameters, primaryAgent: { classic: true } } }; diff --git a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx b/ui/storybook/stories/primary-agent/04-new-task.stories.tsx new file mode 100644 index 00000000000..62a316e629e --- /dev/null +++ b/ui/storybook/stories/primary-agent/04-new-task.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; +type Story = StoryObj; +export const PrimaryFallback: Story = {}; +export const RecentSpecialistWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const ExplicitAssignmentWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const RestoredDraftWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const NoPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const UnavailablePrimary: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true } } }; +export const MobilePrimary: Story = { globals: mobile }; +export const MobileDraft: Story = { ...RestoredDraftWins, globals: mobile }; diff --git a/ui/storybook/stories/primary-agent/05-chat.stories.tsx b/ui/storybook/stories/primary-agent/05-chat.stories.tsx new file mode 100644 index 00000000000..b5a2f3fa2aa --- /dev/null +++ b/ui/storybook/stories/primary-agent/05-chat.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; +type Story = StoryObj; +export const PrimaryFallback: Story = {}; +export const RecentConversationWins: Story = { parameters: { primaryAgent: { recentChat: true } } }; +export const NoPrimaryChooser: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true } } }; +export const UnavailablePrimaryChooser: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const MobilePrimary: Story = { globals: mobile }; +export const MobileChooser: Story = { ...NoPrimaryChooser, globals: mobile }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx b/ui/storybook/stories/primary-agent/06-journeys.stories.tsx new file mode 100644 index 00000000000..0172759b91f --- /dev/null +++ b/ui/storybook/stories/primary-agent/06-journeys.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { PrimaryPage, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +async function openActions(canvasElement: HTMLElement, name: string, profile = false) { + const page = within(canvasElement.ownerDocument.body); + const buttons = await page.findAllByRole("button", { name: `Open actions for ${name}` }); + const button = (profile ? buttons[buttons.length - 1] : buttons[0])!; + button.focus(); + await userEvent.keyboard("{Enter}"); + return page; +} +const maiaProfile = { initialEntries: ["/PAP/agents/maia"] }; +export default { title: "Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; +type Story = StoryObj; +async function confirmPrimaryChange(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + const dialog = within(await page.findByRole("alertdialog", { name: "Change your primary agent?" })); + await expect(dialog.getByText("This will make Alex your primary agent, do you want to do this?")).toBeVisible(); + await expect(dialog.getByRole("img", { name: "Maia avatar" })).toBeVisible(); + await expect(dialog.getByRole("img", { name: "Alex avatar" })).toBeVisible(); + await userEvent.click(dialog.getByRole("button", { name: "Set as my primary" })); + return page; +} +export const SwitchPrimary: Story = { play: async ({ canvasElement }) => { + const page = await confirmPrimaryChange(canvasElement); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); + await expect(page.queryByRole("button", { name: "Set as my primary" })).not.toBeInTheDocument(); +} }; +export const PrimaryHasNoUnsetAction: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByRole("img", { name: "My primary agent" })).toBeVisible(); + await expect(page.queryByRole("button", { name: "Set as my primary" })).not.toBeInTheDocument(); + await openActions(canvasElement, "Maia", true); + await expect(page.queryByRole("button", { name: /primary/i })).not.toBeInTheDocument(); +} }; +export const FailedSaveRestoresCrown: Story = { parameters: { primaryAgent: { failNext: true } }, play: async ({ canvasElement }) => { + const page = await confirmPrimaryChange(canvasElement); + await waitFor(() => expect(page.getByText("Couldn't change your primary agent")).toBeVisible()); + await expect(within(page.getByRole("main")).queryByRole("img", { name: "My primary agent" })).not.toBeInTheDocument(); + await expect(page.getByRole("button", { name: "Set as my primary" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +} }; +export const StarDoesNotMoveCrown: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Jules"); + await userEvent.click(await page.findByText("Star agent")); + await openActions(canvasElement, "Jules"); + await expect(await page.findByText("Remove from starred")).toBeVisible(); + await expect(page.queryByRole("menuitem", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; +export const LeavePrimary: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Maia"); + await userEvent.click(await page.findByText("Leave agent")); + await waitFor(() => expect(page.queryAllByRole("img", { name: "My primary agent" })).toHaveLength(0)); + await expect(await page.findByRole("button", { name: "Set as my primary" })).toBeVisible(); +} }; +export const SwitchThenStartTask: Story = { play: async context => { + await SwitchPrimary.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("button", { name: "New Task" })); + await expect(await page.findByRole("dialog")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Select assignee" })).toHaveTextContent("Alex"); +} }; +export const RetryFailedSave: Story = { ...FailedSaveRestoresCrown, play: async context => { + await FailedSaveRestoresCrown.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; +export const SwitchThenOpenChat: Story = { play: async context => { + await SwitchPrimary.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("link", { name: "Chat" })); + await expect(await page.findByText("Message Alex — describe what you want done…")).toBeVisible(); + await expect(page.queryAllByRole("img", { name: "My primary agent" })).toHaveLength(0); +} }; +export const ProfileActionOnly: Story = { play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Alex"); + await expect(page.queryByRole("menuitem", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await openActions(canvasElement, "Alex", true); + await expect(within(page.getByRole("dialog")).queryByRole("button", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await expect(page.getByRole("button", { name: "Set as my primary" })).toBeVisible(); +} }; +export const CancelKeepsPrimary: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + const dialog = within(await page.findByRole("alertdialog")); + await userEvent.click(dialog.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(page.queryByRole("alertdialog")).not.toBeInTheDocument()); + await expect(page.getByRole("button", { name: "Set as my primary" })).toHaveFocus(); + await expect(within(page.getByRole("main")).queryByRole("img", { name: "My primary agent" })).not.toBeInTheDocument(); + await userEvent.click(page.getByRole("link", { name: "See all agents" })); + await expect(await page.findByRole("link", { name: /^Maia My primary agent/ })).toBeVisible(); +} }; +export const FirstChoiceNeedsNoConfirmation: Story = { parameters: { primaryAgent: { primaryId: null } }, play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + await expect(page.queryByRole("alertdialog")).not.toBeInTheDocument(); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; diff --git a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx b/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx new file mode 100644 index 00000000000..73329989d76 --- /dev/null +++ b/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { + title: "Primary Agent/07 Confirmation", + decorators: [primaryDecorator], + parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, + render: () => , +} satisfies Meta; +type Story = StoryObj; + +export const ReplacePrimary: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + await expect(await page.findByRole("alertdialog", { name: "Change your primary agent?" })).toBeVisible(); +} }; +export const LongName: Story = { ...ReplacePrimary, args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const Mobile: Story = { ...ReplacePrimary, globals: mobile }; +export const Light: Story = { ...ReplacePrimary, globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx b/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx new file mode 100644 index 00000000000..c743d65d5f9 --- /dev/null +++ b/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx @@ -0,0 +1,269 @@ +import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Decorator } from "@storybook/react-vite"; +import type { Agent, Issue, ResourceMemberships } from "@paperclipai/shared"; +import { Layout } from "@/components/Layout"; +import { Layout as ClassicLayout } from "@/components/Layout.production"; +import { SidebarAgents } from "@/components/SidebarAgents"; +import { Agents } from "@/pages/Agents"; +import { Agents as ClassicAgents } from "@/pages/Agents.production"; +import { AgentDetail } from "@/pages/AgentDetail"; +import { AgentChats } from "@/pages/AgentChats"; +import { AgentChat } from "@/pages/AgentChat"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { PrimaryAgentPresentationProvider, usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useToastActions } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { agentChatDraft } from "@/lib/agent-chat-draft"; +import { Navigate, Route, Routes } from "@/lib/router"; +import { storybookAgents, storybookAuthSession, storybookCompanies, storybookIssues, storybookDashboardSummary } from "../../fixtures/paperclipData"; + +export const companyId = "company-storybook"; +export const mobile = { viewport: { value: "mobile", isRotated: false } }; +export const primaryAgent = { ...storybookAgents[0]!, name: "Maia", urlKey: "maia", title: "Chief of staff", role: "general", status: "idle", reportsTo: null } as Agent; +export const specialist = { ...storybookAgents[1]!, name: "Alex", urlKey: "alex", title: "Product engineer", role: "engineer", status: "idle", reportsTo: null } as Agent; +export const designer = { ...storybookAgents[2]!, name: "Jules", urlKey: "jules", title: "Designer", role: "designer", status: "idle" } as Agent; + +export interface PrimaryScenario { + primaryId?: string | null; + primaryName?: string; + paused?: boolean; + error?: boolean; + active?: boolean; + activePrimary?: boolean; + loading?: boolean; + failNext?: boolean; + firstCreated?: boolean; + classic?: boolean; + drawer?: boolean; + recentAssignee?: boolean; + recentChat?: boolean; + unavailable?: boolean; + leftPrimary?: boolean; +} + +interface StoryState { + agents: Agent[]; + memberships: ResourceMemberships; + chats: Issue[]; + primaryId: string | null; + options: PrimaryScenario; +} +const StoryStateContext = createContext(null); +export function usePrimaryStory() { + return useContext(StoryStateContext)!; +} + +function makeState(options: PrimaryScenario): StoryState { + const first = { ...primaryAgent, name: options.primaryName || primaryAgent.name, status: options.paused ? "paused" : options.error ? "error" : options.activePrimary ? "running" : "idle" } as Agent; + return { + agents: options.firstCreated ? [first] : [...(options.unavailable ? [] : [first]), { ...specialist, status: options.active ? "running" : "idle" }, designer], + memberships: { projectMemberships: {}, agentMemberships: options.leftPrimary ? { [first.id]: "left" } : {}, starredAgentIds: options.firstCreated ? [] : [specialist.id], starredProjectIds: [], starredDocumentIds: [], agentStarredAt: {}, projectStarredAt: {}, documentStarredAt: {}, updatedAt: null }, + chats: options.recentChat ? [{ ...agentChatDraft(specialist), id: "primary-story-recent-chat", conversationUserId: "user-board", createdAt: new Date("2026-10-07T13:00:00Z"), updatedAt: new Date("2026-10-07T13:05:00Z") }] : [], + primaryId: options.unavailable || options.leftPrimary ? null : options.primaryId === undefined ? first.id : options.primaryId, + options, + }; +} + +/** All simulated writes are confined to this mounted story. No new API client + * or server behavior is installed in the application during design review. */ +function installFixtures(state: StoryState, changed: () => void) { + const previousFetch = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); + if (!url.pathname.startsWith("/api/")) return previousFetch(input, init); + const path = url.pathname; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}; + const json = (value: unknown) => Response.json(value); + if (path === "/api/auth/get-session") return json(storybookAuthSession); + if (path === "/api/companies") return json([{ ...storybookCompanies[0], name: "Northstar", requireBoardApprovalForNewAgents: false }]); + if (path === "/api/health") return json({ deploymentMode: "authenticated", status: "ok" }); + if (path === "/api/instance/settings/experimental") return json({ enableStreamlinedUi: !state.options.classic, enableAgentChat: true, enableManagedSandboxOnly: false, enableCombinedInboxTasks: true }); + if (path === "/api/instance/settings") return json({ experimental: { enableStreamlinedUi: !state.options.classic, enableAgentChat: true, enableBuiltInAgents: false, enableEnvironments: false } }); + if (path === "/api/cli-auth/me" || path.endsWith("/access/me")) return json({ user: storybookAuthSession.user, userId: "user-board", source: "authenticated", isInstanceAdmin: false, companyIds: [companyId], memberships: [{ companyId, membershipRole: "owner", status: "active" }], keyId: null }); + if (path.endsWith("/resource-memberships/me")) return json(state.memberships); + const membership = path.match(/resource-memberships\/me\/agents\/([^/]+)$/); + if (membership && method === "PUT") { + const id = membership[1]!; + const stars = new Set(state.memberships.starredAgentIds); + if (body.starred === true) stars.add(id); + if (body.starred === false || body.state === "left") stars.delete(id); + state.memberships = { ...state.memberships, starredAgentIds: [...stars], agentMemberships: { ...state.memberships.agentMemberships, [id]: body.state ?? "joined" } }; + if (body.state === "left" && state.primaryId === id) state.primaryId = null; + changed(); + return json({ resourceType: "agent", resourceId: id, state: body.state ?? "joined", starredAt: stars.has(id) ? new Date() : null, updatedAt: new Date() }); + } + if (path === `/api/companies/${companyId}/agents`) return json(state.agents); + const detail = path.match(/^\/api\/agents\/([^/]+)$/); + if (detail) { + const agent = state.agents.find(a => a.id === detail[1] || a.urlKey === detail[1]); + return agent ? json({ ...agent, chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] } }) : new Response(null, { status: 404 }); + } + const lifecycle = path.match(/^\/api\/agents\/([^/]+)\/(pause|resume|terminate)$/); + if (lifecycle && method === "POST") { + const agent = state.agents.find(a => a.id === lifecycle[1])!; + agent.status = lifecycle[2] === "pause" ? "paused" : lifecycle[2] === "terminate" ? "terminated" : "idle"; + if (agent.status === "terminated") { + state.agents = state.agents.filter(a => a.id !== agent.id); + if (state.primaryId === agent.id) state.primaryId = null; + } + changed(); + return json(agent); + } + if (path.endsWith("/runtime-state") || path.endsWith("/identity")) return json(null); + if (path.endsWith("/org")) return json(state.agents.map(a => ({ ...a, children: [] }))); + if (path.endsWith("/live-runs")) return json(state.options.active || state.options.activePrimary ? [{ id: "primary-story-run", agentId: state.options.activePrimary ? primaryAgent.id : specialist.id, companyId, status: "running", issueId: null, startedAt: new Date() }] : []); + if (path.endsWith("/dashboard")) return json(storybookDashboardSummary); + if (path.endsWith("/sidebar-badges")) return json({ inbox: 0, approvals: 0, failedRuns: 0, joinRequests: 0 }); + if (path.endsWith("/sidebar-preferences/me")) return json({ orderedIds: [], updatedAt: null }); + if (path.endsWith("/user-directory")) return json({ users: [{ principalId: "user-board", status: "active", user: storybookAuthSession.user }] }); + if (path.endsWith("/budgets/overview")) return json({ companyId, policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0 }); + if (path === `/api/companies/${companyId}/chats`) return json(state.chats); + const chat = path.match(/\/chats\/([^/]+)$/); + if (chat) { + const agent = state.agents.find(a => a.id === chat[1] || a.urlKey === chat[1]); + let issue = state.chats.find(i => i.conversationAgentId === agent?.id); + if (!issue && method === "POST" && agent) { + issue = { ...agentChatDraft(agent), id: `primary-chat-${agent.id}`, conversationUserId: "user-board", createdAt: new Date(), updatedAt: new Date() }; + state.chats.push(issue); + } + return json(issue ?? null); + } + const issueDetail = path.match(/^\/api\/issues\/([^/]+)$/); + if (issueDetail) return json(state.chats.find(issue => issue.id === issueDetail[1]) ?? null); + if (path === "/api/issues/primary-story-recent-chat/comments") return json([ + { id: "primary-story-reply", issueId: "primary-story-recent-chat", companyId, authorType: "agent", authorAgentId: specialist.id, authorUserId: null, body: "The mobile navigation update is ready for your review.", presentation: null, metadata: null, createdAt: "2026-10-07T13:05:00Z", updatedAt: "2026-10-07T13:05:00Z" }, + { id: "primary-story-message", issueId: "primary-story-recent-chat", companyId, authorType: "user", authorAgentId: null, authorUserId: "user-board", body: "Can you review the mobile navigation before we launch?", presentation: null, metadata: null, createdAt: "2026-10-07T13:00:00Z", updatedAt: "2026-10-07T13:00:00Z" }, + ]); + if (path.endsWith("/active-run") || path.endsWith("/documents/plan")) return json(null); + if (path.endsWith("/tree-control/state")) return json({ activePauseHold: null }); + if (path.includes("/email/tasks/")) return json(null); + if (path.endsWith("/queued-comments")) return json({ issueId: path.split("/")[3], queueId: null, state: null, targetRunId: null, revision: "0", entries: [] }); + if (path === `/api/companies/${companyId}/issues`) { + if (method === "POST") return json({ ...storybookIssues[0], ...body, id: "primary-story-task", identifier: "PAP-1642", companyId }); + return json([]); + } + if (path.endsWith("/read-state")) return json({ unread: false }); + if (path.endsWith("/capabilities") && path.includes("/environments")) return json({ environments: [], defaultEnvironmentId: null }); + if (path.includes("/models") || path === "/api/adapters" || path.includes("/model-profiles")) return previousFetch(input, init); + // Other existing read-only shell panels are empty in this isolated company. + // Refuse unknown writes rather than ever reaching a running Paperclip API. + return method === "GET" ? json([]) : Response.json({ error: "This action is outside the primary-agent design preview." }, { status: 422 }); + }; + return () => { window.fetch = previousFetch; }; +} + +function Sandbox({ options, children }: { options: PrimaryScenario; children: ReactNode }) { + const [state] = useState(() => makeState(options)); + const [ready, setReady] = useState(false); + const [primaryId, setPrimaryId] = useState(state.primaryId); + const [pendingAgentId, setPendingAgentId] = useState(null); + const [loading] = useState(Boolean(options.loading)); + const failed = useRef(false); + const timers = useRef[]>([]); + const client = useQueryClient(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { pushToast, dismissToast } = useToastActions(); + useLayoutEffect(() => { + const storageKeys = ["paperclip:issue-draft", "paperclip:recent-assignees", `paperclip:recent-assignees:${companyId}`, `paperclip.recentAgentChats:${companyId}:user-board`, ...state.agents.map(a => `paperclip.recentAgentChatIssue:${companyId}:user-board:${a.id}`)]; + const previous = storageKeys.map(key => [key, localStorage.getItem(key)] as const); + storageKeys.forEach(key => localStorage.removeItem(key)); + const changed = () => { + setPrimaryId(state.primaryId); + client.setQueryData(queryKeys.agents.list(companyId), [...state.agents]); + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), { ...state.memberships }); + }; + const restore = installFixtures(state, changed); + client.setQueryData(queryKeys.companies.list("user-board"), { companies: [{ ...storybookCompanies[0], name: "Northstar", requireBoardApprovalForNewAgents: false }], unauthorized: false }); + client.setQueryData(queryKeys.auth.session, storybookAuthSession); + client.setQueryData(queryKeys.agents.list(companyId), state.agents); + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), state.memberships); + client.setQueryData(queryKeys.projects.list(companyId), []); + setSelectedCompanyId(companyId); + setReady(true); + return () => { + restore(); + timers.current.forEach(clearTimeout); + previous.forEach(([key, value]) => value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value)); + }; + }, [client, setSelectedCompanyId, state]); + // Review-only simulation: feed the proposed resolved default to the existing + // composer through its remembered selection. Production resolution is deferred. + useEffect(() => { + if (!ready) return; + const id = options.recentAssignee ? specialist.id : primaryId; + localStorage.setItem(`paperclip:recent-assignees:${companyId}`, JSON.stringify(id ? [`agent:${id}`] : [])); + }, [ready, primaryId, options.recentAssignee]); + const changePrimary = (id: string) => { + const before = state.primaryId; + setPrimaryId(id); + setPendingAgentId(id); + timers.current.push(setTimeout(() => { + setPendingAgentId(null); + if (options.failNext && !failed.current) { + failed.current = true; + setPrimaryId(before); + const toastId = pushToast({ title: "Couldn't change your primary agent", body: "Your previous choice is still saved.", tone: "error", action: { label: "Try again", onClick: () => { if (toastId) dismissToast(toastId); changePrimary(id); } } }); + } else { + state.primaryId = id; + if (id && state.memberships.agentMemberships[id] === "left") { + state.memberships = { ...state.memberships, agentMemberships: { ...state.memberships.agentMemberships, [id]: "joined" } }; + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), state.memberships); + } + } + }, 450)); + }; + return ready && selectedCompanyId === companyId ? + agent.id === primaryId) ?? null, pendingAgentId, loading, onChange: changePrimary }}> + {children} + + : null; +} + +export const primaryDecorator: Decorator = (Story, context) => ; +export const primaryParameters = { + layout: "fullscreen", + initialEntries: ["/PAP/agents/all"], + waitForViewport: true, + docs: { story: { inline: false, height: "850px" }, description: { component: "Design review: shared production surfaces with in-memory preferences and API fixtures. Primary fallback routing and first-creation initialization are simulated here; no persistence, automatic production selection, or agent execution has been implemented." } }, +}; + +function ChatEntry() { + const state = usePrimaryStory(); + const primary = usePrimaryAgentPresentation(companyId); + const agent = state.agents.find(a => a.id === (state.options.recentChat ? specialist.id : primary?.primaryAgentId)); + return agent ? : ; +} + +export function PrimaryPage({ task, drawer }: { task?: "primary" | "recent" | "explicit" | "draft"; drawer?: boolean }) { + const state = usePrimaryStory(); + const { setSidebarOpen } = useSidebar(); + const { openNewIssue } = useDialogActions(); + const launched = useRef(false); + useEffect(() => { + if (drawer || state.options.drawer) setSidebarOpen(true); + if (task && !launched.current) { + launched.current = true; + if (task === "draft") { + localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Review the launch mockups", description: "Keep the mobile navigation compact.", companyId, status: "todo", priority: "medium", assigneeValue: `agent:${designer.id}`, projectId: "", reviewerValue: "", approverValue: "", assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false })); + openNewIssue(); + } else { + openNewIssue({ title: "Plan next week's launch", ...(task === "explicit" ? { assigneeAgentId: designer.id } : task === "primary" ? { assigneeAgentId: state.primaryId ?? undefined } : {}) }); + } + } + }, [drawer, openNewIssue, setSidebarOpen, state, task]); + return + : } />}> + : } /> + } /> + } /> + } /> + } /> + } /> + + ; +} From 43b0aff54b39bfb26af39dee6f33533cbe7b1628 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:02:29 -0700 Subject: [PATCH 14/55] Keep pre-dispatch secret configuration blockers out of Sentry (#15472) Keep confirmed pre-dispatch missing-secret setup blockers out of Sentry while preserving failed runs and owner recovery actions. Runtime, provider and ambiguous failures remain reportable. Verified full exact-head CI, focused local regressions, workspace typecheck and independent review; Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- doc/observability.md | 15 ++- .../heartbeat-process-recovery.test.ts | 4 + .../__tests__/run-failure-report.test.ts | 93 +++++++++++++++++++ server/src/services/run-failure-report.ts | 45 +++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/doc/observability.md b/doc/observability.md index 5c1e57c1784..e8d1262a0bb 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -269,9 +269,9 @@ environment variables: `SENTRY_DSN_FRONTEND` for the browser and `SENTRY_DSN_BACKEND` for the server. Each variable is optional. A specific variable always wins for its own component; a legacy variable, `SENTRY_DSN`, supplies a component that has no specific value set. An -empty string counts as absent for all three variables. The feature uses -built-in Sentry options only. It adds no `beforeSend` hook and no custom -filter code. +empty string counts as absent for all three variables. SDK configuration uses +built-in Sentry options only. It adds no `beforeSend` hook. The run-failure +reporter selects reportable outcomes before calling the SDK, as described below. The server is inactive when the backend DSN resolves to `null`; then it imports no Sentry package. The browser is inactive when the front-end DSN @@ -434,6 +434,15 @@ They do not change the ambient Sentry scope, whose isolation is unavailable without an OpenTelemetry context manager. Later, unrelated exceptions must not inherit a previous run's identity or fingerprint. +Known missing-secret configuration blockers are kept in the task's run log, +blocked state, and owner recovery action, without a Sentry run-failure event. +This requires a failed `configuration_incomplete` run in the preparing stage, +a setup-phase report, explicit proof that provider work did not start, and a +nonempty list of recognized missing or inactive secret bindings. Process exit +evidence, unknown binding reasons, secret-provider failures, ambiguous missing +secret-definition lookups, and workspace failures remain reportable. This filter +does not change task recovery, credentials, or execution policy. + The `run_failure` context also includes the recorded process `exitCode` and `signal`, so a generic adapter error can still distinguish a nonzero exit from a signal termination. Exit codes must fit the database's signed 32-bit integer; diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index cc60102766a..86b3867ae50 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -5,6 +5,7 @@ import { legacyDispositionFingerprint, LEGACY_DISPOSITION_REPAIR_INSTRUCTION } f import * as controllerLeases from "../services/legacy-controller-lease.js"; import * as instructionWorkingCopies from "../services/agent-instruction-working-copies.js"; import * as runEvents from "../services/heartbeat-run-events.js"; +import { waitForPendingRunFailureReports } from "../services/run-failure-report.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { createHash, randomUUID } from "node:crypto"; import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; @@ -5688,6 +5689,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); }); expect(configurationComment).toBeTruthy(); + await heartbeat.waitForRunExecutionDrain(runId); + await waitForPendingRunFailureReports(); + expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toEqual([]); }); it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => { diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index a717dfd3479..9176bdb659b 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -110,6 +110,99 @@ describeEmbeddedPostgres("reportRunFailure", () => { } as unknown as typeof heartbeatRuns.$inferSelect; } + function missingSecretRun(overrides: Partial = {}) { + return buildRun({ + errorCode: "configuration_incomplete", + executionStage: "preparing", + resultJson: { + configurationIncomplete: { + reason: "secret_binding_missing", + missingBindings: [{ bindingType: "user_secret_ref", errorCode: "user_secret_missing" }], + }, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + ...overrides, + }); + } + + it.each([ + { bindingType: "secret_ref" }, + { bindingType: "secret_ref", errorCode: "binding_missing" }, + ...["binding_missing", "responsible_user_missing", "user_secret_missing", "secret_inactive", "user_secret_definition_inactive"] + .map((errorCode) => ({ bindingType: "user_secret_ref", errorCode })), + ])("keeps the known pre-dispatch secret blocker local: %j", async (binding) => { + await seedCompanyAndAgent(); + const run = missingSecretRun(); + (run.resultJson!.configurationIncomplete as Record).missingBindings = [binding]; + const saved = structuredClone(run); + + await reportRunFailure(db, run, { phase: "setup" }); + + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(run).toEqual(saved); + }); + + it.each([ + {}, + { reason: "secret_binding_missing", missingBindings: [] }, + { reason: "secret_binding_missing", missingBindings: [null] }, + { reason: "secret_binding_missing", missingBindings: [{ errorCode: "user_secret_missing" }] }, + { reason: "secret_binding_missing", missingBindings: [{ bindingType: "user_secret_ref", errorCode: "provider_error" }] }, + { reason: "secret_binding_missing", missingBindings: [{ bindingType: "user_secret_ref", errorCode: "user_secret_definition_missing" }] }, + { reason: "secret_binding_missing", missingBindings: [{ bindingType: "user_secret_ref", errorCode: "new_unknown_reason" }] }, + { reason: "secret_binding_missing", missingBindings: [ + { bindingType: "user_secret_ref", errorCode: "user_secret_missing" }, + { bindingType: "secret_ref", errorCode: "provider_error" }, + ] }, + { reason: "ai_connection_unavailable" }, + ])("reports ambiguous, unknown, and provider failures: %j", async (configurationIncomplete) => { + await seedCompanyAndAgent(); + const run = missingSecretRun(); + run.resultJson!.configurationIncomplete = configurationIncomplete; + + await reportRunFailure(db, run, { phase: "setup" }); + + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + + it.each([ + { status: "timed_out" }, + { errorCode: "setup_failed" }, + { errorCode: "workspace_validation_failed" }, + { errorCode: "adapter_failed" }, + { executionStage: "executing" }, + { executionStage: null }, + { exitCode: 1 }, + { exitCode: 0 }, + { signal: "SIGTERM" }, + ])("reports missing-secret text without a proven pre-dispatch outcome: %j", async (overrides) => { + await seedCompanyAndAgent(); + + await reportRunFailure(db, missingSecretRun(overrides), { phase: "setup" }); + + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, { kind: "bootstrap" }, { kind: "bootstrap", providerWorkStarted: true }])( + "reports missing-secret failures without explicit bootstrap proof: %j", async (executionRecovery) => { + await seedCompanyAndAgent(); + const run = missingSecretRun(); + run.resultJson!.executionRecovery = executionRecovery; + + await reportRunFailure(db, run, { phase: "setup" }); + + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }, + ); + + it.each([undefined, "execute"] as const)("reports configuration failures outside setup: %s", async (phase) => { + await seedCompanyAndAgent(); + + await reportRunFailure(db, missingSecretRun(), { phase }); + + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + it("captures once for the status failed", async () => { await seedCompanyAndAgent(); const run = buildRun({ status: "failed" }); diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index 2ed65286119..9c341eeca6a 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -40,6 +40,50 @@ function readTaskId(run: HeartbeatRun): string | null { return typeof contextIssueId === "string" && contextIssueId.length > 0 ? contextIssueId : null; } +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +/** A known owner action before dispatch, not a secret-provider or runtime failure. */ +function isMissingSecretConfigurationBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + if ( + run.status !== "failed" || + run.errorCode !== "configuration_incomplete" || + run.executionStage !== "preparing" || + options.phase !== "setup" || + run.exitCode != null || + run.signal != null + ) return false; + + const configuration = asRecord(run.resultJson?.configurationIncomplete); + const recovery = asRecord(run.resultJson?.executionRecovery); + if ( + configuration?.reason !== "secret_binding_missing" || + recovery?.kind !== "bootstrap" || + recovery.providerWorkStarted !== false || + !Array.isArray(configuration.missingBindings) || + configuration.missingBindings.length === 0 + ) return false; + + return configuration.missingBindings.every((value: unknown) => { + const binding = asRecord(value); + if (!binding) return false; + if (binding.bindingType === "secret_ref") { + return binding.errorCode == null || binding.errorCode === "binding_missing"; + } + if (binding.bindingType !== "user_secret_ref") return false; + // Missing-definition resolution can also catch a database error. Keep that + // ambiguous case, unknown codes, and provider errors visible in Sentry. + return binding.errorCode === "binding_missing" || + binding.errorCode === "responsible_user_missing" || + binding.errorCode === "user_secret_missing" || + binding.errorCode === "secret_inactive" || + binding.errorCode === "user_secret_definition_inactive"; + }); +} + /** * Report a terminal run failure to Sentry. Returns at once for any status * other than failures and unexpected started cancellations. Never throws — a Sentry failure or a @@ -54,6 +98,7 @@ function readTaskId(run: HeartbeatRun): string | null { export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureReportOptions = {}): Promise { if (!isRunFailureStatus(run.status)) return Promise.resolve(); if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve(); + if (isMissingSecretConfigurationBlocker(run, options)) return Promise.resolve(); const runStatus = run.status; const report = captureTerminalRunFailure(db, run, runStatus, options); pendingRunFailureReports.add(report); From 128c95837b941bfe38f4f77a3d10776ed4c4f21b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:05:21 -0700 Subject: [PATCH 15/55] Retain bounded orphan process-loss diagnostics (#15475) Record bounded observer uptime, run and output ages, process-check observations and retry eligibility before orphan cleanup changes the evidence. Preserve existing recovery and reporting behavior and omit process IDs, raw paths and credentials. Verified focused helper, actual reaper and real SDK regressions, full typecheck/build, exact-head CI and independent review. An unchanged Cursor timeout passed its isolated retry; unrelated local Slack timing failure is documented separately. Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- doc/observability.md | 13 ++++ .../heartbeat-process-recovery.test.ts | 8 +++ .../run-failure-sentry-real-sdk.test.ts | 43 +++++++++++ .../process-loss-diagnostics.test.ts | 56 +++++++++++++++ .../__tests__/run-failure-diagnostics.test.ts | 13 ++++ server/src/services/heartbeat.ts | 11 ++- .../src/services/process-loss-diagnostics.ts | 71 +++++++++++++++++++ .../src/services/run-failure-diagnostics.ts | 7 ++ 8 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 server/src/services/__tests__/process-loss-diagnostics.test.ts create mode 100644 server/src/services/process-loss-diagnostics.ts diff --git a/doc/observability.md b/doc/observability.md index e8d1262a0bb..b9d19e6f212 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -524,6 +524,19 @@ and native runs: - `run_exception_0` through `run_exception_3`: exception names, codes, HTTP statuses, and request IDs for a caught exception and up to three causes. +The orphan reaper records a bounded `processLossDiagnostic` before status writes +or cleanup. For `process_lost` failures, `run_execution` includes +`processLossPidRecorded`, `processLossGroupRecorded`, and `processLossLocalCheck` +(`not_observed_alive`, `not_checked`, or `no_identifiers`). It also includes +`processLossRunPredatesObserver`, `processLossObserverUptimeMs`, and +`processLossLastOutputAgeMs` when the timestamps are available and valid. Ages +above seven days are omitted. These are observations, not proof of an OOM, +provider failure, or deployment. The last-output age can include system output. +`processLossRetryEligible` records eligibility for the existing process-loss retry +at detection; it does not claim that a retry was queued or succeeded. This data +contains no process IDs, task text, paths, or credentials and changes no recovery +or ownership decisions. + ACP turns record `acpLastEventAgeMs`, `acpObservedEventCount`, `acpPendingToolCount`, and `acpToolInventoryComplete` at finalization, before usage reads, error logging, and cleanup. The age measures time since the last diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 86b3867ae50..325ffbc0a9b 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -2717,6 +2717,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { stopReason: "process_lost", timeoutConfigured: false, timeoutFired: false, + processLossDiagnostic: { + pidRecorded: true, groupRecorded: false, localCheck: "not_observed_alive", retryEligible: true, + observerUptimeMs: expect.any(Number), runPredatesObserver: true, + }, }); // The legacy engine writes this terminal status through the same // guarded emitter that reports a genuine failed transition to Sentry. @@ -2726,6 +2730,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { runId, errorCode: "process_lost", runStatus: "failed", + diagnostics: expect.objectContaining({ execution: expect.objectContaining({ + processLossPidRecorded: true, processLossGroupRecorded: false, processLossLocalCheck: "not_observed_alive", + processLossRetryEligible: true, processLossRunPredatesObserver: true, + }) }), }), ); const [action] = await db diff --git a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts index 5cac0081e31..f92e0a42bc0 100644 --- a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts +++ b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts @@ -42,6 +42,49 @@ afterEach(async () => { }); describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => { + it("sends only bounded process-loss evidence without leaking it into another capture", async () => { + const Sentry = sentryPackage!; + const events: Array> = []; + vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1"); + vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) })); + vi.doMock("@sentry/node", () => ({ + ...Sentry, + init: (options: Record) => Sentry.init({ + ...options, + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event: Record) => { events.push(event); return event; }, + }), + })); + vi.resetModules(); + const { sentryReady, captureRunFailure, captureException } = await import("../sentry.js"); + await sentryReady; + const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({ + errorCode: "process_lost", resultJson: { processLossDiagnostic: { + pidRecorded: true, groupRecorded: false, localCheck: "not_observed_alive", retryEligible: true, + runPredatesObserver: true, observerUptimeMs: 30_000, lastOutputAgeMs: 120_000, + pid: "private-process-identity", path: "/private-process-path", prompt: "private-process-prompt", + } }, + } as unknown as typeof heartbeatRuns.$inferSelect, {})); + captureRunFailure({ + taskId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222", + errorMessage: "Process lost -- server may have restarted", errorCode: "process_lost", + agentAdapter: "fixture-adapter", runStatus: "failed", exitCode: null, signal: null, diagnostics, + }); + captureException(new Error("unrelated process-loss fixture")); + await Sentry.flush(2000); + expect(events).toHaveLength(2); + const failure = events.find(event => (event.tags as Record)?.error_code === "process_lost"); + expect(failure).toMatchObject({ contexts: { run_execution: { + processLossPidRecorded: true, processLossGroupRecorded: false, processLossLocalCheck: "not_observed_alive", + processLossRetryEligible: true, processLossRunPredatesObserver: true, + processLossObserverUptimeMs: 30_000, processLossLastOutputAgeMs: 120_000, + } } }); + expect(JSON.stringify(events)).not.toContain("private-process-"); + const unrelated = events.find(event => event !== failure); + expect(unrelated).not.toHaveProperty("contexts.run_execution"); + expect(unrelated).not.toHaveProperty("contexts.run_failure"); + }); + it("keeps portfolio diagnostics bounded and isolated from unrelated captures", async () => { const Sentry = sentryPackage!; const events: Array> = []; diff --git a/server/src/services/__tests__/process-loss-diagnostics.test.ts b/server/src/services/__tests__/process-loss-diagnostics.test.ts new file mode 100644 index 00000000000..f8efe72f582 --- /dev/null +++ b/server/src/services/__tests__/process-loss-diagnostics.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildProcessLossDiagnostic, readProcessLossDiagnostic } from "../process-loss-diagnostics.js"; + +const nowMs = Date.UTC(2026, 0, 1, 12); +const run = { + startedAt: new Date(nowMs - 10 * 60_000), lastOutputAt: new Date(nowMs - 2 * 60_000), + processPid: 123, processGroupId: null, +}; +const input = { run, nowMs, observerStartedAtMs: nowMs - 30_000, + checksPersistedChildLiveness: true, retryEligible: true }; + +describe("orphan process-loss evidence", () => { + it("distinguishes a run from the previous observer without inventing a restart cause", () => { + expect(buildProcessLossDiagnostic(input)).toEqual({ + pidRecorded: true, groupRecorded: false, localCheck: "not_observed_alive", retryEligible: true, + runPredatesObserver: true, lastOutputAgeMs: 120_000, observerUptimeMs: 30_000, + }); + const currentObserverRun = buildProcessLossDiagnostic({ ...input, observerStartedAtMs: nowMs - 20 * 60_000 }); + expect(currentObserverRun.runPredatesObserver).toBe(false); + expect(currentObserverRun).not.toHaveProperty("restartCause"); + }); + + it("does not describe missing or unchecked identifiers as confirmed child death", () => { + expect(buildProcessLossDiagnostic({ ...input, checksPersistedChildLiveness: false }).localCheck).toBe("not_checked"); + expect(buildProcessLossDiagnostic({ ...input, run: { ...run, processPid: null }, retryEligible: false })) + .toMatchObject({ pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers", retryEligible: false }); + expect(buildProcessLossDiagnostic({ ...input, run: { ...run, processPid: null, processGroupId: 456 } })) + .toMatchObject({ pidRecorded: false, groupRecorded: true, localCheck: "not_observed_alive" }); + }); + + it("omits unavailable, future and unbounded ages", () => { + for (const lastOutputAt of [null, new Date(NaN), new Date(nowMs + 1), new Date(nowMs - 604_800_001)]) { + expect(buildProcessLossDiagnostic({ ...input, run: { ...run, lastOutputAt } })).not.toHaveProperty("lastOutputAgeMs"); + } + for (const observerStartedAtMs of [NaN, Infinity, nowMs + 1]) { + const diagnostic = buildProcessLossDiagnostic({ ...input, observerStartedAtMs }); + expect(diagnostic).not.toHaveProperty("observerUptimeMs"); + expect(diagnostic).not.toHaveProperty("runPredatesObserver"); + } + expect(buildProcessLossDiagnostic({ ...input, run: { ...run, startedAt: new Date(nowMs + 1) } })) + .not.toHaveProperty("runPredatesObserver"); + expect(buildProcessLossDiagnostic({ ...input, observerStartedAtMs: nowMs - 1.5 }).observerUptimeMs).toBe(2); + }); + + it("reads only bounded scalar evidence, including hostile persisted input", () => { + const diagnostic = buildProcessLossDiagnostic(input); + expect(readProcessLossDiagnostic({ ...diagnostic, pid: 123, prompt: "private-prompt", path: "/private/path" })) + .toEqual(diagnostic); + for (const value of [null, [], "private", { localCheck: "private", pidRecorded: 1, + groupRecorded: "false", retryEligible: "private", runPredatesObserver: null, + observerUptimeMs: 604_800_001, lastOutputAgeMs: -1 }, + Object.defineProperty({}, "localCheck", { get() { throw new Error("private"); } })]) { + expect(readProcessLossDiagnostic(value)).toEqual({}); + } + }); +}); diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 6ee721b7a37..0cc4d104b7f 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -7,6 +7,19 @@ const run = (overrides: Partial = {}) => ({ resultJson: null, ...overrides const collect = (error: unknown) => collectRunFailureDiagnostics(run(), { error }); describe("run failure diagnostics", () => { + it("exports bounded orphan evidence only for a process-loss failure", () => { + const processLossDiagnostic = { pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers", + retryEligible: false, runPredatesObserver: true, observerUptimeMs: 30_000, lastOutputAgeMs: 120_000, + pid: 123, path: "/private-path", prompt: "private-prompt" }; + const resultJson = { processLossDiagnostic }; + expect(collectRunFailureDiagnostics(run({ errorCode: "process_lost", resultJson }), {}).execution).toEqual({ + processLossPidRecorded: false, processLossGroupRecorded: false, processLossLocalCheck: "no_identifiers", + processLossRetryEligible: false, processLossRunPredatesObserver: true, + processLossObserverUptimeMs: 30_000, processLossLastOutputAgeMs: 120_000, + }); + expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed", resultJson }), {}).execution).toEqual({}); + }); + it("exports only the closed native model/auth rejection vocabulary", () => { const diagnostic = { provider: "codex", category: "model_auth_incompatible", status: 400, authMode: "chatgpt" }; const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 22895243838..b475c28fd52 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -351,6 +351,8 @@ import { emitAgentTaskRunById, } from "./agent-task-run-telemetry.js"; import { reportRunFailure } from "./run-failure-report.js"; +import { performance } from "node:perf_hooks"; +import { buildProcessLossDiagnostic } from "./process-loss-diagnostics.js"; import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js"; import { companySkillService } from "./company-skills.js"; import { budgetService, withCurrentBudgetEnforcement, type BudgetEnforcementScope } from "./budgets.js"; @@ -19617,6 +19619,13 @@ export function heartbeatService( monitorDispatchLostWithoutFutureWake); if (!(await revokeExpiredLegacyController(db, run))) continue; const baseMessage = buildProcessLossMessage(run); + const processLossDiagnostic = buildProcessLossDiagnostic({ + run, + nowMs: Date.now(), + observerStartedAtMs: performance.timeOrigin, + checksPersistedChildLiveness, + retryEligible: shouldRetry, + }); const conversationContinuationEligible = await runUsedConversationAdapter(db, run); const failureWrite = await setRunStatusFromLive( @@ -19633,7 +19642,7 @@ export function heartbeatService( "failed", { conversationContinuationEligible, - resultJson: parseObject(run.resultJson), + resultJson: { ...parseObject(run.resultJson), processLossDiagnostic }, errorCode: "process_lost", errorMessage: shouldRetry ? `${baseMessage}; retrying once` diff --git a/server/src/services/process-loss-diagnostics.ts b/server/src/services/process-loss-diagnostics.ts new file mode 100644 index 00000000000..408c89d0970 --- /dev/null +++ b/server/src/services/process-loss-diagnostics.ts @@ -0,0 +1,71 @@ +import type { heartbeatRuns } from "@paperclipai/db"; + +type Run = Pick; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000; +const LOCAL_CHECKS = ["not_observed_alive", "not_checked", "no_identifiers"] as const; + +export interface ProcessLossDiagnostic { + pidRecorded: boolean; + groupRecorded: boolean; + localCheck: typeof LOCAL_CHECKS[number]; + retryEligible: boolean; + runPredatesObserver?: boolean; + lastOutputAgeMs?: number; + observerUptimeMs?: number; +} + +function age(now: number, then: number | undefined): number | undefined { + if (then === undefined) return undefined; + const elapsed = now - then; + return Number.isSafeInteger(elapsed) && elapsed >= 0 && elapsed <= MAX_AGE_MS + ? elapsed : undefined; +} + +/** Snapshot before status writes and cleanup. Never infer why ownership was lost. */ +export function buildProcessLossDiagnostic(input: { + run: Run; + nowMs: number; + observerStartedAtMs: number; + checksPersistedChildLiveness: boolean; + retryEligible: boolean; +}): ProcessLossDiagnostic { + const { run, nowMs, observerStartedAtMs } = input; + const pidRecorded = !!run.processPid; + const groupRecorded = !!run.processGroupId; + const startedAt = run.startedAt?.getTime(); + const validObserverStart = Number.isFinite(observerStartedAtMs) && observerStartedAtMs <= nowMs; + const lastOutputAgeMs = age(nowMs, run.lastOutputAt?.getTime()); + const observerUptimeMs = validObserverStart ? age(nowMs, Math.floor(observerStartedAtMs)) : undefined; + return { + pidRecorded, + groupRecorded, + localCheck: !pidRecorded && !groupRecorded ? "no_identifiers" + : input.checksPersistedChildLiveness ? "not_observed_alive" : "not_checked", + retryEligible: input.retryEligible, + ...(validObserverStart && startedAt !== undefined && Number.isFinite(startedAt) && startedAt <= nowMs + ? { runPredatesObserver: startedAt < observerStartedAtMs } : {}), + ...(lastOutputAgeMs !== undefined ? { lastOutputAgeMs } : {}), + ...(observerUptimeMs !== undefined ? { observerUptimeMs } : {}), + }; +} + +/** Persisted adapter data is untrusted. Only closed labels, booleans and ages leave the instance. */ +export function readProcessLossDiagnostic(value: unknown): Partial { + const result: Partial = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return result; + const read = (key: string): unknown => { + try { return (value as Record)[key]; } catch { return undefined; } + }; + for (const key of ["pidRecorded", "groupRecorded", "retryEligible", "runPredatesObserver"] as const) { + const entry = read(key); + if (typeof entry === "boolean") result[key] = entry; + } + const localCheck = read("localCheck"); + if (LOCAL_CHECKS.some(check => check === localCheck)) result.localCheck = localCheck as ProcessLossDiagnostic["localCheck"]; + for (const key of ["lastOutputAgeMs", "observerUptimeMs"] as const) { + const entry = read(key); + if (typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0 && entry <= MAX_AGE_MS) result[key] = entry; + } + return result; +} diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index c5c9d7e820b..8dc0d78a289 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -1,5 +1,6 @@ import type { heartbeatRuns } from "@paperclipai/db"; import { readRunCancellation } from "./run-cancellation.js"; +import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js"; import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction"; import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; @@ -146,6 +147,12 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport if (Number.isFinite(durationMs) && durationMs >= 0) execution.durationMs = durationMs; } const result = run.resultJson; + if (run.errorCode === "process_lost") { + const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic")); + for (const [field, value] of Object.entries(diagnostic)) { + execution[`processLoss${field[0]!.toUpperCase()}${field.slice(1)}`] = value; + } + } const cancellation = readRunCancellation(result); if (cancellation) { execution.cancellationSource = cancellation.source; From 465140596fcca09026cc248e5709b1efa1924f04 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:22:53 -0700 Subject: [PATCH 16/55] Preserve bounded workspace sync diagnostics across RPC (#15481) Preserve bounded error codes and HTTP/exit statuses across the environmentSyncOut worker RPC boundary, and revalidate that method-scoped envelope before attaching host restore diagnostics. Keep the original error and all recovery policy unchanged; do not transmit provider payloads or credentials. Verified real RPC roundtrip and privacy regressions, 95 focused tests, 68 independent tests, full typecheck/build and all exact-head CI. Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- doc/observability.md | 7 ++ doc/run-log-events.md | 6 ++ .../plugins/sdk/src/environment-sync-error.ts | 58 ++++++++++++++ packages/plugins/sdk/src/index.ts | 2 + packages/plugins/sdk/src/worker-rpc-host.ts | 4 +- .../sdk/tests/environment-sync-error.test.ts | 48 ++++++++++++ .../environment-sync-out-error.test.ts | 78 +++++++++++++++++++ server/src/services/environment-runtime.ts | 5 +- .../services/environment-sync-out-error.ts | 15 ++++ 9 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/sdk/src/environment-sync-error.ts create mode 100644 packages/plugins/sdk/tests/environment-sync-error.test.ts create mode 100644 server/src/__tests__/environment-sync-out-error.test.ts create mode 100644 server/src/services/environment-sync-out-error.ts diff --git a/doc/observability.md b/doc/observability.md index b9d19e6f212..c09b7219296 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -576,6 +576,13 @@ integers from 1 through 255. These fields accompany a known restore failure code only. They omit error messages, raw command lines, paths, process output, and arbitrary cause data. Git error wrappers preserve only these safe codes and numbers for diagnostics, without adding the original error as a cause. +Native sandbox `environmentSyncOut` failures carry the same allowlisted codes +and bounded HTTP/exit statuses across the plugin worker RPC boundary. The host +revalidates that optional envelope and retains it only for restore diagnostics. +The envelope excludes provider messages, response bodies, names, paths, and +credentials. The existing RPC error message and code remain unchanged, as do +restore classification, retries, and source retention. Older workers without the +envelope still report `unknown` when no structured cause is available. For `git_integration`, optional `workspaceRestoreGitCommand` identifies the fixed command family: `rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`, `commit_tree`, `update_ref`, or `log`. `workspaceRestoreGitFailureKind` is diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 8941314f274..10557d8b6bb 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -343,6 +343,12 @@ the enclosing workspace task. The original error and restore safety policy are unchanged. These lines stay in the instance run log and its configured durable storage, and are not new first-party telemetry events. +Native sandbox `environmentSyncOut` errors preserve allowlisted error codes and +bounded HTTP/exit statuses across worker RPC for this diagnostic line. The host +revalidates the envelope and keeps the original failure and recovery policy. +Provider messages, paths, response bodies, credentials, and arbitrary error data +are not copied into the new envelope. Older workers can still report `unknown`. + The optional `step` identifies the failed restore operation. For `phase=workspace` and `step=git_integration`, `gitCommand` identifies one fixed command family (`rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`, diff --git a/packages/plugins/sdk/src/environment-sync-error.ts b/packages/plugins/sdk/src/environment-sync-error.ts new file mode 100644 index 00000000000..0450310a07a --- /dev/null +++ b/packages/plugins/sdk/src/environment-sync-error.ts @@ -0,0 +1,58 @@ +/** Diagnostic evidence only. Never use this envelope to authorize cleanup or retry. */ +export interface PluginEnvironmentSyncErrorDiagnostic { + errorCode: string; + httpStatus?: number; + exitCode?: number; +} + +const SCHEMA = "paperclip/environment-sync-error/v1"; +// Match the host's workspace restore diagnostic allowlist. Both sides validate +// independently: plugin-supplied data must never widen the host's log surface. +const ERROR_CODES = new Set([ + "ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EXDEV", "ENOTDIR", "EISDIR", + "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENOTFOUND", "EAI_AGAIN", + "ABORT_ERR", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", +]); + +function field(value: unknown, key: string): unknown { + try { return value && typeof value === "object" ? (value as Record)[key] : undefined; } + catch { return undefined; } +} + +function integer(value: unknown, minimum: number, maximum: number): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum + ? value : undefined; +} + +function sanitize(value: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined { + const code = field(value, "errorCode"); + const errorCode = typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown"; + const httpStatus = integer(field(value, "httpStatus"), 400, 599); + const exitCode = integer(field(value, "exitCode"), 1, 255); + if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined) return undefined; + return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}) }; +} + +/** Capture known codes and numbers, never arbitrary error data, text, or names. */ +export function environmentSyncErrorData(error: unknown): unknown { + const diagnostic: PluginEnvironmentSyncErrorDiagnostic = { errorCode: "unknown" }; + let current = error; + // Bound nested SDK causes, including cyclic errors and throwing getters. + for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) { + const code = field(current, "code"); + if (diagnostic.errorCode === "unknown" && typeof code === "string" && ERROR_CODES.has(code)) diagnostic.errorCode = code; + diagnostic.httpStatus ??= integer(field(current, "status"), 400, 599) + ?? integer(field(current, "statusCode"), 400, 599) + ?? integer(field(field(current, "response"), "status"), 400, 599); + diagnostic.exitCode ??= integer(field(current, "exitCode"), 1, 255) ?? integer(code, 1, 255); + current = field(current, "cause"); + } + const safe = sanitize(diagnostic); + return safe ? { schema: SCHEMA, diagnostic: safe } : undefined; +} + +/** Revalidate the worker envelope before adapting it into host-only diagnostics. */ +export function readEnvironmentSyncErrorDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined { + const data = field(error, "data"); + return field(data, "schema") === SCHEMA ? sanitize(field(data, "diagnostic")) : undefined; +} diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index b834be1a600..7610aaa9a1d 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -452,5 +452,7 @@ export { export { PluginEnvironmentCreationCleanupError, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "./environment-creation-cleanup.js"; export type { PluginEnvironmentCreationCleanup } from "./environment-creation-cleanup.js"; +export { readEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js"; +export type { PluginEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js"; export type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, AiConnectionRouterSelection } from "@paperclipai/shared"; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 6684f8fdd18..786e71954fb 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -1,5 +1,6 @@ import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared"; import { environmentCreationCleanupErrorData } from "./environment-creation-cleanup.js"; +import { environmentSyncErrorData } from "./environment-sync-error.js"; /** * Worker-side RPC host — runs inside the child process spawned by the host. * @@ -1581,7 +1582,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost sendMessage(createErrorResponse(id, errorCode, errorMessage, method === "environmentAcquireLease" || method === "environmentDestroyLease" - ? environmentCreationCleanupErrorData(err) : undefined)); + ? environmentCreationCleanupErrorData(err) + : method === "environmentSyncOut" ? environmentSyncErrorData(err) : undefined)); } } diff --git a/packages/plugins/sdk/tests/environment-sync-error.test.ts b/packages/plugins/sdk/tests/environment-sync-error.test.ts new file mode 100644 index 00000000000..26d98054936 --- /dev/null +++ b/packages/plugins/sdk/tests/environment-sync-error.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic } from "../src/environment-sync-error.js"; + +const schema = "paperclip/environment-sync-error/v1"; + +describe("environment sync diagnostic envelope", () => { + it("keeps only known codes and bounded numbers from nested provider errors", () => { + const error = Object.assign(new Error("private-message"), { + name: "private-name", code: "private-code", path: "/private-path", + response: { status: 503, data: { token: "private-token" } }, + data: { credentials: "private-credentials" }, + cause: { code: "ECONNRESET", exitCode: 7, stderr: "private-output" }, + }); + const data = environmentSyncErrorData(error); + expect(data).toEqual({ schema, diagnostic: { errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 } }); + expect(JSON.stringify(data)).not.toContain("private-"); + expect(readEnvironmentSyncErrorDiagnostic({ data })).toEqual({ errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 }); + }); + + it.each([undefined, null, "private-error", { code: "PRIVATE_TOKEN" }, { status: 600 }, { status: 399 }, + { statusCode: "503" }, { status: 500.5 }, { exitCode: 0 }, { exitCode: 256 }, { code: -32001 }, + { exitCode: Infinity }, { data: { schema, diagnostic: { errorCode: "EACCES" } } }, + ])("omits unrecognized or unbounded provider values: %j", (error) => { + expect(environmentSyncErrorData(error)).toBeUndefined(); + }); + + it("bounds cyclic causes and ignores throwing getters without changing the error", () => { + const error = Object.defineProperties({ statusCode: 429 } as Record, { + code: { get() { throw new Error("private-code"); } }, + response: { get() { throw new Error("private-response"); } }, + }); + error.cause = error; + expect(environmentSyncErrorData(error)).toEqual({ schema, diagnostic: { errorCode: "unknown", httpStatus: 429 } }); + const wrap = (cause: unknown) => ({ cause }); + expect(environmentSyncErrorData(wrap(wrap(wrap({ code: "EIO" }))))).toEqual({ schema, diagnostic: { errorCode: "EIO" } }); + expect(environmentSyncErrorData(wrap(wrap(wrap(wrap({ code: "EIO" })))))).toBeUndefined(); + }); + + it("revalidates worker fields and ignores arbitrary payload and protocol versions", () => { + expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: { + errorCode: "EACCES", httpStatus: 403, exitCode: 1.5, + message: "private-message", path: "/private-path", credentials: "private-token", + } } })).toEqual({ errorCode: "EACCES", httpStatus: 403 }); + expect(readEnvironmentSyncErrorDiagnostic({ data: { schema: "other", diagnostic: { errorCode: "EACCES" } } })).toBeUndefined(); + expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: { errorCode: "private-token", httpStatus: 900 } } })).toBeUndefined(); + expect(readEnvironmentSyncErrorDiagnostic(Object.defineProperty({}, "data", { get() { throw new Error("private"); } }))).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/environment-sync-out-error.test.ts b/server/src/__tests__/environment-sync-out-error.test.ts new file mode 100644 index 00000000000..36407d2453d --- /dev/null +++ b/server/src/__tests__/environment-sync-out-error.test.ts @@ -0,0 +1,78 @@ +import { createInterface } from "node:readline"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + createRequest, definePlugin, isJsonRpcResponse, JsonRpcCallError, parseMessage, + PLUGIN_RPC_ERROR_CODES, serializeMessage, startWorkerRpcHost, type JsonRpcResponse, +} from "@paperclipai/plugin-sdk"; +import { + getWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep, +} from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; +import { preserveEnvironmentSyncOutErrorDiagnostic } from "../services/environment-sync-out-error.js"; + +async function roundTrip(method: string, error: unknown): Promise<{ error: JsonRpcCallError; wire: string }> { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const lines = createInterface({ input: stdout }); + const worker = startWorkerRpcHost({ stdin, stdout, plugin: definePlugin({ + async setup() {}, + async onEnvironmentSyncOut() { throw error; }, + async onEnvironmentSyncIn() { throw error; }, + async onEnvironmentExecute() { throw error; }, + }) }); + try { + const response = new Promise<{ message: JsonRpcResponse; wire: string }>((resolve) => { + lines.on("line", (wire) => { + const message = parseMessage(wire); + if (isJsonRpcResponse(message)) resolve({ message, wire }); + }); + }); + stdin.write(serializeMessage(createRequest(method, {}, "sync-out-test"))); + const { message, wire } = await response; + if (!("error" in message) || !message.error) throw new Error("Expected RPC failure"); + return { error: new JsonRpcCallError(message.error), wire }; + } finally { + worker.stop(); lines.close(); stdin.destroy(); stdout.destroy(); + } +} + +describe("environment sync-out restore diagnostics", () => { + it("retains bounded evidence through the real worker RPC roundtrip and host restore classifier", async () => { + const failure = Object.assign(new Error("Workspace export failed"), { + name: "private-provider-class", code: "ETIMEDOUT", statusCode: 504, + response: { data: { apiKey: "private-token" } }, path: "/private-path", + cause: new Error("private-cause"), data: { credentials: "private-credentials" }, + }); + const { error, wire } = await roundTrip("environmentSyncOut", failure); + expect(wire).not.toContain("private-"); + expect(wire.length).toBeLessThan(400); + expect(error.code).toBe(PLUGIN_RPC_ERROR_CODES.WORKER_ERROR); + expect(error.message).toBe(failure.message); + const before = structuredClone({ message: error.message, code: error.code, data: error.data }); + const logs: string[] = []; + await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { + throw preserveEnvironmentSyncOutErrorDiagnostic(error); + }), async (line) => { logs.push(line); })).rejects.toBe(error); + expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504 }); + expect(logs).toEqual(['[paperclip] Workspace restore diagnostic: {"phase":"workspace","step":"workspace_transfer","errorCode":"ETIMEDOUT","httpStatus":504}\n']); + expect({ message: error.message, code: error.code, data: error.data }).toEqual(before); + expect(error).not.toHaveProperty("cause"); + }); + + it.each(["environmentSyncIn", "environmentExecute"])("does not add the sync-out envelope to %s", async (method) => { + const { error } = await roundTrip(method, Object.assign(new Error("Provider failure"), { code: "EACCES", status: 503 })); + expect(error.data).toBeUndefined(); + expect(preserveEnvironmentSyncOutErrorDiagnostic(error)).toBe(error); + expect(getWorkspaceRestoreDiagnostic(error)).toBeUndefined(); + }); + + it("keeps old workers and unknown diagnostics compatible without changing policy fields", async () => { + const { error } = await roundTrip("environmentSyncOut", new Error("daytona_sandbox_not_found")); + expect(error.data).toBeUndefined(); + expect(preserveEnvironmentSyncOutErrorDiagnostic(error)).toBe(error); + expect(error.message).toBe("daytona_sandbox_not_found"); + const original = Object.freeze(new JsonRpcCallError({ code: -32001, message: "Existing worker failure" })); + expect(preserveEnvironmentSyncOutErrorDiagnostic(original)).toBe(original); + expect(preserveEnvironmentSyncOutErrorDiagnostic(null)).toBeNull(); + }); +}); diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 4105ec6642d..07ba10ebe4b 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1,5 +1,6 @@ import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js"; import { readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk"; +import { preserveEnvironmentSyncOutErrorDiagnostic } from "./environment-sync-out-error.js"; import { remoteTerminationReceipt } from "./remote-execution-termination.js"; import { hasNativeWorkspaceExportResume, releaseCompletedNativeWorkspaceExportRetention } from "./native-runtime/native-workspace-export-resume.js"; import { createHash, randomUUID } from "node:crypto"; @@ -1812,7 +1813,9 @@ function createSandboxEnvironmentDriver( expiresAt: input.lease.expiresAt?.toISOString() ?? null, }, operations: input.operations, - }, resolvePluginSandboxRpcTimeoutMs(sanitizedConfig)); + }, resolvePluginSandboxRpcTimeoutMs(sanitizedConfig)).catch((error: unknown) => { + throw method === "environmentSyncOut" ? preserveEnvironmentSyncOutErrorDiagnostic(error) : error; + }); } return { diff --git a/server/src/services/environment-sync-out-error.ts b/server/src/services/environment-sync-out-error.ts new file mode 100644 index 00000000000..f9574f696a7 --- /dev/null +++ b/server/src/services/environment-sync-out-error.ts @@ -0,0 +1,15 @@ +import { readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk"; +import { preserveWorkspaceRestoreErrorDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; + +/** Keep the original RPC failure and policy fields; add only private diagnostic evidence. */ +export function preserveEnvironmentSyncOutErrorDiagnostic(error: unknown): unknown { + const diagnostic = readEnvironmentSyncErrorDiagnostic(error); + if (diagnostic && error && typeof error === "object") { + preserveWorkspaceRestoreErrorDiagnostic(error, { + code: diagnostic.errorCode, + status: diagnostic.httpStatus, + exitCode: diagnostic.exitCode, + }); + } + return error; +} From 572cec0344e8dfa88ef2dfc0a6934883ff56dc45 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:34:12 -0700 Subject: [PATCH 17/55] fix(ui): present missing costs as an informational notice (#15459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Costs dashboard shows spending and gaps in cost data. > - Historical usage can have token counts without a recorded price. > - The red notice implies that the user must fix those records. > - This pull request shows the missing costs as a neutral note below the totals. > - Users can see the limit of the totals without a false call to action. ## Linked Issues or Issue Description Refs #14997. **What happened?** The Costs dashboard shows a red notice when a selected period contains usage without prices. It also shows “0 runs await accounting” when no runs are pending. Historical pricing gaps can therefore look like current errors that need cleanup. **Expected behavior** Show a neutral explanation below the totals. State that totals include only known costs. Show pending runs separately and only when the count is positive. **Steps to reproduce** 1. Open Costs for a period with unpriced usage and no pending runs. 2. Observe the red notice above the totals. 3. Apply this change. Confirm that the notice is muted and below the totals, with no zero-count pending message. **Paperclip version or commit** Reproduced on master after #14997. **Deployment mode** Board UI in both the embedded Activity page and the standalone Costs page. ## What Changed - Move the cost-data notice below the summary tiles and use the muted text token. - Explain unavailable prices with “Totals include known costs only.” - Separate pending runs from missing prices. Omit zero counts and use singular or plural copy as needed. - Update the existing UI tests for both page variants, including missing-only, pending-only, and fully accounted states after refresh. ## Verification - `pnpm --dir ui exec vitest run src/pages/Costs.test.tsx`: 21 tests passed on the final commit. Both page variants cover mixed, missing-only, pending-only, and fully accounted states. - `pnpm check:token-gates`: passed on the final commit. - `pnpm build`: passed locally. - `pnpm -r typecheck`: passed locally. - Full Linux CI passed on `0c23544c0f17e6641cc0c8de6ed498a731748bde`, including browser tests, server tests, shared-package tests, build, and typecheck. [CI run](https://github.com/paperclipai/paperclip/actions/runs/37661883464). - Greptile Apex: 5/5 on the final commit, with no unresolved review threads. - Local full-suite limit: `pnpm test:run` first failed because the fresh checkout lacked embedded PostgreSQL library links. A runtime-skill fixture also selected an unrelated parent directory, and a load test timed out. After setup repair, the database suite (70 tests), skill suite (3 tests), email suite (39 tests), and load suite (4 tests) passed individually. The subsequent full local rerun was stopped after the complete Linux CI run passed. This is not a claim that a full local test invocation passed. ## Risks Low risk. This changes presentation only. The note remains visible and retains its accessible status role. Cost calculations, stored records, and budget enforcement do not change. The copy does not assume that all missing prices are historical. No schema migration or operator documentation change is needed. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository inspection, code editing, and test execution. The exact served model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/pages/Costs.test.tsx | 21 +++++++++++++++++++-- ui/src/pages/Costs.tsx | 19 ++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/Costs.test.tsx b/ui/src/pages/Costs.test.tsx index d7f21fd8dae..ac21351ad3d 100644 --- a/ui/src/pages/Costs.test.tsx +++ b/ui/src/pages/Costs.test.tsx @@ -241,7 +241,7 @@ describe("Shared Costs surfaces", () => { } finally { queryClient.clear(); vi.useRealTimers(); } }); - it.each(surfaces)("shows incomplete accounting and currency boundaries on the %s page", async (_name, props) => { + it.each(surfaces)("explains missing costs and pending runs neutrally on the %s page", async (_name, props) => { for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]); costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] }); costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 2, pendingRunCount: 1 }); @@ -254,9 +254,26 @@ describe("Shared Costs surfaces", () => { root.render(); }); await act(async () => { - await vi.waitFor(() => expect(container.textContent).toContain("Spend is incomplete: 2 usage events have no reliable price; 1 runs await accounting.")); + await vi.waitFor(() => expect(container.textContent).toContain("Costs are unavailable for 2 usage entries in this period. Totals include known costs only.")); }); + const notice = [...container.querySelectorAll('[role="status"]')].find(element => element.textContent?.includes("Costs are unavailable"))!; + expect(notice.classList.contains("text-muted-foreground")).toBe(true); + expect(notice.querySelector(".text-destructive")).toBeNull(); + expect(notice.classList.contains("text-destructive")).toBe(false); + expect(notice.textContent).toContain("1 run is awaiting cost data."); + expect(notice.previousElementSibling?.textContent).toContain("Inference spend"); expect(container.textContent).toContain("Finance headline totals are USD only"); + costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 10, pendingRunCount: 0 }); + await act(async () => { await queryClient.invalidateQueries(); }); + await vi.waitFor(() => expect(notice.textContent).toContain("Costs are unavailable for 10 usage entries")); + expect(notice.textContent).not.toContain("awaiting cost data"); + expect(notice.textContent).not.toContain("0 runs"); + costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 0, pendingRunCount: 1 }); + await act(async () => { await queryClient.invalidateQueries(); }); + await vi.waitFor(() => expect(notice.textContent).toBe("1 run is awaiting cost data.")); + costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: true, unpricedEventCount: 0, pendingRunCount: 0 }); + await act(async () => { await queryClient.invalidateQueries(); }); + await vi.waitFor(() => expect(container.contains(notice)).toBe(false)); }); it.each(surfaces)("labels each agent and expanded model independently on the %s page", async (_name, props) => { diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 3f951d0d2d0..56330c26f84 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -622,11 +622,6 @@ export function Costs({
    ) : null} - {spendData?.summary.pricingComplete === false && ( -

    - Spend is incomplete: {spendData.summary.unpricedEventCount} usage events have no reliable price; {spendData.summary.pendingRunCount} runs await accounting. Known spend is shown below. -

    - )} {financeData?.summary.currencies?.some((row) => row.currency !== "USD") && (

    Finance headline totals are USD only. Other currencies are listed separately; no exchange-rate conversion is applied. @@ -673,6 +668,20 @@ export function Costs({ icon={ArrowUpRight} />

    + {spendData?.summary.pricingComplete === false && ( +
    + {spendData.summary.unpricedEventCount > 0 && ( +

    + Costs are unavailable for {spendData.summary.unpricedEventCount} usage {spendData.summary.unpricedEventCount === 1 ? "entry" : "entries"} in this period. Totals include known costs only. +

    + )} + {spendData.summary.pendingRunCount > 0 && ( +

    + {spendData.summary.pendingRunCount} {spendData.summary.pendingRunCount === 1 ? "run is" : "runs are"} awaiting cost data. +

    + )} +
    + )}
    ) : null} From 0514e8abd58e0f35899f7f47ffba6747f44fee7f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:38:06 -0700 Subject: [PATCH 18/55] Keep Cursor managed-runtime test installation offline (#15484) Intercept the Cursor installer in its local shell fixture and guard against accidental curl downloads. Preserve real managed-home archive operations and the default timeout; change no production code. Verified 46 related tests, safe negative mutation, independent review and all exact-head CI. Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- .../cursor-local/src/server/execute.test.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/adapters/cursor-local/src/server/execute.test.ts b/packages/adapters/cursor-local/src/server/execute.test.ts index 866cdd952e0..61f5a05dd3f 100644 --- a/packages/adapters/cursor-local/src/server/execute.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.test.ts @@ -291,6 +291,15 @@ exit 7 const remoteWorkspace = path.join(rootDir, "remote-workspace"); const systemHomeDir = path.join(rootDir, "system-home"); const managedCaptureDir = path.join(rootDir, "managed-capture"); + const fixtureBinDir = path.join(rootDir, "fixture-bin"); + const networkAttemptPath = path.join(rootDir, "network-attempted"); + await fs.mkdir(fixtureBinDir, { recursive: true }); + // A regression in installer interception must fail locally, not download + // and execute the real CLI or depend on an external server's latency. + await fs.writeFile(path.join(fixtureBinDir, "curl"), `#!/bin/sh +: > "$FIXTURE_CURL_ATTEMPT_PATH" +exit 97 +`, { mode: 0o755 }); await fs.mkdir(managedCaptureDir, { recursive: true }); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(remoteWorkspace, { recursive: true }); @@ -337,6 +346,7 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio const runnerState = { commands: [] as string[], + installCommands: [] as string[], }; // The managed-runtime restore path probes the generated archive with // `wc -c` before reading bounded `dd | base64` chunks. Keep this fixture's @@ -345,11 +355,23 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio const runner = { execute: async (input: { command: string; args?: string[]; env?: Record; stdin?: string }) => { runnerState.commands.push(input.command); + const args = [...(input.args ?? [])]; + if (args[1] === SANDBOX_INSTALL_COMMAND) { + runnerState.installCommands.push(args[1]); + args[1] = buildInstallSimulationCommand( + path.join(systemHomeDir, ".local", "bin", "agent"), + managedCaptureDir, + ); + } // Exercise actual bounded file reads during managed-home restoration; // reporting empty success for every shell command hides missing bytes. - return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, input.args ?? [], { + return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, args, { cwd: remoteWorkspace, - env: { ...input.env, PATH: `${input.env?.PATH ?? ""}:/usr/bin:/bin` }, + env: { + ...input.env, + PATH: `${fixtureBinDir}:${input.env?.PATH ?? ""}:/usr/bin:/bin`, + FIXTURE_CURL_ATTEMPT_PATH: networkAttemptPath, + }, stdin: input.stdin, timeoutSec: 30, graceSec: 5, @@ -402,7 +424,11 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio }); expect(result.exitCode).toBe(0); + await expect(fs.stat(networkAttemptPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(runnerState.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]); expect(prepareInputs).toHaveLength(2); + expect(prepareInputs[1].env.HOME).toBeTruthy(); + expect(prepareInputs[1].env.HOME).not.toBe(systemHomeDir); expect(finalPreparedCommand).not.toBeNull(); expect(finalPreparedCommand).toMatch(/\.local\/(bin|sbin)\/agent$/); const resolvedCommand = runMeta.find(Boolean)?.command as string | undefined; From 5405b1f46fe2e00eac0d8207615bb1b20fcf5342 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:38:12 -0700 Subject: [PATCH 19/55] Synchronize chat delivery tests with completed drains (#15480) Expose the already tracked and caught deferred-work promise to test schedulers while preserving default setImmediate behavior. Assert Slack delivery ordering while the primary lease is held, then join the drains; update the existing GitHub test to assert the resulting quiescent queue. Verified all 1,063 chat tests, full typecheck/build, negative synchronization control, independent review and exact-head CI. An unchanged browser shard retry passed and is documented. Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- .../chat-channels.integration.test.ts | 114 +++++++++--------- server/src/services/chat-channels.ts | 5 +- 2 files changed, 58 insertions(+), 61 deletions(-) diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 5c479f76c70..da71ea61520 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -16000,7 +16000,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { it("reorders rapid Slack callbacks by provider time before one conversation drain", async () => { const fixture = await seedCompany(); const runtime = new FakeChatSdkRuntime(); - const deferred: Array<() => void> = []; + const deferred: Array<() => void | Promise> = []; + let releaseFirstDelivery!: () => void; + let signalFirstDelivery!: () => void; + const firstDeliveryEntered = new Promise((resolve) => { signalFirstDelivery = resolve; }); + const firstDeliveryReleased = new Promise((resolve) => { releaseFirstDelivery = resolve; }); + let firstDelivery = true; const wakeup = vi.fn(async () => ({ accepted: true })); const service = chatChannelService(db, { deferWebhookProcessing: true, @@ -16009,6 +16014,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { publicBaseUrl: "https://paperclip.example", runtime: runtime as unknown as ChatSdkRuntime, scheduleDeferredWork: (task) => deferred.push(task), + reachAuthorizationBarrier: async () => { + if (!firstDelivery) return; + firstDelivery = false; + signalFirstDelivery(); + await firstDeliveryReleased; + }, }); const endpoint = await service.create( fixture.companyId, @@ -16127,69 +16138,54 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { publicBaseUrl: "https://paperclip.example", runtime: new FakeChatSdkRuntime() as unknown as ChatSdkRuntime, }); - deferred.shift()?.(); - // Simulate another server process reconciling the same durable rows at - // the same time as the webhook process's deferred drain. - await competingService.processPendingDeliveries(); - await vi.waitFor(async () => { - const rows = await db + let drainSettled = false; + const draining = Promise.resolve(deferred.shift()?.()).then(() => { drainSettled = true; }); + try { + // Hold the deferred owner after it acquires the conversation lease. The + // competing sweep must finish without overtaking that owner. + await firstDeliveryEntered; + await competingService.processPendingDeliveries(); + expect(drainSettled).toBe(false); + expect(wakeup.mock.calls.filter((call) => call[0] === fixture.assignedAgentId)).toHaveLength(0); + releaseFirstDelivery(); + await draining; + expect(drainSettled).toBe(true); + const conversations = await db .select() .from(chatConversations) .where(eq(chatConversations.endpointId, endpoint.id)); - expect(rows).toHaveLength(1); - }); - const [conversation] = await db - .select() - .from(chatConversations) - .where(eq(chatConversations.endpointId, endpoint.id)); - await vi.waitFor(async () => { - const rows = await db - .select({ id: issueComments.id }) + expect(conversations).toHaveLength(1); + const [conversation] = conversations; + const comments = await db + .select({ id: issueComments.id, body: issueComments.body }) .from(issueComments) - .where(eq(issueComments.issueId, conversation.issueId)); - expect(rows).toHaveLength(8); - }); - const comments = await db - .select({ id: issueComments.id, body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, conversation.issueId)) - .orderBy(asc(issueComments.createdAt), asc(issueComments.id)); - expect(comments.map((comment) => comment.body)).toEqual([ - "@maya acknowledge quickly", - "follow-up 3", - "follow-up 4", - "follow-up 5", - "and include the rollback status", - "follow-up 6", - "follow-up 7", - "follow-up 8", - ]); - // Comment admission commits before the durable wake. Wait for this - // company's last wake too, not merely its already-visible last comment. - // The competing sweep may legitimately reconcile another fixture company. - await vi.waitFor(() => { - const calls = wakeup.mock.calls.filter( - (call) => call[0] === fixture.assignedAgentId, - ); + .where(eq(issueComments.issueId, conversation.issueId)) + .orderBy(asc(issueComments.createdAt), asc(issueComments.id)); + expect(comments.map((comment) => comment.body)).toEqual([ + "@maya acknowledge quickly", + "follow-up 3", + "follow-up 4", + "follow-up 5", + "and include the rollback status", + "follow-up 6", + "follow-up 7", + "follow-up 8", + ]); + // Joining the owner includes all comment admissions, durable wakeups and + // lease release. These assertions do not race a one-second polling budget. + expect(comments).toHaveLength(8); + const calls = wakeup.mock.calls.filter((call) => call[0] === fixture.assignedAgentId); expect(calls).toHaveLength(8); - expect(calls.map((call) => call[1]?.payload?.wakeCommentId)).toEqual( - comments.map((comment) => comment.id), - ); - }); - // The last comment and wakeup commit inside the lease. Under full-suite - // load the assertions above can observe those effects one microtask before - // the deferred owner's `finally` deletes its lease. Require prompt eventual - // release; a real leak would remain for the much longer lease TTL. - await vi.waitFor(async () => { + expect(calls.map((call) => call[1]?.payload?.wakeCommentId)).toEqual(comments.map((comment) => comment.id)); expect( - await db - .select() - .from(chatEndpointLeases) - .where(eq(chatEndpointLeases.endpointId, endpoint.id)), + await db.select().from(chatEndpointLeases).where(eq(chatEndpointLeases.endpointId, endpoint.id)), ).toHaveLength(0); - }); - await competingService.shutdown(); - await service.shutdown(); + } finally { + releaseFirstDelivery(); + await draining; + await competingService.shutdown(); + await service.shutdown(); + } }); it("stops a conversation drain after its lease renewal fails", async () => { @@ -61921,8 +61917,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ).resolves.toMatchObject({ ok: true }); expect(deferred).toHaveLength(1); await drainDeferred(); - await vi.waitFor(() => expect(deferred).toHaveLength(1)); - await drainDeferred(); + // Awaiting each callback also drains the lifecycle work it queues. + expect(deferred).toHaveLength(0); await vi.waitFor(async () => { await expect( db diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index c8b2628a228..1fe4eac26f5 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1467,8 +1467,8 @@ export interface ChatChannelServiceOptions { /** Optional verified ingress origin; never used for board or identity links. */ webhookPublicBaseUrl?: string | null; runtime?: ChatSdkRuntime; - /** Testable scheduler hook; production defaults to the next event-loop turn. */ - scheduleDeferredWork?: (task: () => void) => void; + /** Testable scheduler hook; its callback settles after the tracked work finishes. */ + scheduleDeferredWork?: (task: () => void | Promise) => void; /** Test boundary after selecting due Slack status work and before claiming. */ slackSessionSyncSelectionBarrier?: () => Promise; /** Narrow fault-injection boundary for the one-time setup-secret audit. */ @@ -3441,6 +3441,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); backgroundMessageTasks.add(pending); void pending.finally(() => backgroundMessageTasks.delete(pending)); + return pending; }); } From fd8c6b920ae5968f7609566dece131a2d712bfa7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:43:07 -0500 Subject: [PATCH 20/55] fix(native): resume connection tasks after approval decisions (#15471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native tasks can pause while a human decides whether to allow a connection action. > - The next turn needs both the saved decision and complete accounting for the previous turn. > - Cancellation could discard final usage, and complete direct Claude API receipts could remain unpriced. > - A stale blocked or review report could also request approval again after the original card was declined. > - This pull request retains shutdown accounting and rejects approval waits bound to an already resolved action. > - The benefit is reliable continuation with the existing budget and approval controls. ## Linked Issues or Issue Description Refs #15420. Related: #15312 addresses requester ownership during dispatch. This change addresses receipt capture and final-response validation. ## What Changed - Retain usage events after native cancellation. Continue to reject late provider messages and work. - Drain same-turn accounting and terminal events for at most fifteen seconds after a durable governed wait. Keep incomplete accounting blocked. - Estimate complete, unpriced, direct Anthropic API receipts for the exact `claude-sonnet-5` model. Record the rate version and assumptions. Use the one-hour cache-write rate when the receipt lacks cache TTL. - Bind stale approval reports to exact interaction, action-request, or invocation IDs in the same company, task, agent, and run. Cover blocked, review, and response-wake reports. Keep independent reviews valid. - Fence checkpoint and result writes after a controller detaches for restart, including operations waiting for a database lock. Reject stale successful returns before certifying accounting. - Allow bounded subscription teardown only after retaining an actual provider terminal. - Journal the exact governed-wait trigger and disposition before provider interruption. Recover that wait independently of a later saved answer, replay retained accounting, and reject mismatched or unproven terminal evidence. - Give settling governed turns a bounded window before shutdown detaches their controller. - Keep fuzzy external app matches alongside installed capability matches instead of forcing an unrelated provider question for a generic query. - Return up to twenty exact active catalog tool names after an invalid request, after eligibility checks; still reject the request without granting access or creating an approval. - Require retained provider terminal proof before settling a governed wait, including when complete usage arrives before stream closure/error/timeout. Retain harmless numbered cancellation events so restart replay stays contiguous. - Isolate accounting-test OpenCode config from the host plugin directory. - Add regression coverage and document the accounting, restart and connection-search behavior. ## Verification Current PR source: `0cf08efd75f8fb23f7989beda6dbda92587088bf`. The live matrix below measured frozen `09a776bcb1f77422156f8be11e13f8c29f43e7f7`; later review fixes are verified separately and do not relabel those runs. - Repository typecheck and build pass. - Current runner runtime and cancellation suites: 191 tests pass. Six new regressions cover stream end/error/timeout without provider-stop proof and contiguous cancellation acknowledgement/request replay; all six failed before the fix. The existing bounded cleanup case now explicitly supplies terminal proof. All 31 adapter accounting tests pass with isolated fixture config. - New checkpoint-rebinding and approval-criterion suites: 65 tests pass; runner HTTP integration: 29 tests pass. Unchanged executor/control-plane suites: 640 tests pass; database-backed connection suites: 68 tests pass. - Current retained evidence verification covers 222 file hashes across all fifteen original result artifacts. The three-case [published report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37651896525-1/index.html) and all eight screenshot hashes verify. The [three-case recovery report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37662587356-1/index.html) and all six screenshots also verify; the nine-result campaign did not publish. - Evaluation support: 1,805 Vitest tests pass, one skipped; 128 Node tests pass. Eval typecheck and catalog discovery pass. - Full local repository unit run was interrupted before the follow-up edits after three tool-access failures and one runner HTTP failure. Those failures pass in isolation; the 09a full run was interrupted after one rapid Slack callback-ordering failure and seven skill-service failures. All eight pass both isolated and with full-runner environment settings, and all 74 skill-service tests pass together; the subsequent full run reported two 15-second OpenCode accounting timeouts and was stopped with exit 130 to apply review fixes. The timeouts reproduce while copying this host’s 61 MB OpenCode config. All 31 tests pass after isolating config inside each fixture without increasing timeouts or changing assertions. A complete local full-suite pass is not claimed. Repository-wide CI also passes on the final review-fix head in [run 37669185952](https://github.com/paperclipai/paperclip/actions/runs/37669185952). Earlier database-skipped diagnostics and the older ENFILE run are retained and are not full-suite passing evidence. - Three-case live campaign [37651896525](https://github.com/paperclipai/paperclip/actions/runs/37651896525) passes all three original grades on frozen source 09a: 55/55 checks, eight succeeded run records, complete accounting receipts, matching checkpoint identities and no pending approvals. Campaign [37653533353](https://github.com/paperclipai/paperclip/actions/runs/37653533353) adds nine original passes (173/173 checks, eighteen succeeded records) on the identical source. Its other three jobs failed before runner assignment or any step while GitHub could not load the paid environment; those original infrastructure failures are retained. Campaign [37662587356](https://github.com/paperclipai/paperclip/actions/runs/37662587356) completes only those unstarted cells: all three original grades pass (57/57 checks, six succeeded run records). All fifteen exact cases now pass on source 09a: eight FAIL → PASS, seven PASS → PASS, zero new overall failures and zero pending pairs. Total current evidence: 285/285 checks and thirty-two succeeded run records, complete accounting receipts, matching checkpoint identities, no pending approvals or retry records. Earlier campaigns retain forty-seven additional run records and two known same-run recovery attempts; actual provider-call counts and invoices remain unknown. The nine-result campaign skipped publication and its public URL returns 403; original artifacts remain retained. Previous ba9 campaign [37645656840](https://github.com/paperclipai/paperclip/actions/runs/37645656840) completed 2 PASS / 1 FAIL: Claude restart/approval and Codex decline pass, while OpenCode resumes but times out searching for exact tool names and never creates the access card. That failure and incomplete cancelled-run accounting remain preserved; the new catalog error guidance targets this observed dead end. Campaign [37642957312](https://github.com/paperclipai/paperclip/actions/runs/37642957312) remains 0 PASS / 3 FAIL and exposed the now-corrected cross-run marker leak and unknown-criterion approval gap. The original baseline remains 7 PASS / 8 FAIL, first repair 2 PASS / 3 FAIL, and second repair 0 PASS / 3 FAIL. All fifteen selected cases are qualified by their original grades in this bounded trial. These live grades belong to 09a. Its twelve governed-wait checkpoints retain matching same-turn terminal fingerprints, but passing artifacts omit detailed event journals; the later six adversarial regressions qualify the new terminal-proof and replay guards separately. Final-head repository CI passes. [Fresh Greptile review](https://github.com/paperclipai/paperclip/pull/15471#issuecomment-6044369780) is 5/5, confirms both findings are fixed, and reports no new actionable issues. All review threads are resolved and the PR has no merge conflicts. ## Risks - Governed cancellation drains accounting for up to fifteen seconds. Restart detachment gives a settling batch up to twenty seconds to finish. An incomplete receipt or unproven provider terminal still prevents successful qualification. - Claude prices are estimates, not invoices. The estimate assumes standard global API pricing and uses a conservative cache-write rate. Unsupported models, billers, and billing modes remain unpriced. - Approval identity matching must remain scoped to the current run and the requested approval. It does not authorize execution of a declined call. - Original baseline and final candidate have different merged master context. Exact-case outcomes are before/after observations, not isolated causal attribution to this repair. - No schema migration, fixture, oracle or grader change. Search-result guidance now treats fuzzy external matches as suggestions. Existing app authorization and provider-consent checks remain required. ## Model Used - OpenAI GPT-6 through Codex, with code editing, terminal tools, and test execution. The exact deployment identifier and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (191 current runtime tests and 31 accounting tests; interrupted full-suite history and CI coverage are disclosed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/SPEC-implementation.md | 14 ++ doc/connections/CONNECTION-SEARCH.md | 13 + doc/execution-semantics.md | 17 ++ .../backends/harness-driver-backend.test.ts | 22 ++ .../src/backends/harness-driver-backend.ts | 11 +- .../src/contracts/native-session-backend.ts | 7 + .../src/native-session-runtime.test.ts | 228 +++++++++++++++++- .../src/native-session-runtime.ts | 163 +++++++++++-- .../adapter-cost-failure-paths.test.ts | 10 +- .../connection-aggregator-fallback.test.ts | 72 +++++- server/src/services/anthropic-pricing.test.ts | 29 +++ server/src/services/anthropic-pricing.ts | 31 +++ server/src/services/connection-intents.ts | 20 +- .../native-completion-feedback.test.ts | 41 ++++ .../native-completion-feedback.ts | 32 ++- .../native-session-executor.test.ts | 91 +++++++ .../native-runtime/native-session-executor.ts | 45 +++- .../native-session-resume.test.ts | 9 +- .../native-runtime/native-session-resume.ts | 3 + .../paperclip-control-plane-port.test.ts | 61 +++++ .../paperclip-control-plane-port.ts | 63 +++-- server/src/services/usage-receipts.ts | 5 +- 22 files changed, 917 insertions(+), 70 deletions(-) create mode 100644 server/src/services/anthropic-pricing.test.ts create mode 100644 server/src/services/anthropic-pricing.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f50252a776d..931de428964 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1554,6 +1554,20 @@ for contracts, recovery behavior, Storybook, and acceptance workflows. Board may override by raising budget or explicitly resuming agent. +Native runs retain final usage receipts during a bounded accounting-only drain +after a governed wait cancels provider work. This does not accept late messages, +tool calls, or new completion proposals. Missing or incomplete receipts continue +to block budget admission. +A controller that detaches for server restart loses checkpoint and completion +write authority. A closing event stream is not proof that the governed run +finished; the replacement controller must adopt and settle the original run. + +Complete direct Anthropic API receipts for `claude-sonnet-5` can use a versioned +list-price estimate when the provider supplies no run price. The receipt records +the rates and assumptions. Aggregate cache writes use the one-hour rate because +their TTL is unknown. Estimates are not invoices. Other models, billers, unknown +billing types, and incomplete receipts remain unpriced. + ## 13.3 Cost Event Ingestion `POST /companies/:companyId/cost-events` body: diff --git a/doc/connections/CONNECTION-SEARCH.md b/doc/connections/CONNECTION-SEARCH.md index 9c0630e21e1..594b10a66ec 100644 --- a/doc/connections/CONNECTION-SEARCH.md +++ b/doc/connections/CONNECTION-SEARCH.md @@ -57,6 +57,12 @@ grants consent. A provider connection is not proof that its underlying app is authorized. Multiple matching aggregator apps are returned as candidates; the agent searches the selected `aggregator.targetService` to get its provider question. +A fuzzy or partial external app match remains a suggestion alongside authorized +installed capability matches. It cannot replace those matches with a mandatory +provider question. For example, a query for a page service can return the +installed page tools alongside a Page X suggestion; selecting the exact Page X +service is required before that external route asks for provider consent. + ## Regression coverage ```sh @@ -69,3 +75,10 @@ Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The nati tool authority test searches a paragraph longer than the old 200-character limit and then creates a real connection interaction. Existing tests retain checks for private metadata, administrative denials, stale identities, and saved declines. + +When `connection_request` rejects an unknown `toolNames` entry, its error includes +up to twenty exact names from that eligible connection's active tool catalog. +This is discovery metadata: the request still fails with 422, grants no access, +and creates no approval card. The agent must select the needed exact names and +submit a new request; normal user approval, catalog-version and policy checks +still apply. Ineligible connections and inactive catalog entries are excluded. diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 1819311080a..c923e18c66f 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1382,6 +1382,23 @@ controller must recover it through the authenticated ownership checks. This preserves active work and queued messages without treating a server restart as user cancellation. +When a native turn creates a governed approval wait, its checkpoint retains the +exact committed triggering event and yielded disposition before interrupting the +provider. Shutdown gives these turns a shared, bounded 20-second window to finish +settling their usage and terminal evidence before detaching. Recovery verifies +the trigger against the retained event ledger and settles that same turn, even +if the user has already answered the interaction. It replays retained accounting +through the ordinary control-plane observers without starting another model turn. +When a later heartbeat run reuses the provider session, it clears that prior +run's wait marker along with its terminal, result and pending-request authority. +Already-numbered cancellation acknowledgements and cancelled/expired runtime requests +are retained during shutdown so recovery can replay a contiguous journal. They +carry no permission to start work. Stream closure, stream failure or drain timeout +after complete usage leaves the saved wait unfinalized until a provider terminal +is retained; complete token counters alone cannot certify a stopped provider. +A timeout, missing accounting, or an unproven provider terminal remains a failure; +the saved wait does not certify successful provider execution. + Before either shutdown path exits, idle warm sessions close through their normal suspend-and-checkpoint path. Remote sessions therefore leave verified backup authority for the next controller even though their last run is already diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts index d550d594d64..a2cf6f970cd 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -974,6 +974,28 @@ describe("HarnessDriverBackend", () => { await expect(iterator.next()).rejects.toThrow("provider transport lost after resolution"); }); + it.each([false, true])("retains final usage without accepting late output (journal boundary only: %s)", async journalOnly => { + class AccountingAfterCancelSession extends FakeHarnessSession { + async interrupt() {} + override async *events() { + yield prpEvent(1, "item.completed", { kind: "agentMessage", text: "late answer" }); + yield prpEvent(2, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } }); + yield prpEvent(3, "turn.completed", { status: "completed" }); + } + } + const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new AccountingAfterCancelSession(); } }); + const session = await backend.openSession({ + identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" }, workingDirectory: "/workspace", + }); + if (journalOnly) session.revokeTurnPublication(); + else await session.cancel({ reason: "durable governed wait", signal: new AbortController().signal }).cleanup; + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.filter(event => event.payload.kind === "agentMessage")).toHaveLength(0); + expect(events.find(event => event.payload.kind === "usage")?.payload.usage).toMatchObject({ runDelta: { inputTokens: 7, outputTokens: 3 } }); + expect(events.some(event => event.eventType === "turn.cancelled")).toBe(true); + }); + it("does not start provider work when Stop arrives before the first turn", async () => { const provider = new FakeHarnessSession(); const start = vi.spyOn(provider, "startTurn"); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index bf5496c097e..4597400ff3b 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -549,7 +549,10 @@ class HarnessNativeSession implements NativeSession { (event.eventType === "run.terminal" && ["failed", "cancelled"].includes(String(event.payload.runTerminalState))) || (event.eventType === "item.completed" && event.payload.kind === "interrupt_acknowledgement"); - if (this.#explicitlyCancelled && !isCancellationEvent) continue; + // Accounting for work already performed survives cancellation. It grants + // no tool, message, semantic-result, or continuation authority. + const isUsageReceipt = event.eventType === "item.completed" && event.payload.kind === "usage"; + if (this.#explicitlyCancelled && !isCancellationEvent && !isUsageReceipt) continue; sourceInstanceId = event.sourceInstanceId; lastSourceSequence = Math.max(lastSourceSequence, event.sourceSeq); if (event.eventType === "runtime_request.created") { @@ -735,6 +738,10 @@ class HarnessNativeSession implements NativeSession { return this.#session.interrupt(input); } + revokeTurnPublication() { + this.#explicitlyCancelled = true; + } + cancel(input: { reason: string; signal: AbortSignal }) { if (input.signal.aborted) { throw ( @@ -744,7 +751,7 @@ class HarnessNativeSession implements NativeSession { // This flag is the adapter's synchronous publication boundary. Provider // interruption happens afterward as passive cleanup, so a slow or broken // transport cannot synthesize or publish new accepted output for the turn. - this.#explicitlyCancelled = true; + this.revokeTurnPublication(); const interrupt = this.#session.interrupt; return { cleanup: diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index 87af9e2ad05..f473b56a03e 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -63,6 +63,10 @@ export interface PersistedNativeSession { terminal?: PrpTerminalState | null; activeTurnId?: string | null; terminalTurns?: PersistedHarnessTurnTerminal[]; + /** Control-plane disposition bound to the exact committed tool event. It is + * independent of the interaction's later answer and proves no provider terminal. */ + governedWait?: { sourceEvent: PrpEvent; result: PrpStructuredRunResult }; + /** At-most-once marker for resultless disposition repair or restart continuation. */ dispositionOnlyRecoveryConsumed?: boolean; dispositionOnlyRecoveryTurnId?: string | null; @@ -200,6 +204,9 @@ export interface NativeSession { correlationId?: string; }): Promise; interrupt?(input: { turnId?: string; reason?: string }): Promise; + /** Revoke publication synchronously while the control plane journals a wait. + * Does not interrupt the provider; cancel owns the subsequent passive cleanup. */ + revokeTurnPublication?(): void; /** Commit cancellation synchronously; the returned promise owns cleanup only. */ cancel?(input: { reason: string; diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index db0344becc7..e65944682cd 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -4403,7 +4403,9 @@ describe("executeNativeSession recovery", () => { expect(lifecycle).toEqual(["closed"]); }); - it("commits a governed wait without waiting for abort-insensitive provider cleanup", async () => { + it("commits a proved governed wait without waiting for abort-insensitive provider cleanup", async () => { + vi.useFakeTimers(); + try { const lifecycle: string[] = []; let cancellationSignal: AbortSignal | undefined; const cancel = vi.fn(({ signal }: { signal: AbortSignal }) => { @@ -4429,6 +4431,8 @@ describe("executeNativeSession recovery", () => { }, async *events() { yield runnerEvent(1, "item.completed"); + expect(cancel).toHaveBeenCalledOnce(); + yield runnerEvent(2, "turn.interrupted", { providerTerminalObserved: true }); }, async startTurn() { return { turnId: "turn-recovery" }; @@ -4498,7 +4502,13 @@ describe("executeNativeSession recovery", () => { keepSessionOpen: true, onSession: (current) => retainedSessions.push(current), }); - await expect(execution).resolves.toMatchObject({ result: yieldedResult }); + let settled = false; + const completed = execution.then(value => { settled = true; return value; }); + void completed.catch(() => undefined); + await vi.advanceTimersByTimeAsync(9_900); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(200); + await expect(completed).resolves.toMatchObject({ result: yieldedResult }); expect(cancel).toHaveBeenCalledOnce(); expect(cancellationSignal?.aborted).toBe(true); expect(close).toHaveBeenCalledOnce(); @@ -4506,9 +4516,11 @@ describe("executeNativeSession recovery", () => { expect(retainedSessions.at(-1)).toBeNull(); expect(events.map((event) => event.eventType)).toEqual([ "item.completed", + "turn.interrupted", "run.result.accepted", "run.terminal", ]); + } finally { vi.useRealTimers(); } }); it("uses the execution timeout when a completion report has no provider terminal", async () => { @@ -4637,7 +4649,7 @@ describe("executeNativeSession recovery", () => { expect(retainedSessions.at(-1)).toBeNull(); }); - it("retains a reusable session while a semantic terminal releases its remote subscription", async () => { + it.each([false, true])("retains a reusable session while terminal teardown settles (governed=%s)", async (governed) => { const close = vi.fn(async () => undefined); const retainedSessions: Array = []; const session: NativeSession = { @@ -4653,9 +4665,9 @@ describe("executeNativeSession recovery", () => { }, async *events() { try { - yield runnerEvent(1, "run.result.proposed", result); + yield { ...runnerEvent(1, governed ? "item.completed" : "run.result.proposed", governed ? { kind: "dynamicToolCall" } : result), turnId: "turn-recovery" }; yield { - ...runnerEvent(2, "turn.completed"), + ...runnerEvent(2, governed ? "turn.interrupted" : "turn.completed"), turnId: "turn-recovery", }; } finally { @@ -4680,6 +4692,7 @@ describe("executeNativeSession recovery", () => { lineage: [], }; }, + cancel: () => ({ cleanup: Promise.resolve() }), close, }; const backend: NativeSessionBackend = { @@ -4720,10 +4733,11 @@ describe("executeNativeSession recovery", () => { controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", + resolveGovernedWait: governed ? () => yieldedResult : undefined, keepSessionOpen: true, onSession: (current) => retainedSessions.push(current), }), - ).resolves.toMatchObject({ result, terminal }); + ).resolves.toMatchObject({ result: governed ? yieldedResult : result }); expect(close).not.toHaveBeenCalled(); expect(retainedSessions).toEqual([session]); @@ -7641,7 +7655,192 @@ describe("executeNativeSession recovery", () => { ]); }); - it("parks a provider turn immediately after a durable governed wait appears", async () => { + it.each(["end", "throw", "timeout"])("keeps a governed wait unfinalized when complete usage is followed by %s without a provider terminal", async fault => { + vi.useFakeTimers(); + try { + const trigger = { ...runnerEvent(1, "item.completed", { kind: "dynamicToolCall" }), turnId: "turn-waiting" }; + const usage = { ...runnerEvent(2, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } }), turnId: trigger.turnId }; + const history: PrpEvent[] = []; + let checkpoint: PersistedNativeSession | undefined; + let releaseStream!: () => void; + const streamClosed = new Promise(resolve => { releaseStream = resolve; }); + const complete = vi.fn(async () => undefined); + const session: NativeSession = { + identity: () => identity, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + async startTurn() { return { turnId: trigger.turnId }; }, + cancel() { return { cleanup: Promise.resolve() }; }, + async snapshot() { return { backendKind: "mock", sessionId: identity.sessionId, identity, activeTurnId: trigger.turnId }; }, + async result() { return null; }, async close() { releaseStream(); }, + async *events() { + yield trigger; yield usage; + if (fault === "throw") throw new Error("provider stream lost"); + if (fault === "timeout") await streamClosed; + }, + }; + const backend: NativeSessionBackend = { + async descriptor() { return { kind: "mock", name: "missing-stop", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { return session; }, + }; + const port: ControlPlanePort = { + async openRun() {}, completeRun: complete, + async checkpointSession(value) { checkpoint = structuredClone(value); }, + async appendEvent(event) { history.push(event); return { cursor: history.length, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; }, + async replayEvents(request) { return { events: history.filter(row => row.sourceInstanceId === request.sourceInstanceId && row.sourceSeq > request.afterSourceSeq), highestContiguousSourceSeq: 0 }; }, + }; + const outcome = expect(executeNativeSession({ input, backend, controlPlane: port, timeoutMs: 1_000, + runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", resolveGovernedWait: () => yieldedResult })) + .rejects.toMatchObject({ providerCode: "governed_wait_provider_terminal_unproven" }); + void outcome.catch(() => undefined); + await vi.advanceTimersByTimeAsync(600); + await outcome; + expect(checkpoint?.governedWait).toEqual({ sourceEvent: trigger, result: yieldedResult }); + expect(complete).not.toHaveBeenCalled(); + expect(history).toEqual([trigger, usage]); + } finally { vi.useRealTimers(); } + }); + + it.each(["interrupt_acknowledgement", "runtime_request.cancelled", "runtime_request.expired"])("retains %s in contiguous governed-wait history for recovery", async cancellationKind => { + const trigger = { ...runnerEvent(1, "item.completed", { kind: "dynamicToolCall" }), turnId: "turn-waiting" }; + const acknowledgement: PrpEvent = { ...runnerEvent(2, cancellationKind === "interrupt_acknowledgement" ? "item.completed" : cancellationKind as "runtime_request.cancelled" | "runtime_request.expired", + cancellationKind === "interrupt_acknowledgement" ? { kind: cancellationKind } : { requestId: "request-1" }), turnId: trigger.turnId }; + const usage = { ...runnerEvent(3, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } }), turnId: trigger.turnId }; + const stopped = { ...runnerEvent(4, "turn.interrupted", { providerTerminalObserved: true }), turnId: trigger.turnId }; + const history: PrpEvent[] = []; + let checkpoint: PersistedNativeSession | undefined; + const start = vi.fn(async () => ({ turnId: trigger.turnId })); + const cancel = vi.fn(() => ({ cleanup: Promise.resolve() })); + const complete = vi.fn(async () => undefined); + const session: NativeSession = { + identity: () => identity, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + startTurn: start, cancel, + async snapshot() { return { backendKind: "mock", sessionId: identity.sessionId, identity, activeTurnId: trigger.turnId }; }, + async result() { return null; }, async close() {}, + async *events() { yield trigger; yield acknowledgement; yield usage; yield stopped; }, + }; + const backend: NativeSessionBackend = { + async descriptor() { return { kind: "mock", name: "cancel-history", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { return session; }, async recoverSession() { return { recovered: true, session }; }, + }; + const port: ControlPlanePort = { + async openRun() {}, completeRun: complete, + async checkpointSession(value) { checkpoint = structuredClone(value); }, + async appendEvent(event) { + const duplicate = history.some(row => row.sourceEventId === event.sourceEventId); + if (!duplicate) history.push(event); + return { cursor: history.length, highestContiguousSourceSeq: event.sourceSeq, disposition: duplicate ? "duplicate" : "committed" }; + }, + async replayEvents(request) { + const events = history.filter(row => row.sourceInstanceId === request.sourceInstanceId && row.sourceSeq > request.afterSourceSeq); + return { events, highestContiguousSourceSeq: events.at(-1)?.sourceSeq ?? request.afterSourceSeq }; + }, + }; + const options = { input, backend, controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery" }; + await expect(executeNativeSession({ ...options, resolveGovernedWait: () => yieldedResult })) + .resolves.toMatchObject({ result: yieldedResult }); + expect(history.filter(event => event.sourceInstanceId === trigger.sourceInstanceId)).toEqual([trigger, acknowledgement, usage, stopped]); + await expect(executeNativeSession({ ...options, persistedSession: checkpoint })).resolves.toMatchObject({ result: yieldedResult }); + expect(start).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it.each(["trigger", "usage", "terminal"])("recovers a journaled governed wait after its answer is saved (last committed: %s)", async lastCommitted => { + const terminalCommitted = lastCommitted === "terminal"; + const trigger = { ...runnerEvent(1, "item.completed", { kind: "dynamicToolCall", item: { id: "ask-1", name: "request_human_input" } }), turnId: "turn-waiting" }; + const usage = { ...runnerEvent(2, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } }), turnId: trigger.turnId }; + const stopped = { ...runnerEvent(3, "turn.interrupted", { providerTerminalObserved: true }), turnId: trigger.turnId }; + const history: PrpEvent[] = []; + let checkpoint: PersistedNativeSession | undefined; + let crashed = false; + let interrupted = false; + const revoke = vi.fn(); + const cancel = vi.fn(() => { interrupted = true; return { cleanup: Promise.resolve() }; }); + const start = vi.fn(async () => ({ turnId: trigger.turnId })); + const complete = vi.fn(async () => undefined); + const snapshot = (): PersistedNativeSession => ({ backendKind: "mock", sessionId: identity.sessionId, identity, + providerSessionId: "same-provider", activeTurnId: trigger.turnId, cursor: String(history.at(-1)?.sourceSeq ?? 0) }); + const makeSession = (recovering: boolean): NativeSession => ({ identity: () => identity, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + startTurn: start, revokeTurnPublication: revoke, cancel, + async snapshot() { return snapshot(); }, async result() { return null; }, async close() {}, + async *events() { + if (!recovering) { yield trigger; return; } + // The restart resumes settlement only; it must not submit a model turn. + expect(interrupted).toBe(true); + yield usage; + yield stopped; + }, + }); + const backend: NativeSessionBackend = { + async descriptor() { return { kind: "mock", name: "durable-wait", version: "1", capabilities: await makeSession(false).capabilities() }; }, + async openSession() { return makeSession(false); }, + async recoverSession() { return { recovered: true, session: makeSession(true) }; }, + }; + const port: ControlPlanePort = { + async openRun() {}, completeRun: complete, + async checkpointSession(value) { + checkpoint = structuredClone(value); + if (value.governedWait && !crashed) { + expect(revoke).toHaveBeenCalledOnce(); + expect(cancel).not.toHaveBeenCalled(); + crashed = true; + throw new Error("controller lost after governed checkpoint"); + } + }, + async appendEvent(event) { + if (!history.some(row => row.sourceEventId === event.sourceEventId)) history.push(event); + return { cursor: history.length, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; + }, + async replayEvents(request) { + const events = history.filter(row => row.sourceInstanceId === request.sourceInstanceId && row.sourceSeq > request.afterSourceSeq); + return { events, highestContiguousSourceSeq: events.at(-1)?.sourceSeq ?? request.afterSourceSeq }; + }, + }; + const options = { input, backend, controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery" }; + await expect(executeNativeSession({ ...options, resolveGovernedWait: () => yieldedResult })) + .rejects.toThrow("controller lost after governed checkpoint"); + expect(checkpoint?.governedWait).toEqual({ sourceEvent: trigger, result: yieldedResult }); + expect(complete).not.toHaveBeenCalled(); + // The failed controller's ordinary cleanup also invokes cancel. Its + // checkpoint assertion above proves interruption followed persistence. + cancel.mockClear(); interrupted = false; + if (lastCommitted !== "trigger") history.push(usage); + if (terminalCommitted) history.push(stopped); + const answerAlreadySaved = vi.fn(() => null); + await expect(executeNativeSession({ ...options, persistedSession: checkpoint, resolveGovernedWait: answerAlreadySaved })) + .resolves.toMatchObject({ result: yieldedResult, turnId: trigger.turnId, terminal: { runTerminalState: "succeeded" } }); + expect(start).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledTimes(terminalCommitted ? 0 : 1); + expect(answerAlreadySaved).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledOnce(); + expect(history.filter(event => event.payload.kind === "usage")).toEqual([usage]); + }); + + it.each(["missing-trigger", "changed-trigger", "unproven-terminal", "different-turn"])("rejects unsafe governed-wait recovery: %s", async fault => { + const trigger = { ...runnerEvent(1, "item.completed", { kind: "dynamicToolCall" }), turnId: "turn-waiting" }; + const checkpoint: PersistedNativeSession = { backendKind: "mock", sessionId: identity.sessionId, identity, + activeTurnId: trigger.turnId, governedWait: { sourceEvent: trigger, result: yieldedResult } }; + const history = fault === "missing-trigger" ? [] : [fault === "changed-trigger" ? { ...trigger, payload: {} } : trigger]; + if (fault === "unproven-terminal") history.push({ ...trigger, sourceSeq: 2, sourceEventId: "unproven", eventType: "turn.failed", payload: { providerTerminalObserved: false } }); + if (fault === "different-turn") history.push({ ...trigger, sourceSeq: 2, sourceEventId: "new-turn", turnId: "different", eventType: "turn.started" }); + const start = vi.fn(); + const complete = vi.fn(); + const session: NativeSession = { identity: () => identity, startTurn: start, async result() { return null; }, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + async snapshot() { return checkpoint; }, async *events() {}, async close() {} }; + const backend: NativeSessionBackend = { async descriptor() { return { kind: "mock", name: "unsafe-wait", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { return session; }, async recoverSession() { return { recovered: true, session }; } }; + const port: ControlPlanePort = { async openRun() {}, async checkpointSession() {}, async appendEvent() { throw new Error("unexpected append"); }, + completeRun: complete, async replayEvents(request) { return { events: history.filter(row => row.sourceSeq > request.afterSourceSeq), highestContiguousSourceSeq: history.at(-1)?.sourceSeq ?? 0 }; } }; + await expect(executeNativeSession({ input, backend, controlPlane: port, persistedSession: checkpoint, + runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery" })).rejects.toThrow(/governed_wait|failed terminal/); + expect(start).not.toHaveBeenCalled(); expect(complete).not.toHaveBeenCalled(); + }); + + it.each([0, 6_000, 12_000])("retains actual shutdown usage after %s ms before finalizing a governed wait", async (receiptDelayMs) => { + vi.useFakeTimers(); + try { const yielded: PrpStructuredRunResult = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", @@ -7681,8 +7880,8 @@ describe("executeNativeSession recovery", () => { turnId: "turn-waiting", }; const turnInterrupted: PrpEvent = { - ...controlEvent(2, "turn.interrupted", { reason: "governed_wait" }), - sourceEventId: "provider-recovery:2", + ...controlEvent(3, "turn.interrupted", { reason: "governed_wait" }), + sourceEventId: "provider-recovery:3", sourceInstanceId: "provider-recovery", sourceKind: "provider", turnId: "turn-waiting", @@ -7711,6 +7910,9 @@ describe("executeNativeSession recovery", () => { async *events() { yield itemCompleted; await cancelled; + await new Promise(resolve => setTimeout(resolve, receiptDelayMs)); + yield { ...itemCompleted, sourceSeq: 2, sourceEventId: "provider-recovery:2", + payload: { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } } }; yield turnInterrupted; }, async startTurn() { @@ -7778,7 +7980,7 @@ describe("executeNativeSession recovery", () => { completeRun, }; - const completed = await executeNativeSession({ + const execution = executeNativeSession({ input, backend, controlPlane: port, @@ -7788,7 +7990,10 @@ describe("executeNativeSession recovery", () => { event.eventType === "item.completed" ? yielded : null, }); + await vi.advanceTimersByTimeAsync(receiptDelayMs + 1); + const completed = await execution; expect(cancel).toHaveBeenCalledOnce(); + expect(events.find(event => event.payload.kind === "usage")?.payload.usage).toMatchObject({ runDelta: { inputTokens: 7, outputTokens: 3 } }); expect(completed).toMatchObject({ result: yielded, terminal: { @@ -7799,9 +8004,12 @@ describe("executeNativeSession recovery", () => { }); expect(events.map((event) => event.eventType)).toEqual([ "item.completed", + "item.completed", + "turn.interrupted", "run.result.accepted", "run.terminal", ]); + } finally { vi.useRealTimers(); } }); it("hands a committed structured input to the durable wait after its live window", async () => { diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index df65c4450ab..e49b4f6b612 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -47,6 +47,11 @@ const FAILED_OPERATION_SETTLEMENT_GRACE_MS = 100; // bounded by the transport. Other iterator and handoff cleanup keeps the short // fail-closed quarantine boundary below. const REUSABLE_SESSION_CANCELLATION_SETTLEMENT_GRACE_MS = 10_000; +// Governed cancellation can traverse the ACPX turn cancellation (2s), host +// cancellation (2s), protocol/TERM/KILL shutdown (7s), and final status read +// (1s). Allow that bounded settlement plus transport scheduling; never treat +// elapsed time or a cancellation acknowledgement as usage/terminal evidence. +const GOVERNED_ACCOUNTING_DRAIN_MS = 15_000; const DEFAULT_NATIVE_CHECKPOINT_TIMEOUT_MS = 30_000; type NativeSessionCleanupDomain = string; @@ -870,12 +875,15 @@ async function consumeTurn( }, initialGoal?: HarnessThreadGoal | null, resumeInterruptedTurn?: (event: PrpEvent, signal: AbortSignal) => Promise, + checkpointGovernedWait?: (wait: NonNullable, signal: AbortSignal) => Promise, + initialGovernedWait?: PersistedNativeSession["governedWait"], ) { let timer: ReturnType | undefined; const appendAbort = new AbortController(); const governedCleanupOperations = new Set>(); let governedCancellationCommitted = false; let semanticResultObserved = false; + let governedProviderTerminalObserved = false; let deferredGovernedCleanupSettlement: Promise | null = null; let deferredSessionCancellationSettlement: Promise | null = null; const inputTimers = new Map>(); @@ -922,7 +930,7 @@ async function consumeTurn( let latestSessionGoal: HarnessThreadGoal | null = null; let resultSource: "semantic_result" | "governed_wait" | null = null; let providerFailure: NativeProviderTerminalFailure | null = null; - const settleDurableResult = ( + const settleDurableResult = async ( event: PrpEvent, result: PrpStructuredRunResult, reason: string, @@ -930,16 +938,77 @@ async function consumeTurn( if (session.cancel === undefined) { throw new Error("native_governed_wait_cancellation_unavailable"); } - const cancellation = session.cancel({ - reason, - signal: appendAbort.signal, - }); + // Revoke new publication before any asynchronous journal write. Persist + // the disposition before provider interruption, retaining usage and terminal + // evidence even when the interaction is answered during the restart. + session.revokeTurnPublication?.(); + await checkpointGovernedWait?.({ sourceEvent: event, result }, appendAbort.signal); + appendAbort.signal.throwIfAborted(); + const cancellation = session.cancel({ reason, signal: appendAbort.signal }); governedCancellationCommitted = true; const cleanup = cancellation.cleanup; governedCleanupOperations.add(cleanup); void cleanup .catch(() => quarantineSession("governed_cleanup_failed")) .finally(() => governedCleanupOperations.delete(cleanup)); + // Cancellation revokes provider work immediately, but shutdown can still + // supply its final usage. Keep a bounded accounting-only drain before + // acknowledging the run; otherwise the next wake sees permanent debt. + // Never let a stuck stream or cleanup revoke the durable governed wait. + const drainAbort = new AbortController(); + const stopDrain = () => drainAbort.abort(appendAbort.signal.reason); + appendAbort.signal.addEventListener("abort", stopDrain, { once: true }); + const drainMs = Math.min(GOVERNED_ACCOUNTING_DRAIN_MS, timeoutMs > 0 ? Math.max(1, Math.floor(timeoutMs / 4)) : GOVERNED_ACCOUNTING_DRAIN_MS); + let drainTimer: ReturnType | undefined; + const drained = (async () => { + while (!drainAbort.signal.aborted) { + const next = await eventIterator.next(); + if (next.done || drainAbort.signal.aborted) return; + const trailing = next.value; + const terminal = isTurnTerminal(trailing); + const usage = trailing.eventType === "item.completed" && trailing.payload.kind === "usage"; + // These already-numbered cancellation facts carry no new work + // authority. Retain them so later receipts have contiguous replay. + const cancellation = trailing.eventType === "runtime_request.cancelled" + || trailing.eventType === "runtime_request.expired" + || (trailing.eventType === "item.completed" && trailing.payload.kind === "interrupt_acknowledgement") + || (trailing.eventType === "run.terminal" && ["failed", "cancelled"].includes(String(trailing.payload.runTerminalState))); + if (trailing.turnId === event.turnId && (usage || terminal || cancellation)) { + const receipt = await controlPlane.appendEvent(trailing, { signal: drainAbort.signal }); + if (drainAbort.signal.aborted) return; + eventCount += receipt.disposition === "committed" ? 1 : 0; + highestContiguousSourceSeq = Math.max(highestContiguousSourceSeq, receipt.highestContiguousSourceSeq); + if (terminal) { + if (trailing.eventType === "turn.failed" || trailing.payload.providerTerminalObserved === false) { + throw new NativeProviderTerminalFailure("governed_wait_provider_terminal_unproven", false); + } + governedProviderTerminalObserved = true; + return; + } + } + } + })(); + await Promise.race([ + drained.catch(error => { + quarantineSession("governed_accounting_drain_failed"); + if (error instanceof NativeProviderTerminalFailure) throw error; + }), + new Promise(resolve => { drainTimer = setTimeout(() => { + drainAbort.abort(new Error("governed accounting drain timed out")); + quarantineSession("governed_accounting_drain_timed_out"); + resolve(); + }, drainMs); }), + ]).finally(() => { + clearTimeout(drainTimer); + appendAbort.signal.removeEventListener("abort", stopDrain); + drainAbort.abort(); + }); + // A durable wait and complete usage cannot prove that the provider + // stopped. Leave the journaled wait recoverable, without a successful + // result or an accounting-completion boundary, if shutdown is unproven. + if (!governedProviderTerminalObserved) { + throw new NativeProviderTerminalFailure("governed_wait_provider_terminal_unproven", false); + } return { event, eventCount, @@ -947,6 +1016,10 @@ async function consumeTurn( governedResult: result, }; }; + if (initialGovernedWait) { + return settleDurableResult(initialGovernedWait.sourceEvent, initialGovernedWait.result, + "Paperclip resumed settlement of this durable governed interaction."); + } while (true) { const next = await eventIterator.next().catch((error) => { throw providerFailure ?? error; @@ -1309,15 +1382,14 @@ async function consumeTurn( ); } else { // Iterator and provider cleanup own no control-plane mutation authority. - // A reusable session with a semantic result needs a longer bounded - // window for the remote event subscription to release. This applies - // after the provider terminal, which still crosses the remote PRP - // acknowledgement boundary and routinely takes longer than - // the generic local cleanup grace. Governed waits and unrelated stalled - // cleanup retain the short fail-closed boundary. + // A reusable session needs a bounded window for its remote terminal + // subscription to release. A governed wait qualifies only after its + // actual provider terminal was retained during the accounting drain. + // Missing terminal evidence and unrelated stalled cleanup retain the + // short fail-closed boundary. const teardownSettled = await settlesWithin( passiveTeardownSettlement, - semanticResultObserved + semanticResultObserved || governedProviderTerminalObserved ? reusableSessionCancellationGraceMs : FAILED_OPERATION_SETTLEMENT_GRACE_MS, ); @@ -1871,6 +1943,42 @@ export async function executeNativeSession( issueId: input.binding.issueId, agentId: input.binding.agentId, }; + let governedWait = persistedSession?.governedWait; + const governedReplay: PrpEvent[] = []; + if (governedWait) { + const event = governedWait.sourceEvent; + const validation = validatePrpStructuredRunResult(governedWait.result); + if (!event?.turnId || event.runId !== identity.runId || event.normalizedSessionId !== identity.sessionId + || event.sourceInstanceId !== options.runnerInstanceId || !Number.isSafeInteger(event.sourceSeq) || event.sourceSeq < 1 + || !validation.ok || validation.result.reportedWorkDisposition !== "yielded" + || validation.result.completionClaim?.contractRevision !== input.completionContract.contract.revision) { + throw new Error("native_governed_wait_checkpoint_mismatch"); + } + await runAbortableOperationWithin({ + timeoutMs: options.timeoutMs ?? 900_000, + timeoutMessage: "native governed-wait recovery replay timed out", + operation: async signal => { + let afterSourceSeq = event.sourceSeq - 1; + while (true) { + const page = await options.controlPlane.replayEvents({ runId: identity.runId, + sourceInstanceId: options.runnerInstanceId, afterSourceSeq, limit: 1_000 }, { signal }); + signal.throwIfAborted(); + if (!page.events.length) break; + for (const entry of page.events) { + if (entry.runId !== identity.runId || entry.normalizedSessionId !== identity.sessionId + || entry.sourceInstanceId !== event.sourceInstanceId || entry.sourceSeq !== afterSourceSeq + 1 + || governedReplay.length >= 10_000) throw new Error("native_governed_wait_replay_mismatch"); + governedReplay.push(entry); + afterSourceSeq = entry.sourceSeq; + } + } + }, + }); + if (canonicalJson(governedReplay[0]) !== canonicalJson(event)) throw new Error("native_governed_wait_trigger_unproven"); + if (governedReplay.some(entry => entry.eventType === "turn.started" && entry.turnId !== event.turnId)) { + throw new Error("native_governed_wait_turn_mismatch"); + } + } let recovered = false; let session: NativeSession | null = null; let continuityBreak: { @@ -2152,7 +2260,7 @@ export async function executeNativeSession( externalSignal?: AbortSignal, ) => persistCheckpointWithin({ - snapshot, + snapshot: governedWait ? { ...snapshot, governedWait } : snapshot, controlPlane: options.controlPlane, onCheckpoint: options.onCheckpoint, timeoutMs: checkpointTimeoutMs, @@ -2240,6 +2348,21 @@ export async function executeNativeSession( await persistCheckpoint(recoveredSnapshot); } + const governedTerminal = governedWait ? governedReplay.find(event => + event.turnId === governedWait!.sourceEvent.turnId && isTurnTerminal(event)) : undefined; + if (governedTerminal?.eventType === "turn.failed" || governedTerminal?.payload.providerTerminalObserved === false) { + throw new NativeProviderTerminalFailure("governed_wait_provider_terminal_unproven", false); + } + if (governedWait && recoveredActiveTurnId && recoveredActiveTurnId !== governedWait.sourceEvent.turnId) { + throw new Error("native_governed_wait_turn_mismatch"); + } + if (governedWait) { + for (const event of governedReplay) { + if (event.turnId === governedWait.sourceEvent.turnId && (event.payload.kind === "usage" || isTurnTerminal(event))) { + await options.controlPlane.appendEvent(event); + } + } + } let consumed = { event: null as PrpEvent | null, eventCount: 0, @@ -2322,7 +2445,10 @@ export async function executeNativeSession( replayedDisposition?.terminal ?? dispositionFallback; const consumptionAbort = new AbortController(); const consuming = - recoveryTerminal === null + governedTerminal && governedWait + ? Promise.resolve({ event: governedWait.sourceEvent, governedResult: governedWait.result, + eventCount: 0, highestContiguousSourceSeq: governedTerminal.sourceSeq }) + : recoveryTerminal === null ? consumeTurn( session, options.controlPlane, @@ -2345,6 +2471,11 @@ export async function executeNativeSession( : undefined, recoveredSnapshot.goal, resumeInterruptedTurn, + async (wait, signal) => { + governedWait = structuredClone(wait); + await checkpoint(signal); + }, + governedWait, ) : Promise.resolve({ event: recoveryTerminal, @@ -2360,12 +2491,12 @@ export async function executeNativeSession( // that later rejection becomes process-fatal under Node's strict policy. void consuming.catch(() => undefined); try { - const shouldStartFreshTurn = + const shouldStartFreshTurn = !governedWait && ( !recovered || (!recoveredActiveTurnId && !adoptedProviderTerminal && !checkpointedDispositionTerminal && - !dispositionRecoveryStillOwned); + !dispositionRecoveryStillOwned)); if (options.sessionGoalControl) { // Explicit controls remain authoritative after controller loss. In // particular, pause/clear/edit must reach a recovered session even diff --git a/server/src/__tests__/adapter-cost-failure-paths.test.ts b/server/src/__tests__/adapter-cost-failure-paths.test.ts index 0c15c0081e4..4703f374321 100644 --- a/server/src/__tests__/adapter-cost-failure-paths.test.ts +++ b/server/src/__tests__/adapter-cost-failure-paths.test.ts @@ -29,6 +29,8 @@ const cases: Array<{ name: string; execute: () => Promise; event: unkno ]; describe("CLI adapter accounting on timeout", () => { + // Keep OpenCode runtime config copies inside each fixture; host config may + // include plugins and large dependency trees unrelated to accounting. const directories: string[] = []; afterEach(async () => { vi.clearAllMocks(); for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); }); it.each(cases.filter(f => ["codex", "cursor", "opencode"].includes(f.name)))("$name preserves malformed optional counters through the checkpoint wrapper", async fixture => { @@ -49,7 +51,7 @@ describe("CLI adapter accounting on timeout", () => { runId: "invalid-counter-run", agent: { id: "agent", companyId: "company", name: "Worker", adapterType: `${fixture.name}_local`, adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { engine: "cli", command, cwd: dir, model: fixture.name === "opencode" ? "openai/test" : "gpt-6-astra", paperclipRuntimeSkills: [], - env: { OPENAI_API_KEY: "fixture", OPENCODE_ALLOW_ALL_MODELS: "1" } }, + env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "fixture", OPENCODE_ALLOW_ALL_MODELS: "1" } }, context: {}, onLog: async () => {}, onUsage, }); const valid = cached === 0 || cached === undefined; @@ -77,7 +79,7 @@ describe("CLI adapter accounting on timeout", () => { runId: "test-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { engine: "cli", command, cwd: dir, model: ["opencode", "pi"].includes(fixture.name) ? "openai/test" : fixture.name === "cursor" ? "gpt-5" : "test", paperclipRuntimeSkills: [], - env: { OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } }, + env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } }, context: {}, onLog: async () => {}, onUsage, }; const execute = await fixture.execute(); @@ -211,7 +213,7 @@ describe("CLI adapter accounting on timeout", () => { const result = await execute({ runId: "tail-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, - config: { engine: "cli", command, cwd: dir, model: "anthropic/test", paperclipRuntimeSkills: [], env: { ANTHROPIC_API_KEY: "fixture", OPENAI_BASE_URL: "https://unrelated-proxy.example/v1", OPENROUTER_API_KEY: "unrelated-key", OPENCODE_ALLOW_ALL_MODELS: "1" } }, + config: { engine: "cli", command, cwd: dir, model: "anthropic/test", paperclipRuntimeSkills: [], env: { XDG_CONFIG_HOME: dir, ANTHROPIC_API_KEY: "fixture", OPENAI_BASE_URL: "https://unrelated-proxy.example/v1", OPENROUTER_API_KEY: "unrelated-key", OPENCODE_ALLOW_ALL_MODELS: "1" } }, context: {}, onLog: async () => {}, onUsage, }); expect(result.usage).toMatchObject({ inputTokens: 40, outputTokens: 20, cachedInputTokens: 200 }); @@ -231,7 +233,7 @@ describe("CLI adapter accounting on timeout", () => { runId: "test-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { engine: "cli", command, cwd: dir, model: ["opencode", "pi"].includes(fixture.name) ? "openai/test" : fixture.name === "cursor" ? "gpt-5" : "test", paperclipRuntimeSkills: [], - env: { OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", MOONSHOT_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } }, + env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", MOONSHOT_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } }, context: {}, onLog: async () => {}, }); expect(result.timedOut).toBe(true); diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts index 40cee41710b..c129a6e092f 100644 --- a/server/src/__tests__/connection-aggregator-fallback.test.ts +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -317,9 +317,34 @@ const support = await getEmbeddedPostgresTestSupport(); expect(result.results.some(item => item.service === "google-sheets")).toBe(true); expect(result.instruction).not.toContain("search its exact name"); }); + it("keeps installed capability matches when a generic query resembles an external app name", async () => { + await resetQuestions(); + const installed = await seedProvider("Studio library", "notion:list_pages"); + await db.update(toolCatalogEntries).set({ description: "Read recent pages and return their titles and verification code" }) + .where(eq(toolCatalogEntries.connectionId, installed.id)); + const service = connectionIntentService(db); + const result = await service.search(claims, + "connected page service that can find or list recent pages and return page titles plus a verification code"); + expect(result.results).toEqual(expect.arrayContaining([expect.objectContaining({ + service: `connection:${installed.id}`, connectionId: installed.id, state: "ready", + })])); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).not.toContain("Ask the responsible user with providerQuestion"); + // A deliberate app name still reaches the existing governed provider choice. + const named = await service.search(claims, "Page X"); + expect(named.providerQuestion?.id).toBe("connection-provider:page-x"); + expect(named.results[0]?.service).toBe("via:composio:page-x"); + }); + it("keeps an external typo as a discovery suggestion until its app is selected", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, + "Find Circlebak meeting transcripts and action items from yesterday"); + expect(result.results.some(item => item.service === "via:composio:circleback-mcp")).toBe(true); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("aggregator.targetService"); + }); it.each([ ["help me find tools for circle back", "circleback-mcp"], - ["Find Circlebak meeting transcripts and action items from yesterday", "circleback-mcp"], ["Can you connect Circleback MCP to get all our meeting notes?", "circleback-mcp"], ["Find Attio tools to review all our customer contacts before next week's meeting", "attio"], ["Help me find a ClickUp connection to organize our team's projects", "clickup"], @@ -583,6 +608,51 @@ const support = await getEmbeddedPostgresTestSupport(); ); expect(result.instruction).toContain("Arcade"); }); + it("returns exact eligible catalog names after a guessed tool without granting access", async () => { + await resetQuestions(); + const connection = await seedProvider("arcade", "Hubspot_ListContacts", "responsible-user", false); + await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id)); + await seedProvider("composio", "Unrelated_PrivateTool", "other-user", false); + await db.insert(toolCatalogEntries).values({ companyId: claims.company_id, connectionId: connection.id, + toolName: "Hubspot_RemovedTool", name: "Removed", versionHash: "old", entryKind: "tool", status: "removed" }); + const answer = await selectProvider("via:arcade:hubspot"); + const service = connectionIntentService(db); + const error = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, toolNames: ["hubspot_list_contacts"], + }).catch(error => error); + expect(error).toMatchObject({ status: 422 }); + expect(error.message).toContain('"Hubspot_ListContacts"'); + expect(error.message).toContain("Request only the needed exact names"); + expect(error.message).not.toMatch(/Unrelated_PrivateTool|Hubspot_RemovedTool/); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]); + const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, claims.company_id)); + expect(interactions.map(row => [row.id, row.status])).toEqual([[answer.id, "answered"]]); + const requested = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, toolNames: ["Hubspot_ListContacts"], + }); + expect(requested.state).toBe("needs_user_action"); + const loaded = await service.loadIntent(requested.interactionId!); + expect(loaded.interaction.payload.accessRequest?.tools.map(tool => tool.toolName)).toEqual(["Hubspot_ListContacts"]); + expect(loaded.interaction.status).toBe("pending"); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]); + }); + it("bounds catalog-name recovery and does not disclose an ineligible connection", async () => { + await resetQuestions(); + const connection = await seedProvider("arcade", "Read_00", "responsible-user", false); + await db.insert(toolCatalogEntries).values(Array.from({ length: 24 }, (_, index) => ({ + companyId: claims.company_id, connectionId: connection.id, toolName: `Read_${String(index + 1).padStart(2, "0")}`, + name: `Read_${String(index + 1).padStart(2, "0")}`, versionHash: "v1", entryKind: "tool" as const, status: "active" as const, + }))); + const service = connectionIntentService(db); + const error = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error); + expect(error).toMatchObject({ status: 422 }); + expect(error.message).toContain("first 20 of 25"); + expect(error.message.match(/Read_\d{2}/g)).toHaveLength(20); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, connection.id)); + const denied = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error); + expect(denied.message).not.toContain("Read_"); + expect(denied.message).toContain("not eligible"); + }); it("does not switch providers when the chosen route loses permission", async () => { await resetQuestions(); const answer = await selectProvider("via:arcade:hubspot"); diff --git a/server/src/services/anthropic-pricing.test.ts b/server/src/services/anthropic-pricing.test.ts new file mode 100644 index 00000000000..760b18d2eef --- /dev/null +++ b/server/src/services/anthropic-pricing.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; +import { priceAnthropicReceipt } from "./anthropic-pricing.js"; + +const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run", provider: "anthropic", biller: "anthropic", + billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced", + usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } }; +describe("complete Anthropic receipt pricing", () => { + it("prices complete counters conservatively and labels their provenance as an estimate", () => { + const priced = priceAnthropicReceipt(receipt); + expect(priced.costUsdExact).toBe("0.155967600"); + expect(priced.costStatus).toBe("estimated"); + expect(priced.pricingProvenance).toMatchObject({ source: "rate_card", version: "anthropic-standard-2026-10-07" }); + expect(priced.pricingProvenance?.evidence).toContain("one-hour upper rate"); + expect(priced.usage).toBe(receipt.usage); + expect(receipt.costStatus).toBe("unpriced"); + }); + it.each([ + { complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" }, + { provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, + { usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 }, + { pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] }, + { usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } }, + { usage: { inputTokens: 1, outputTokens: 1, cacheWriteTokens: 2, cachedInputTokens: 0 } }, + ] as Partial[])("preserves incomplete, incompatible or already priced receipts: %j", override => { + const original = { ...receipt, ...override }; + expect(priceAnthropicReceipt(original)).toBe(original); + }); +}); diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts new file mode 100644 index 00000000000..6a9d3cfdae3 --- /dev/null +++ b/server/src/services/anthropic-pricing.ts @@ -0,0 +1,31 @@ +import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared"; +import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; + +// Direct Anthropic API list prices, verified 2026-10-07: +// https://platform.claude.com/docs/en/about-claude/pricing +// Keep this bounded to the qualified model; aliases and other billers stay unknown. +const RATES = ["2", "0.2", "4", "10"] as const; + +/** A complete token receipt can receive an explicit estimate, never an invoice + * claim. ACP aggregates cache writes without TTL; conservatively price them at + * the documented one-hour rate rather than silently assume five-minute writes. */ +export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint { + if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run" + || receipt.provider !== "anthropic" || receipt.biller !== "anthropic" + || !["api", "metered_api"].includes(receipt.billingType ?? "") + || receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length + || receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null + || (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt; + const usage = receipt.usage; + if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt; + const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens]; + if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt; + const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n); + return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated", + pricingProvenance: { + source: "rate_card", version: "anthropic-standard-2026-10-07", + evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice", + inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard", + }, + }; +} diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index c53a42b76f5..68005f19f4c 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -466,7 +466,8 @@ export function connectionIntentService(db: Db) { } const publicMatches = searchAggregatorServices(preparedQuery); const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100)); - const publicService = findAggregatorService(serviceQuery, publicMatches) ?? (bestMatches.length === 1 ? bestMatches[0]!.service : undefined); + const namedPublicService = findAggregatorService(serviceQuery, publicMatches); + const publicService = namedPublicService ?? (bestMatches.length === 1 ? bestMatches[0]!.service : undefined); // Extract service namespaces only from the current identity's indexed tools. // Generic provider search/execute descriptions do not prove app support. const indexedServices = new Set(); @@ -553,6 +554,14 @@ export function connectionIntentService(db: Db) { return alternatives; } const alternatives = await aggregatorAlternatives(targetService, targetName, publicService); + // A typo/prefix match is a discovery hint, not a selected app. Generic + // capabilities (e.g. "pages") can resemble a catalog name ("Page X"). + // Retain authorized installed matches instead of prescribing provider consent. + const namedTarget = namedPublicService !== undefined + || indexedMatches.some(match => match.slug === targetService && match.nameScore >= 500); + if (alternatives.length && !namedTarget) { + return discoverySuggestions([...ranked(), ...alternatives]); + } if (explicit && explicitConsent) { const selected = alternatives.find(item => item.aggregator?.provider === explicit.provider); if (!selected) return { version: 1, query, results: [], instruction: "The explicitly requested external provider is unavailable or its app support could not be verified. Explain the limitation. Do not switch providers automatically." }; @@ -586,7 +595,7 @@ export function connectionIntentService(db: Db) { function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult { return { version: 1, query, results: results.slice(0, 40), - instruction: "Multiple apps match this query. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." }; + instruction: "These are possible connection matches. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." }; } } @@ -730,7 +739,12 @@ export function connectionIntentService(db: Db) { const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool"); const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName)); const tools = names.map(name => catalog.find(tool => tool.toolName === name)); - if (options.toolNames && tools.some(tool => !tool)) throw unprocessable("A requested tool is not in this connection's active catalog"); + if (options.toolNames && tools.some(tool => !tool)) { + // Return discovery metadata only after validating this connection's + // eligibility. Never substitute guessed names or grant access here. + const availableNames = catalog.map(tool => tool.toolName).sort(); + throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`); + } const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id); const installed = effective.installedConnections.some(item => item.id === connection.id); const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id)); diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts index e52702dd173..d49a5cc1339 100644 --- a/server/src/services/native-runtime/native-completion-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -54,6 +54,47 @@ describe("native final-response feedback", () => { await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, interactionId)); await expect(nativeCompletionFeedback(db, value.runId, waiting)).resolves.toContain("Completion report accepted"); }); + it.each(["blocked", "needs_review", "yielded"] as const)("rejects a %s approval report bound to an already declined invocation", async (disposition) => { + const value = await fixture(), interactionId = randomUUID(), invocationId = randomUUID(), actionRequestId = randomUUID(); + await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: value.companyId, issueId: value.issueId, + sourceRunId: value.runId, createdByAgentId: value.agentId, kind: "request_confirmation", status: "rejected", + continuationPolicy: "wake_assignee", result: { version: 1, outcome: "rejected" }, + payload: { version: 1, prompt: "Approve service read", toolAction: { version: 1, actionRequestId, invocationId, toolName: "pages.read", toolDisplayName: "Read pages", connectionId: null, applicationId: null, appDisplayName: null, risk: "read", previewMarkdown: "Read pages", argumentsSummaryJson: "{}", argumentsHash: "test-hash", expiresAt: "2026-10-07T12:00:00Z" } }, + }); + // The provider can name its exact invocation in the summary without citing + // an interaction evidence ref, as in the retained OpenCode failure. + const report: PrpStructuredRunResult = { ...done, reportedWorkDisposition: disposition, + summary: disposition === "blocked" ? "Waiting on the page-service approval." : `Waiting on the page-service approval (invocationId ${invocationId}).`, + ...(disposition === "blocked" ? { blocker: { reasonCode: "approval_required", owner: { kind: "user" as const, name: "Release Owner" }, scope: "task_wide" as const, unblockAction: `Resolve invocation ${invocationId}` } } : {}), + completionClaim: { ...done.completionClaim, objectiveSatisfied: false, remainingWork: [{ description: "Wait for the decision", blocksCompletion: true }] }, + attentionRequests: [{ kind: "approval", ownerClass: "human", summary: "Approve the page-service call" }], + ...(disposition === "yielded" ? { continuation: { kind: "response_wake" as const, idempotencyKey: "wait", summary: "Wait for approval" } } : {}), + }; + await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("already resolved"); + // A blocked report may name the exact action only in an unmet criterion. + const criterionOnly: PrpStructuredRunResult = { ...report, blocker: undefined, + summary: "Await the requested approval", evidence: [{ ref: `approval:${actionRequestId}` }], + completionClaim: { ...report.completionClaim, criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [`approval:${actionRequestId}`] }] }, + }; + await expect(nativeCompletionFeedback(db, value.runId, criterionOnly)).rejects.toThrow("already resolved"); + // A provider can mark the approval criterion unknown while citing the exact + // declined interaction; that still is not a new review target. + await expect(nativeCompletionFeedback(db, value.runId, { + ...criterionOnly, completionClaim: { ...criterionOnly.completionClaim, criteria: [{ criterionId: "objective", status: "unknown", evidenceRefs: [`interaction:${interactionId}`] }] }, + })).rejects.toThrow("already resolved"); + // Satisfied historical evidence does not bind the independent review target. + if (disposition !== "yielded") await expect(nativeCompletionFeedback(db, value.runId, { + ...criterionOnly, completionClaim: { ...criterionOnly.completionClaim, criteria: [{ criterionId: "past-action", status: "satisfied", evidenceRefs: [`approval:${actionRequestId}`] }] }, + })).resolves.toContain("report accepted"); + // Completed-action evidence is not the target of an independent review. + if (disposition !== "yielded") await expect(nativeCompletionFeedback(db, value.runId, { + ...report, blocker: undefined, summary: "Review a separate deliverable", evidence: [{ ref: `interaction:${interactionId}` }], + })).resolves.toContain("report accepted"); + // Neither a similar description nor another run's card is identity proof. + await expect(nativeCompletionFeedback(db, value.runId, { ...report, blocker: undefined, summary: "Wait for a separate approval" })).resolves.toContain("report accepted"); + await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, interactionId)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("report accepted"); + }); it.each(["accepted", "rejected"] as const)("allows a new question after a %s tool approval cited as completed evidence", async (status) => { const value = await fixture(), interactionId = randomUUID(), questionId = randomUUID(); await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: value.companyId, issueId: value.issueId, diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index d814ca3a232..9bbb9d39d26 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -176,21 +176,31 @@ export async function nativeCompletionFeedback( .limit(1) .then((rows) => rows[0]), ]); - // A response wake for this run's tool action already has a durable approval - // surface. Reject a repeated approval request before it becomes a new review. - // General evidence can also cite completed actions while waiting on something - // else, so a resolved card alone is not a stale wait target. - if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "response_wake") { - const referencedIds = result.evidence.flatMap(({ ref }) => { - const match = typeof ref === "string" ? /^interaction:([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.exec(ref) : null; - return match ? [match[1]!] : []; - }); - const referenced = referencedIds.length ? await db.select().from(issueThreadInteractions).where(and( + // Match an existing action by its exact durable identity, regardless of how + // the provider labels its final disposition. A summary can cite the invocation + // ID instead of an interaction evidence ref; prose similarity is not identity. + if (signals.actionableAttentionRequests.some(request => request.kind === "approval" && request.ownerClass === "human")) { + const approvalTarget = { + summary: result.summary, + attentionRequests: signals.actionableAttentionRequests.filter(request => request.kind === "approval" && request.ownerClass === "human"), + blocker: result.blocker, + // An unmet criterion can name the action still being requested. Completed + // evidence elsewhere in the report is not authority to suppress a review. + unmetCriteria: result.completionClaim.criteria.filter(criterion => criterion.status !== "satisfied"), + continuation: result.continuation, + // The existing response-wake contract also names its card through evidence. + evidence: result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "response_wake" ? result.evidence : [], + }; + const referencedIds = new Set(JSON.stringify(approvalTarget).match(/\b[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\b/gi)?.map(id => id.toLowerCase()) ?? []); + const cards = referencedIds.size ? await db.select().from(issueThreadInteractions).where(and( eq(issueThreadInteractions.companyId, run.companyId), eq(issueThreadInteractions.issueId, issue.id), eq(issueThreadInteractions.sourceRunId, run.id), eq(issueThreadInteractions.createdByAgentId, run.agentId), eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.continuationPolicy, "wake_assignee"), - inArray(issueThreadInteractions.id, referencedIds), )) : []; + const referenced = cards.filter(card => { + const action = record(record(card.payload).toolAction); + return [card.id, action.actionRequestId, action.invocationId].some(id => typeof id === "string" && referencedIds.has(id.toLowerCase())); + }); for (const card of referenced) { const action = record(record(card.payload).toolAction); if (action.version !== 1 || typeof action.actionRequestId !== "string") continue; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 3fbe173df1e..f165e004b43 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -5141,6 +5141,62 @@ describe("native startup cancellation fence", () => { }); describe("native startup restart detachment", () => { + it("waits for a checkpointed governed settlement before relinquishing its controller", async () => { + const restarting = structuredClone(execution); + restarting.binding.runId = "restart-governed-settlement"; + let release!: () => void, announce!: () => void; + const accounting = new Promise(resolve => { release = resolve; }); + const checkpointed = new Promise(resolve => { announce = resolve; }); + const detach = vi.fn(async () => undefined); + state.execute.mockReset().mockImplementationOnce(async options => { + await options.onSession({ detachControllerForRestart: detach }); + await options.onCheckpoint({ governedWait: { sourceEvent: { turnId: "settling-turn" } } }); + announce(); + await accounting; + await options.onSession(null); + throw new Error("settlement test complete"); + }); + const outcome = executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting, runnerInstanceId: "runner" }).catch(error => error); + await checkpointed; + const detached = detachNativeSessionsForRestart([restarting.binding.runId]); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(detach).not.toHaveBeenCalled(); + release(); + await outcome; + expect(await detached).toMatchObject({ inactiveRunIds: [restarting.binding.runId] }); + expect(detach).not.toHaveBeenCalled(); + } finally { release(); await outcome; await detached; } + }); + it("bounds governed settlement without allowing the detached controller to publish success", async () => { + const restarting = structuredClone(execution); + restarting.binding.runId = "restart-governed-settlement-timeout"; + let release!: () => void, announce!: () => void; + const accounting = new Promise(resolve => { release = resolve; }); + const checkpointed = new Promise(resolve => { announce = resolve; }); + const detach = vi.fn(async () => undefined); + const onUsage = vi.fn(async () => undefined); + state.execute.mockReset().mockImplementationOnce(async options => { + await options.onSession({ detachControllerForRestart: detach }); + await options.onCheckpoint({ governedWait: { sourceEvent: { turnId: "settling-turn" } } }); + announce(); await accounting; + await options.onSession(null); + return { result: {}, terminal: { runTerminalState: "succeeded" }, usage: null }; + }); + const outcome = executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting, runnerInstanceId: "runner", onUsage }).catch(error => error); + await checkpointed; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const detaching = detachNativeSessionsForRestart([restarting.binding.runId]); + try { + await vi.advanceTimersByTimeAsync(19_999); + expect(detach).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await detaching).toMatchObject({ detachedRunIds: [restarting.binding.runId] }); + release(); + expect(await outcome).toBeInstanceOf(NativeControllerDetachedForRestartError); + expect(onUsage).not.toHaveBeenCalled(); + } finally { vi.useRealTimers(); release(); await outcome; await detaching; } + }); it("waits for in-flight runner startup and its detach acknowledgement before shutdown returns", async () => { const root = await mkdtemp(join(tmpdir(), "native-startup-detach-")); const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR; @@ -5186,6 +5242,24 @@ describe("native startup restart detachment", () => { } }); + it("rejects a governed completion returned successfully by an already detached controller", async () => { + const restarting = structuredClone(execution); + restarting.binding.runId = "restart-successful-old-consumer"; + const detach = vi.fn(async () => undefined); + const onUsage = vi.fn(async () => undefined); + state.execute.mockReset().mockImplementationOnce(async options => { + await options.onSession({ detachControllerForRestart: detach }); + await detachNativeSessionsForRestart([restarting.binding.runId]); + await options.onSession(null); + return { result: {}, + terminal: { runTerminalState: "succeeded" }, usage: null }; + }); + await expect(executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting, + runnerInstanceId: "runner", onUsage })).rejects.toBeInstanceOf(NativeControllerDetachedForRestartError); + expect(detach).toHaveBeenCalledOnce(); + expect(onUsage).not.toHaveBeenCalled(); + }); + it.each([false, true])("settles failed startup without claiming detachment (deadline exceeded: %s)", async (exceedDeadline) => { const root = await mkdtemp(join(tmpdir(), "native-startup-failure-")); const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR; @@ -5240,6 +5314,23 @@ describe("native startup restart detachment", () => { }); describe("native terminal-turn accounting", () => { + it.each([false, true])("prices only complete direct Claude API turn accounting (%s)", async complete => { + const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live"); + const { priceAnthropicReceipt } = await import("../anthropic-pricing.js"); + const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {}); + const counts = { inputTokens: 12, outputTokens: 4, cacheReadTokens: 30, cacheWriteTokens: 20, providerCostUsd: 0 }; + state.execute.mockReset().mockImplementationOnce(async () => { + const notification = rehydrateRunnerdUsageNotification({ provider: "acpx", cumulative: { ...counts, providerCostUsd: 10 }, runDelta: counts, runDeltaAvailable: complete }, "session", "turn"); + await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", payload: { kind: "usage", usage: notification.tokenUsage } } as unknown as PrpEvent); + await accountingEvents.committed!({ eventType: "turn.interrupted", turnId: "turn", payload: {} } as unknown as PrpEvent); + return { result: { summary: "Waiting for approval" }, terminal: { runTerminalState: "succeeded" }, turnId: "turn", normalizedSessionId: "session", providerSessionId: null, driverKind: "acpx_runtime", driverVersion: "1", nativeEventCount: 2, highestContiguousSourceSeq: 2, usage: null }; + }); + await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "acpx", agent: "claude", model: "claude-sonnet-5" } } as NativeExecutionInput, runnerInstanceId: "runner", runnerEnvironment: { ANTHROPIC_API_KEY: "fixture" }, onUsage }); + const receipt = onUsage.mock.calls.at(-1)![0]; + const priced = priceAnthropicReceipt(receipt); + if (complete) expect(priced).toMatchObject({ complete: true, costStatus: "estimated", costUsdExact: "0.000150000" }); + else expect(priced).toMatchObject({ complete: false, costStatus: "unpriced", costUsd: null }); + }); it.each(["claude", "codex"].flatMap(agent => [false, true].flatMap(restart => [false, true].flatMap(partialFirst => [false, true].map(zeroFirst => ({ agent, restart, partialFirst, zeroFirst }))), )))("accumulates ACPX turns for $agent (restart: $restart, partial: $partialFirst, zero first: $zeroFirst)", async ({ agent, restart, partialFirst, zeroFirst }) => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index fec05347384..997845791cc 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -242,6 +242,9 @@ type NativeSessionStartup = { resolve: (session: ActiveNativeSession | null) => void; stopRequested?: boolean; cancellationSettled?: Promise; + governedWait?: boolean; + settled: Promise; + settle: () => void; }; const nativeSessionStartups = new Map(); @@ -279,7 +282,20 @@ export async function detachNativeSessionsForRestart( const detachedRunIds: string[] = []; const inactiveRunIds: string[] = []; const unsupportedRunIds: string[] = []; + const settlementDeadline = Date.now() + 20_000; for (const runId of new Set(runIds)) { + // A governed stop already revoked new work and is collecting terminal + // accounting. Give that exact owner a bounded chance to persist it before + // fencing callbacks and relinquishing the runner. Timeout is not success. + const settling = nativeSessionStartups.get(runId); + if (settling?.governedWait) { + let timer: ReturnType | undefined; + try { + await Promise.race([settling.settled, new Promise(resolve => { + timer = setTimeout(resolve, Math.max(0, settlementDeadline - Date.now())); + })]); + } finally { clearTimeout(timer); } + } nativeRunsDetachingForRestart.add(runId); const active = activeNativeSessions.get(runId) ?? await waitForNativeSessionStartup(runId); if (!active) { @@ -6002,6 +6018,7 @@ function loadWarmNativeCheckpoint( activeTurnId: null, terminalTurns: [], pendingRuntimeRequests: [], + governedWait: undefined, }; if (path !== scopedPath || envelope.configDigest !== configDigest) { // Upgrade the validated checkpoint atomically. When moving from a legacy @@ -7381,7 +7398,10 @@ export async function executePaperclipNativeSession(input: { // Register before the first asynchronous operation on either backend path. // A duplicate execution must not replace the original startup handoff. let resolveStartup!: (session: ActiveNativeSession | null) => void; + let settle!: () => void; const startup: NativeSessionStartup = { + settled: new Promise(resolve => { settle = resolve; }), + settle: () => settle(), promise: new Promise(resolve => { resolveStartup = resolve; }), resolve: session => resolveStartup(session), }; @@ -7478,6 +7498,7 @@ export async function executePaperclipNativeSession(input: { executingNativeOwnerScopes.delete(ownerScope); } startup.resolve(null); + startup.settle(); if (nativeSessionStartups.get(runId) === startup) { nativeSessionStartups.delete(runId); } @@ -7993,6 +8014,11 @@ async function executePaperclipNativeSessionWithinScope( } }; let completedConversationReply: PrpEvent | null = null; + const assertControllerActive = () => { + if (nativeRunsDetachingForRestart.has(input.execution.binding.runId)) { + throw new NativeControllerDetachedForRestartError(); + } + }; const controlPlane = new PaperclipControlPlanePort( input.db, { @@ -8008,6 +8034,7 @@ async function executePaperclipNativeSessionWithinScope( }, { privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY, + assertControllerActive, onCommittedEvent: async (event) => { await observeAccountingEvent(event); await toolTrace.observe(event); @@ -8541,8 +8568,15 @@ async function executePaperclipNativeSessionWithinScope( controlPlane, runnerInstanceId: effectiveRunnerInstanceId, controlPlaneInstanceId, - resolveGovernedWait: ({ event }) => - governedWaitObservation.consume(event), + resolveGovernedWait: ({ event }) => { + assertControllerActive(); + const result = governedWaitObservation.consume(event); + if (result) { + const startup = nativeSessionStartups.get(input.execution.binding.runId); + if (startup) startup.governedWait = true; + } + return result; + }, resolveMissingResult: async ({ terminalEvent }) => { // Governed waits take precedence over an ordinary chat reply. // Execution tasks still require their normal semantic finish. @@ -8581,6 +8615,10 @@ async function executePaperclipNativeSessionWithinScope( requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { + if (snapshot.governedWait) { + const startup = nativeSessionStartups.get(input.execution.binding.runId); + if (startup) startup.governedWait = true; + } snapshot = identityRedactor.redact(snapshot); if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( @@ -8774,6 +8812,9 @@ async function executePaperclipNativeSessionWithinScope( }, { parentName: "task.run" }, ); + // A detached consumer can resolve successfully after its stream closes. + // Only the replacement controller may settle the run or certify accounting. + assertControllerActive(); // Persist provider accounting before any workspace/issue finalization. A // detached controller or failed finalizer must not lose a completed turn. // session.usage() may be an attachment baseline, a partial report, or a diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 60290408caf..574e0dda15e 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -2125,10 +2125,17 @@ describe("rebindNativeSessionCheckpoint", () => { }); it("retains provider identity but clears prior turn and event state", () => { + const prior = previousRun(); + const profile = prior.runnerProfileJson as Record; + const checkpoint = profile.sessionCheckpoint as Record; + checkpoint.governedWait = { sourceEvent: { runId: previousRunId, turnId: "old-turn" }, result: { reportedWorkDisposition: "yielded" } }; + const before = structuredClone(prior); const rebound = rebindNativeSessionCheckpoint({ - previousRun: previousRun(), + previousRun: prior, currentExecution: execution(currentRunId), }); + expect(rebound?.governedWait).toBeUndefined(); + expect(prior).toEqual(before); expect(rebound).toMatchObject({ sessionId: "provider-thread-123", providerSessionId: "provider-thread-123", diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index c7090ea8639..051fbc0cf22 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -568,6 +568,9 @@ export function rebindNativeSessionCheckpoint(input: { activeTurnId: null, terminalTurns: [], pendingRuntimeRequests: [], + // Settlement evidence belongs to the prior heartbeat run, even when the + // provider session is reused for the user's response. + governedWait: undefined, providerRecoveryPolicy, }; } diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index f6651aedc88..129ffdcf23a 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -299,6 +299,67 @@ describe("PaperclipControlPlanePort conformance", () => { } }); + it.each([ + ["checkpoint", "detach"], ["result", "detach"], ["event", "detach"], + ["checkpoint", "abort"], ["result", "abort"], ["event", "abort"], + ] as const)("revokes an in-flight %s on %s", async (operation, reason) => { + const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; + const runnerId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: identity.runId, companyId: identity.companyId, agentId: identity.agentId, + status: "running", runtimeMode: "native", nativeIssueId: identity.issueId, + nativeSessionId: identity.sessionId, runnerInstanceId: runnerId, + completionContractId: contractId, completionContractSha256: contractSha, + contextSnapshot: { issueId: identity.issueId }, + }); + const binding = { ...identity, completionContractId: contractId, completionContractSha256: contractSha, + sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `detached-${identity.runId}` }; + let detached = false; + const controller = new AbortController(); + const port = new PaperclipControlPlanePort(db, binding, { + assertControllerActive: () => { if (detached) throw new Error("controller_detached"); }, + }); + await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + const mutate = () => operation === "event" ? port.appendEvent({ + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, eventType: "turn.interrupted", schemaVersion: 1, + priority: 0, emittedAt: new Date().toISOString(), payload: { reason: "governed_wait" }, + }, { signal: controller.signal }) : operation === "checkpoint" + ? port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity }, { signal: controller.signal }) + : port.completeRun({ result: CONTROL_PLANE_CONFORMANCE_RESULT, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL }, { signal: controller.signal }); + let pending: Promise | undefined; + await db.transaction(async tx => { + await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)).for("update"); + const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>; + pending = mutate().then(() => null, error => error); + let waiting = false; + for (let attempt = 0; attempt < 100; attempt++) { + const [state] = await db.execute(sql`select exists ( + select 1 from pg_stat_activity where ${backend.pid} = any(pg_blocking_pids(pid)) + ) as waiting`) as unknown as Array<{ waiting: boolean }>; + if (state.waiting) { waiting = true; break; } + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(waiting).toBe(true); + if (reason === "detach") detached = true; + else controller.abort(new Error("controller_detached")); + }); + expect(await pending).toMatchObject({ message: "controller_detached" }); + await expect(mutate()).rejects.toThrow("controller_detached"); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)); + expect(run.runnerProfileJson?.sessionCheckpoint).toBeUndefined(); + expect(run.nextEventSeq).toBe(1); + expect(await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId))).toEqual([]); + expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, identity.runId))).toEqual([]); + expect(await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, identity.runId))).toEqual([]); + // A new controller's independently authorized port can still settle it. + const replacement = new PaperclipControlPlanePort(db, binding); + await replacement.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + await expect(replacement.completeRun({ result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL })).resolves.toBeUndefined(); + }); + it("redacts identity private material before persisting native events and checkpoints", async () => { const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; const runnerId = randomUUID(); diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index e41fd522c8e..bfd1cdc56b2 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -80,6 +80,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { #redactedDeltaBytes = 0; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; + readonly #assertControllerActive?: () => void; readonly #onCommittedEvent?: (event: PrpEvent) => Promise; readonly #onDuplicateEvent?: (event: PrpEvent) => Promise; @@ -89,17 +90,25 @@ export class PaperclipControlPlanePort implements ControlPlanePort { options: { /** Runtime-only secret; never part of the persisted binding. */ privateKeyPem?: string; + /** Synchronous revocation fence for a controller handing off on restart. */ + assertControllerActive?: () => void; onCommittedEvent?: (event: PrpEvent) => Promise; onDuplicateEvent?: (event: PrpEvent) => Promise; } = {}, ) { this.#db = db; + this.#assertControllerActive = options.assertControllerActive; this.#identityRedactor = createAgentIdentityRedactor(options.privateKeyPem); this.#binding = structuredClone(binding); this.#onCommittedEvent = options.onCommittedEvent; this.#onDuplicateEvent = options.onDuplicateEvent; } + #assertActive(options?: { signal: AbortSignal }): void { + this.#assertControllerActive?.(); + options?.signal.throwIfAborted(); + } + #matchesPersistedBinding(run: typeof heartbeatRuns.$inferSelect): boolean { return run.companyId === this.#binding.companyId && run.agentId === this.#binding.agentId @@ -112,6 +121,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } async openRun(input: OpenControlPlaneRunInput): Promise { + this.#assertControllerActive?.(); const identity = input.identity; if ( identity.companyId !== this.#binding.companyId @@ -134,6 +144,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { if (!run || !this.#matchesPersistedBinding(run)) { throw new Error("native_open_run_not_authorized"); } + this.#assertControllerActive?.(); this.#sessionId = identity.sessionId; } @@ -160,7 +171,8 @@ export class PaperclipControlPlanePort implements ControlPlanePort { return structuredClone(snapshot as PersistedNativeSession); } - async checkpointSession(snapshot: PersistedNativeSession): Promise { + async checkpointSession(snapshot: PersistedNativeSession, options?: { signal: AbortSignal }): Promise { + this.#assertActive(options); snapshot = this.#identityRedactor.redact(snapshot); const identity = snapshot.identity; if ( @@ -174,6 +186,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { const run = await tx.select().from(heartbeatRuns) .where(eq(heartbeatRuns.id, this.#binding.runId)).for("update").limit(1) .then((rows) => rows[0] ?? null); + this.#assertActive(options); if (!run || !this.#matchesPersistedBinding(run)) { throw new Error("native_session_checkpoint_binding_mismatch"); } @@ -187,10 +200,12 @@ export class PaperclipControlPlanePort implements ControlPlanePort { nativePhaseUpdatedAt: new Date(), updatedAt: new Date(), }).where(eq(heartbeatRuns.id, this.#binding.runId)); + this.#assertActive(options); }); } - async appendEvent(value: NativeRunEvent | PrpEvent) { + async appendEvent(value: NativeRunEvent | PrpEvent, options?: { signal: AbortSignal }) { + this.#assertActive(options); if (!isPrpEvent(value)) throw new Error("native_legacy_event_not_supported"); const validated = validatePrpEvent(value); if (!validated.ok) throw new Error(`native_event_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`); @@ -235,22 +250,30 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } } } - const persisted = await appendHeartbeatRunEvent(this.#db, { - companyId: this.#binding.companyId, - runId: this.#binding.runId, - agentId: this.#binding.agentId, - eventType: event.eventType, - stream: "system", - level: event.eventType.includes("failed") ? "error" : "info", - payload: { prpEvent: event as unknown as Record }, - nativeSource: { - sourceInstanceId: event.sourceInstanceId, - sourceEventId: event.sourceEventId, - sourceSeq: event.sourceSeq, - protocolSchemaVersion: event.schemaVersion, - canonicalPayload: event as unknown as Record, - }, + const persisted = await this.#db.transaction(async tx => { + this.#assertActive(options); + const receipt = await appendHeartbeatRunEvent(tx as unknown as Db, { + companyId: this.#binding.companyId, + runId: this.#binding.runId, + agentId: this.#binding.agentId, + eventType: event.eventType, + stream: "system", + level: event.eventType.includes("failed") ? "error" : "info", + payload: { prpEvent: event as unknown as Record }, + nativeSource: { + sourceInstanceId: event.sourceInstanceId, + sourceEventId: event.sourceEventId, + sourceSeq: event.sourceSeq, + protocolSchemaVersion: event.schemaVersion, + canonicalPayload: event as unknown as Record, + }, + }); + // The nested append has no publication side effects. Revocation rolls + // back its event and sequence allocation before the outer commit. + this.#assertActive(options); + return receipt; }); + this.#assertActive(options); if (persisted.disposition === "committed") { publishChatPublicationCommitSignal({ companyId: this.#binding.companyId, @@ -305,7 +328,8 @@ export class PaperclipControlPlanePort implements ControlPlanePort { return { events, highestContiguousSourceSeq: cursor }; } - async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput): Promise { + async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput, options?: { signal: AbortSignal }): Promise { + this.#assertActive(options); value = this.#identityRedactor.redact(value); if (!isCompleteInput(value)) throw new Error("native_structured_result_required"); // Capture compatibility diagnostics before canonical validation removes @@ -341,6 +365,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { const run = await tx.select().from(heartbeatRuns) .where(eq(heartbeatRuns.id, this.#binding.runId)).for("update").limit(1) .then((rows) => rows[0] ?? null); + this.#assertActive(options); if (!run || !this.#matchesPersistedBinding(run)) { throw new Error("native_result_binding_mismatch"); } @@ -352,6 +377,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { .where(and(eq(nativeRunResults.runId, this.#binding.runId), or(...callerConditions))) .limit(1).then((rows) => rows[0] ?? null); if (existing) { + this.#assertActive(options); if (existing.canonicalSha256 !== canonicalSha256) throw new Error("structured_result_replay_conflict"); return; } @@ -404,6 +430,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { }, updatedAt: new Date(), }).where(eq(heartbeatRuns.id, this.#binding.runId)); + this.#assertActive(options); }); } } diff --git a/server/src/services/usage-receipts.ts b/server/src/services/usage-receipts.ts index 7d02f0c047c..3606cd3a0ce 100644 --- a/server/src/services/usage-receipts.ts +++ b/server/src/services/usage-receipts.ts @@ -1,3 +1,4 @@ +import { priceAnthropicReceipt } from "./anthropic-pricing.js"; import { priceCodexReceipt } from "./codex-pricing.js"; import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; @@ -237,8 +238,8 @@ export async function createRunUsageRecorder(db: Db, input: { companyId: string; const attempt = parsed.attemptId ?? currentAttempt; if (!attempts.has(attempt)) currentAttempt = attempt; const previous = attempts.get(attempt); - const priced = priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage, - complete: parsed.complete && !(previous?.usage && !parsed.usage) }); + const priced = priceAnthropicReceipt(priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage, + complete: parsed.complete && !(previous?.usage && !parsed.usage) })); priced.pricingProvenance ??= { source: priced.costUsd != null || priced.costUsdExact != null || priced.cacheAdjustedCostUsd != null ? "provider_reported" : "unknown", version: "accounting-receipt/v1", From 50b1f95e79308d87c1a4f47656f41e634b890b25 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 12:48:01 -0700 Subject: [PATCH 21/55] fix(tool-gateway): keep MCP connection healthy on oversized/malformed responses (#15462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents call remote MCP tools through the Paperclip tool gateway > - The gateway keeps a health status for each remote MCP connection, and it hides the tools of a connection that has `healthStatus = "error"` > - One oversized, malformed, or invalid-JSON `tools/call` reply set the whole connection to `error` > - Health goes back to `ok` only after a successful call, but the hidden tools prevent that call, so the connection stayed locked until a person reconnected it > - This pull request makes these reply errors fail only the one call, and it starts a new MCP session for the caller on the next call > - The benefit is that one large or bad reply no longer disconnects a working connection for every agent ## Linked Issues or Issue Description No public issue exists. Related open PRs fix other health downgrades in the same function. They do not overlap with this change: - Refs #11910 (timeouts and JSON-RPC errors) - Refs #15325 (transport errors) **What happened?** An agent called a remote MCP tool that returned more than `MAX_REMOTE_MCP_RESPONSE_BYTES` (1 MB). The gateway returned 502 `mcp_remote_response_too_large`. It also set the connection to `healthStatus = "error"`. Then `connectedMcpConnectionFilter` hid all tools of the connection (except `per_user` connections), and `connections_search` showed the connection as `needs_user_action`. The `malformed_response` and `invalid_json` errors from `readMcpHttpResponse` did the same thing. `readMcpHttpResponse` can also cancel the reply stream before the end. The remote server can then close its MCP session. The gateway kept the cached `mcp-session-id` for up to 30 minutes and cleared it only after a 404. The next calls then failed with "Remote MCP session expired". **Expected behavior** A reply that is too large or not correct fails only that call. The connection stays healthy, and its tools stay visible. The next call uses a new MCP session. **Steps to reproduce** 1. Add a remote MCP connection with `mcpSessionRequired: true`. 2. Call a tool that returns a reply larger than 1 MB. 3. Look at the connection: `healthStatus` is `error`. 4. Start a new gateway session: the tools of the connection are not in the list. **Paperclip version or commit** `master` at `99a9de9940bf5974352d9dbfbb2f21e62e89689f` **Deployment mode** All modes. The fault is in the server tool gateway. ## What Changed - `server/src/services/tool-gateway.ts`: `too_large`, `malformed_response`, and `invalid_json` from `readMcpHttpResponse` now fail only the call. The caller gets the same 502 reason code as before. The gateway does not call `markRemoteConnectionHealth(…, "error")` for these errors. The `invalid_json` branch after `JSON.parse(body)` also does not change health now, so all `invalid_json` paths are the same. - The `mcp_remote_response_too_large` message now tells the caller to request a smaller result, for example a narrower query or a smaller page size. The error details now include `maxBytes`. - `server/src/services/mcp-http.ts`: new `forgetMcpHttpSession()`. It removes only the cached session for one scope and one credential set, and only while that entry still holds the session ID that failed. It does not touch other agents, sessions that are initializing, or a newer session that replaced the failed one. - The gateway calls `forgetMcpHttpSession()` after these reply errors, so the next call from that caller initializes a new session. The existing 404 "session expired" path now uses the same function. Before, both paths cleared all sessions and all pending initializations on the connection. That made a concurrent initialization by another agent fail with "MCP connection changed while initializing", which the gateway reported as a fetch failure and marked as a connection `error`. - `forgetMcpHttpSessions(connectionId)` is not changed. Disconnect and revocation still clear the full connection. Why `malformed_response` and `invalid_json` are also per-call errors: - Each error is about one reply body. The server was reachable, accepted the credentials, and sent HTTP 2xx. A reply can be too large or bad because of the tool and its arguments. That is not a fault of the connection. - The other malformed-reply checks in the same function (payload is not an object, or has no `result`) already throw `remote_mcp_malformed_response` and do not change health. Only the reader-level errors changed health. - Health recovers only after a successful call. An `error` status for one bad reply therefore locks the connection until a person reconnects it. - Other failures (HTTP errors, fetch failures, timeouts, JSON-RPC errors) still change health. This PR does not change them. #11910 and #15325 address some of them. ## Verification - `server/src/__tests__/tool-gateway.test.ts`: the recovery test now runs for three replies: oversized, invalid JSON, and a reply without the requested message ID. Each run uses a fake HTTP MCP server with `mcpSessionRequired: true` that gives `session-N` for each `initialize`. Each run checks that: - the call fails with the correct 502 reason code (and, for the oversized reply, the smaller-page hint and `maxBytes: 1000000`) - `healthStatus` stays `ok` - a new gateway session still lists the tool and can call it - the second `tools/call` uses `session-2`, not `session-1` - New test: agent A gets an oversized reply while agent B initializes a session on the same connection. Agent B's call completes, health stays `ok`, and the two calls use `session-1` and `session-2`. With the old connection-wide reset, this test fails: agent B gets 502 `mcp_remote_fetch_failed`. - `server/src/__tests__/remote-mcp-protocol.test.ts`: new unit test. `forgetMcpHttpSession()` keeps the session of a different identity, and a late failure from an old session does not remove the newer session. - Each regression check fails without its fix. Without the health change, the test fails on `healthStatus: 'error'`. Without the session reset, it fails with `['session-1', 'session-1']`. ``` cd server npx vitest run src/__tests__/tool-gateway.test.ts src/__tests__/tool-gateway-service.test.ts src/__tests__/remote-mcp-protocol.test.ts Test Files 3 passed (3) Tests 132 passed (132) ``` - I did not run the full server `tsc --noEmit` locally because the sandbox does not have sufficient memory. The CI typecheck covers it. ## Risks - Low risk. The change affects only the error path of remote MCP `tools/call`. - A remote server that always sends bad replies now keeps `healthStatus = "ok"`. Each call still fails with a clear 502 reason code and an audit record, so the failure stays visible. Only the connection-wide hiding of tools stops. - After one of these errors, the next call from the same caller sends one more `initialize` request. This adds one round trip. - A 404 "session expired" now clears only the session of the caller that got the 404. Before, it cleared the cached sessions of all agents on the connection. If the remote server restarts, each agent now gets its own "session expired" error one time and then initializes again. A 404 is about one session, and the old connection-wide clear also cancelled other agents' initializations. - #11910 and #15325 change the same catch block. The PR that merges last can have a small merge conflict. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, 1M-token context window. - Run as an agent in Claude Code with tool use (shell, file edit, and local test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no user-facing documentation changes are necessary) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge This PR replaces #15418. It keeps the same commits on a branch name without an internal ticket ID, and adds a fix for the Greptile review on #15418. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/__tests__/remote-mcp-protocol.test.ts | 21 +- server/src/__tests__/tool-gateway.test.ts | 188 ++++++++++++++++++ server/src/services/mcp-http.ts | 15 +- server/src/services/tool-gateway.ts | 28 ++- 4 files changed, 239 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/remote-mcp-protocol.test.ts b/server/src/__tests__/remote-mcp-protocol.test.ts index 1167e514d45..8db8252ccbc 100644 --- a/server/src/__tests__/remote-mcp-protocol.test.ts +++ b/server/src/__tests__/remote-mcp-protocol.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { forgetMcpHttpSessions, getMcpHttpSession, initializeMcpHttpSession, McpHttpInitializationError, readMcpHttpResponse } from "../services/mcp-http.js"; +import { forgetMcpHttpSession, forgetMcpHttpSessions, getMcpHttpSession, initializeMcpHttpSession, McpHttpInitializationError, readMcpHttpResponse } from "../services/mcp-http.js"; const encoder = new TextEncoder(); function stream(chunks: string[], close = true) { @@ -60,6 +60,25 @@ describe("remote connector Streamable HTTP", () => { expect((await getMcpHttpSession(input))["Mcp-Session-Id"]).toBe("session-5"); expect(count).toBe(5); }); + it("forgets only the failed session for one identity", async () => { + let count = 0; + const send = vi.fn(async (init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "initialize") { count++; return Response.json({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18", capabilities: {} } }, { headers: { "Mcp-Session-Id": `session-${count}` } }); } + return new Response(null, { status: 202 }); + }); + const agentA = { send, requestId: "a", scope: "narrow-connection:agent-a", headers: { Authorization: "Bearer shared" } }; + const agentB = { ...agentA, scope: "narrow-connection:agent-b" }; + expect((await getMcpHttpSession(agentA))["Mcp-Session-Id"]).toBe("session-1"); + expect((await getMcpHttpSession(agentB))["Mcp-Session-Id"]).toBe("session-2"); + forgetMcpHttpSession({ ...agentA, sessionId: "session-1" }); + expect((await getMcpHttpSession(agentB))["Mcp-Session-Id"]).toBe("session-2"); + expect((await getMcpHttpSession(agentA))["Mcp-Session-Id"]).toBe("session-3"); + // A late failure from the old session must not drop the session that replaced it. + forgetMcpHttpSession({ ...agentA, sessionId: "session-1" }); + expect((await getMcpHttpSession(agentA))["Mcp-Session-Id"]).toBe("session-3"); + expect(count).toBe(3); + }); it("does not resurrect an in-flight session after disconnect", async () => { let finish: (() => void) | undefined; const gate = new Promise((resolve) => { finish = resolve; }); diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index ee0c57d6983..473c3f4d1a8 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -4413,6 +4413,194 @@ rl.on("line", (line) => { }); } + function initializeFakeMcpSession(request: FakeMcpRequest, sessionId: string) { + return { + headers: { "mcp-session-id": sessionId }, + body: { + jsonrpc: "2.0", + id: request.body?.id, + result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } }, + }, + }; + } + + const perCallResponseFailures = [ + { + name: "an oversized response", + reasonCode: "mcp_remote_response_too_large", + response: () => ({ headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) }), + check: (error: ToolGatewayHttpError) => { + expect(error.message).toContain("smaller page size"); + expect(error.details).toMatchObject({ maxBytes: 1_000_000 }); + }, + }, + { + name: "an invalid JSON response", + reasonCode: "mcp_remote_invalid_json", + response: () => ({ headers: { "content-type": "application/json" }, rawBody: "not json" }), + }, + { + name: "a response without the requested message ID", + reasonCode: "remote_mcp_malformed_response", + response: () => ({ body: { jsonrpc: "2.0", id: "someone-else", result: { content: [{ type: "text", text: "wrong id" }] } } }), + }, + ]; + + for (const scenario of perCallResponseFailures) { + it(`keeps a remote MCP connection healthy and starts a fresh MCP session after ${scenario.name}`, async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + let initializeCount = 0; + let toolCallCount = 0; + const fake = await startFakeRemoteMcpServer((request) => { + if (request.body?.method === "initialize") { + initializeCount += 1; + return initializeFakeMcpSession(request, `session-${initializeCount}`); + } + if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" }; + toolCallCount += 1; + if (toolCallCount === 1) return scenario.response(); + return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "small page" }] } } }; + }); + try { + const { connection } = await createRemoteMcpTool(db, company.id, { + applicationKey: `per-call-${scenario.reasonCode}`, + toolName: "kv_get", + url: fake.url, + connectionConfig: { mcpSessionRequired: true }, + }); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createTestToolGatewayService(db); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const connectedTool = (await gateway.listToolsForSession(session.token)) + .find((tool) => tool.providerType === "mcp_remote_http"); + expect(connectedTool).toBeTruthy(); + + await gateway.executeTool({ + sessionToken: session.token, + tool: connectedTool!.name, + parameters: { key: "everything" }, + }).then( + () => { + throw new Error("Expected the remote MCP call to fail"); + }, + (error) => { + expectGatewayError(error, 502, scenario.reasonCode); + scenario.check?.(error as ToolGatewayHttpError); + }, + ); + + await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where( + eq(toolConnections.id, connection.id), + )).resolves.toEqual([{ healthStatus: "ok" }]); + + const nextSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + await expect(gateway.listToolsForSession(nextSession.token)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ name: connectedTool!.name })]), + ); + await expect(gateway.executeTool({ + sessionToken: nextSession.token, + tool: connectedTool!.name, + parameters: { key: "page-1" }, + })).resolves.toMatchObject({ status: "completed" }); + + const toolCallSessionIds = fake.requests + .filter((request) => request.body?.method === "tools/call") + .map((request) => request.headers["mcp-session-id"]); + expect(toolCallSessionIds).toEqual(["session-1", "session-2"]); + } finally { + await fake.close(); + } + }); + } + + it("resets only the failing agent's MCP session when another agent is initializing on the same connection", async () => { + const company = await createCompany(db); + const agentA = await createAgent(db, company.id); + const agentB = await createAgent(db, company.id); + const { run: runA } = await createIssueAndRun(db, company.id, agentA.id); + const { run: runB } = await createIssueAndRun(db, company.id, agentB.id); + let initializeCount = 0; + let markOversizedCallStarted!: () => void; + const oversizedCallStarted = new Promise((resolve) => { markOversizedCallStarted = resolve; }); + let markSecondInitializeStarted!: () => void; + const secondInitializeStarted = new Promise((resolve) => { markSecondInitializeStarted = resolve; }); + let releaseSecondInitialize!: () => void; + const secondInitializeReleased = new Promise((resolve) => { releaseSecondInitialize = resolve; }); + const fake = await startFakeRemoteMcpServer(async (request) => { + if (request.body?.method === "initialize") { + initializeCount += 1; + const sessionId = `session-${initializeCount}`; + if (initializeCount === 2) { + markSecondInitializeStarted(); + await secondInitializeReleased; + } + return initializeFakeMcpSession(request, sessionId); + } + if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" }; + if (request.headers["mcp-session-id"] === "session-1") { + markOversizedCallStarted(); + // Fail agent A's call only while agent B's initialization is in flight. + await secondInitializeStarted; + return { headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) }; + } + return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "ok" }] } } }; + }); + let callA: Promise | undefined; + let callB: Promise | undefined; + try { + const { connection } = await createRemoteMcpTool(db, company.id, { + applicationKey: "overlapping-sessions", + toolName: "kv_get", + url: fake.url, + connectionConfig: { mcpSessionRequired: true }, + }); + await allowAllToolsForAgent(db, company.id, agentA.id); + await allowAllToolsForAgent(db, company.id, agentB.id); + const gateway = createTestToolGatewayService(db); + const sessionA = await gateway.createSession({ companyId: company.id, agentId: agentA.id, runId: runA.id }); + const sessionB = await gateway.createSession({ companyId: company.id, agentId: agentB.id, runId: runB.id }); + const connectedTool = (await gateway.listToolsForSession(sessionA.token)) + .find((tool) => tool.providerType === "mcp_remote_http"); + expect(connectedTool).toBeTruthy(); + + callA = gateway.executeTool({ + sessionToken: sessionA.token, + tool: connectedTool!.name, + parameters: { key: "everything" }, + }).then( + () => { + throw new Error("Expected agent A's oversized remote MCP response to fail"); + }, + (error) => expectGatewayError(error, 502, "mcp_remote_response_too_large"), + ); + await oversizedCallStarted; + callB = gateway.executeTool({ + sessionToken: sessionB.token, + tool: connectedTool!.name, + parameters: { key: "page-1" }, + }); + await callA; + releaseSecondInitialize(); + await expect(callB).resolves.toMatchObject({ status: "completed" }); + + await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where( + eq(toolConnections.id, connection.id), + )).resolves.toEqual([{ healthStatus: "ok" }]); + const toolCallSessionIds = fake.requests + .filter((request) => request.body?.method === "tools/call") + .map((request) => request.headers["mcp-session-id"]); + expect(toolCallSessionIds).toEqual(["session-1", "session-2"]); + } finally { + // Open both gates and settle both calls so a failed assertion can't leave a fake-server request hanging. + markSecondInitializeStarted(); + releaseSecondInitialize(); + await Promise.allSettled([callA, callB]); + await fake.close(); + } + }); + it("persists hashed sessions and accepts them across gateway service instances", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/services/mcp-http.ts b/server/src/services/mcp-http.ts index 3ae11aec377..445d26b93ea 100644 --- a/server/src/services/mcp-http.ts +++ b/server/src/services/mcp-http.ts @@ -243,10 +243,14 @@ const sessions = new Map; expiresAt: n const initializing = new Map>>(); const SESSION_TTL_MS = 30 * 60_000; +function mcpHttpSessionKey(scope: string, headers: Record | undefined) { + return `${scope}:${createHash("sha256").update(JSON.stringify(Object.entries(headers ?? {}).sort())).digest("hex")}`; +} + /** Cache only protocol headers; credential hashes and scope separate every * connection and effective identity. Never cache a tool call or replay a write. */ export async function getMcpHttpSession(input: Parameters[0] & { scope: string }) { - const key = `${input.scope}:${createHash("sha256").update(JSON.stringify(Object.entries(input.headers ?? {}).sort())).digest("hex")}`; + const key = mcpHttpSessionKey(input.scope, input.headers); const cached = sessions.get(key); if (cached && cached.expiresAt > Date.now()) return { ...input.headers, ...cached.headers }; const pending = initializing.get(key); @@ -263,6 +267,15 @@ export async function getMcpHttpSession(input: Parameters; sessionId: string }) { + const key = mcpHttpSessionKey(input.scope, input.headers); + const cached = sessions.get(key); + if (cached && new Headers(cached.headers).get("mcp-session-id") === input.sessionId) sessions.delete(key); +} + export function forgetMcpHttpSessions(connectionId: string) { for (const key of sessions.keys()) if (key.startsWith(`${connectionId}:`)) sessions.delete(key); for (const key of initializing.keys()) if (key.startsWith(`${connectionId}:`)) initializing.delete(key); diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 01778927ccd..3e56275d205 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -108,7 +108,7 @@ import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js" import { initializeMcpHttpSession, getMcpHttpSession, - forgetMcpHttpSessions, + forgetMcpHttpSession, readMcpHttpResponse, McpHttpResponseError, mcpHttpRequestHeaders, @@ -5625,7 +5625,7 @@ export function createToolGatewayService( function responseTooLargeError() { return new ToolGatewayHttpError( 502, - "Remote MCP response exceeded the gateway size limit", + "Remote MCP response exceeded the gateway size limit. Request a smaller result, for example a narrower query or a smaller page size.", "mcp_remote_response_too_large", { maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES }, ); @@ -5958,6 +5958,9 @@ export function createToolGatewayService( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); timer.unref?.(); + // The cached MCP session this call used, so a failure resets only this + // identity's session and leaves other agents on the connection alone. + let mcpSession: { scope: string; headers: Record; sessionId: string } | undefined; try { const dispatchRemote = (target: string, init: RequestInit) => options.remoteHttpRequest @@ -6000,8 +6003,9 @@ export function createToolGatewayService( } let requestHeaders = headers; if (connection.config.mcpSessionRequired === true) { + const scope = `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`; requestHeaders = await getMcpHttpSession({ - scope: `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`, + scope, send: (init) => dispatchRemote(endpoint, { ...init, @@ -6011,6 +6015,8 @@ export function createToolGatewayService( headers, requestId, }); + const sessionId = new Headers(requestHeaders).get("mcp-session-id"); + if (sessionId) mcpSession = { scope, headers, sessionId }; } // The guard runs inside this call and the connection is pinned to the // address it approved, so an operator-supplied hostname cannot be rebound @@ -6149,7 +6155,7 @@ export function createToolGatewayService( if (sessionExpired) { // The next explicit call initializes again. Never replay a tools/call // automatically: the failed call may have changed app data. - forgetMcpHttpSessions(connection.id); + if (mcpSession) forgetMcpHttpSession(mcpSession); } const body = response.ok ? JSON.stringify(await readMcpHttpResponse(response, requestId, { @@ -6209,11 +6215,6 @@ export function createToolGatewayService( try { payload = JSON.parse(body); } catch { - await markRemoteConnectionHealth( - connection, - "error", - "Remote MCP server returned invalid JSON.", - ); throw new ToolGatewayHttpError( 502, "Remote MCP server returned invalid JSON", @@ -6296,9 +6297,14 @@ export function createToolGatewayService( const failure = error.reason === "too_large" ? responseTooLargeError() : error.reason === "malformed_response" ? malformedRemoteMcpResponse() : new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json"); - await markRemoteConnectionHealth(connection, "error", failure.message); + // These describe one response body, not the connection: the server + // answered with HTTP 2xx. Marking the connection unhealthy would hide + // every tool, and only a successful call restores health. + // The reader may have cancelled the stream partway, and the server can + // then drop its session. Start a fresh session on the next call. + if (mcpSession) forgetMcpHttpSession(mcpSession); throw new ToolGatewayHttpError(failure.status, failure.message, failure.reasonCode, { - connectionId: connection.id, catalogEntryId: entry.id, execution, + ...failure.details, connectionId: connection.id, catalogEntryId: entry.id, execution, }); } if (error instanceof RailwayError) { From cd40ebb95ba6e4982044935a66546d1df831e863 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 13:01:22 -0700 Subject: [PATCH 22/55] Preserve ACP bridge terminal evidence before disconnect (#15478) Drain terminal frames through backpressure and record bounded terminal evidence without waiting for log persistence. Guard later events and input failures against writes after socket end. Validated with 217 focused tests, full typecheck/build, independent review and green CI with Greptile 5/5. Co-Authored-By: Paperclip --- doc/acp-run-lifecycle.md | 9 + .../src/execution-target-sandbox.test.ts | 194 ++++++++++++++++++ .../adapter-utils/src/execution-target.ts | 56 ++++- 3 files changed, 249 insertions(+), 10 deletions(-) diff --git a/doc/acp-run-lifecycle.md b/doc/acp-run-lifecycle.md index 0e75f3e7d7f..f5a4019b8d8 100644 --- a/doc/acp-run-lifecycle.md +++ b/doc/acp-run-lifecycle.md @@ -58,6 +58,15 @@ removes it if startup fails. The child's environment values remain unchanged. If the launch shell fails before the wrapper emits a protocol event, the run log retains the shell's stderr alongside the exit code. +The process-session bridge also writes one bounded terminal diagnostic to the +run's stderr. It distinguishes a remote event, input delivery failure, output +poll failure, output stream failure, and unexpected authenticated proxy closure. +Exit codes and signals are validated; unavailable values are `unknown`. The line +contains no raw error message, command, path, or environment value. It records +the first observed terminal event, not proof of remote shutdown. An error frame +drains to the local proxy before the connection closes, and a failed or stalled +diagnostic write does not delay failure delivery or teardown. + The settlement sequence is the one live cleanup owner for every settled path. It claims the ledger once, makes the pure reuse decision, then runs the ordered steps: diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 51ab309890f..0655c9f0b26 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1107,6 +1107,7 @@ describe("sandbox adapter execution targets", () => { // ACP keeps stdin open while it waits for a handshake. A remote child can // exit before replying; that must close the proxy and fail the handshake. const output = "final output\n".repeat(16_384); + const logs: string[] = []; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-open-stdin", target: { @@ -1124,11 +1125,16 @@ describe("sandbox adapter execution targets", () => { env: {}, timeoutSec: 10, streamOutputViaSession, + onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); }, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, "initialize\n", true); expect(result.code).toBe(exitCode ?? 1); + const terminal = logs.filter((line) => line.includes("ACP process session terminal:")); + expect(terminal).toEqual([ + `[paperclip] ACP process session terminal: source=remote_event outcome=${exitCode === null ? "error" : "exit"} exitCode=${exitCode ?? "unknown"} signal=unknown.\n`, + ]); if (exitCode === null) { expect(result.stdout).toBe(""); expect(result.stderr).toContain("ENOENT"); @@ -1141,6 +1147,194 @@ describe("sandbox adapter execution targets", () => { } }, 15_000); + it("flushes remote errors under backpressure and retains only bounded terminal evidence", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-flush-")); + cleanupDirs.push(rootDir); + const delegate = createLocalSandboxRunner(); + const logs: string[] = []; + let emit!: (frame: string) => Promise; + let complete!: () => void; + let failInput!: (error: Error) => void; + let inputStarted = false; + const inputFailure = new Promise((_resolve, reject) => { failInput = reject; }); + void inputFailure.catch(() => {}); + const commandComplete = new Promise((resolve) => { complete = resolve; }); + const frame = { + type: "error", message: "synthetic-private-detail".repeat(200_000), + code: 1e100, signal: "untrusted-private-signal", + }; + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "terminal-flush", adapterKey: "acpx", runtimeRootDir: rootDir, + target: { + kind: "remote", transport: "sandbox", remoteCwd: rootDir, + runner: { execute: async (input) => { + if (!input.useSession) { + const script = input.args?.[1] ?? ""; + if (script.startsWith("mkdir -p") && script.includes("/stdin/000000000001.json")) { + inputStarted = true; + return inputFailure; + } + return delegate.execute(input); + } + emit = async (text) => { await input.onLog?.("stdout", text); }; + await commandComplete; + return { exitCode: 1, stdout: "", stderr: "", timedOut: false, signal: null, pid: null, startedAt: null }; + } }, + }, + command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true, + onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); }, + }); + let peer: net.Socket | undefined; + try { + const source = await readFile(bridge!.agentCommand, "utf8"); + const port = Number(/port: (\d+)/.exec(source)![1]); + const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string; + peer = net.createConnection({ host: "127.0.0.1", port }); + peer.on("error", () => {}); + peer.setEncoding("utf8"); + let output = ""; + peer.on("data", (chunk) => { output += chunk; }); + const closed = new Promise((resolve) => peer!.once("close", resolve)); + await new Promise((resolve) => peer!.once("connect", resolve)); + peer.write(JSON.stringify({ token, type: "hello" }) + "\n"); + // Confirm authentication by receiving a marker before applying backpressure. + await emit(JSON.stringify({ type: "data", stream: "stdout", data: "" }) + "\n"); + await waitForCondition(() => output.length > 0, "Missing authenticated marker."); + output = ""; + peer.write(JSON.stringify({ token, type: "stdin", data: "aW5wdXQ=" }) + "\n"); + await waitForCondition(() => inputStarted, "Input write did not start."); + peer.pause(); + // The real wrapper emits error then exit when a child fails to spawn. + // A later frame must not write after end() and abort the pending error. + await emit([ + frame, + { type: "exit", code: 1 }, + { type: "data", stream: "stdout", data: "bGF0ZQ==" }, + { type: "shutdownAck" }, + ].map((event) => JSON.stringify(event) + "\n").join("")); + // The reverse race matters too: an already-accepted input write can + // reject after the remote error started draining. Its end(data) must + // not abort or replace the first terminal frame. + failInput(new Error("synthetic-private-input-detail")); + await waitForCondition( + () => logs.some((line) => line.includes("ACP process session input delivery failed.")), + "Input failure was not handled.", + ); + peer.resume(); + await closed; + expect(JSON.parse(output)).toEqual(frame); + expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([ + "[paperclip] ACP process session terminal: source=remote_event outcome=error exitCode=unknown signal=unknown.\n", + ]); + } finally { + failInput(new Error("test cleanup")); + complete(); + peer?.destroy(); + await bridge?.stop(); + } + }, 15_000); + + it("reports a streamed transport failure even when diagnostic persistence stalls", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-stream-")); + cleanupDirs.push(rootDir); + const delegate = createLocalSandboxRunner(); + const logs: string[] = []; + let releaseLog!: () => void; + const stalledLog = new Promise((resolve) => { releaseLog = resolve; }); + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "terminal-stream", adapterKey: "acpx", runtimeRootDir: rootDir, + target: { + kind: "remote", transport: "sandbox", remoteCwd: rootDir, + runner: { execute: async (input) => { + if (input.useSession) throw new Error("synthetic-private-provider-detail"); + return delegate.execute(input); + } }, + }, + command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true, + onLog: async (stream, chunk) => { + if (stream === "stderr") { logs.push(chunk); await stalledLog; } + }, + }); + try { + const result = await runProxyWithInput(bridge!.agentCommand, "", true); + expect(result.code).toBe(1); + expect(logs).toEqual([ + "[paperclip] ACP process session terminal: source=output_stream outcome=error exitCode=unknown signal=unknown.\n", + ]); + } finally { + releaseLog(); + await bridge?.stop(); + } + }, 10_000); + + it("closes a failed output poll without waiting for the run log", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-poll-")); + cleanupDirs.push(rootDir); + const delegate = createLocalSandboxRunner(); + const logs: string[] = []; + let releaseLog!: () => void; + const stalledLog = new Promise((resolve) => { releaseLog = resolve; }); + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "terminal-poll", adapterKey: "acpx", runtimeRootDir: rootDir, + target: { + kind: "remote", transport: "sandbox", remoteCwd: rootDir, + runner: { execute: async (input) => { + const script = input.args?.[1] ?? ""; + if (script.includes("/events'/*.json")) throw new Error("synthetic-private-provider-detail"); + return delegate.execute(input); + } }, + }, + command: "cat", args: [], cwd: rootDir, env: {}, + onLog: async (stream, chunk) => { + if (stream === "stderr") { logs.push(chunk); await stalledLog; } + }, + }); + try { + const result = await runProxyWithInput(bridge!.agentCommand, "", true); + expect(result.code).toBe(1); + expect(logs).toEqual([ + "[paperclip] ACP process session terminal: source=output_poll outcome=error exitCode=unknown signal=unknown.\n", + ]); + } finally { + releaseLog(); + await bridge?.stop(); + } + }, 10_000); + + it("records an unexpected authenticated proxy close once and ignores unauthenticated peers", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-proxy-")); + cleanupDirs.push(rootDir); + const logs: string[] = []; + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "terminal-proxy", adapterKey: "acpx", runtimeRootDir: rootDir, + target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner: createLocalSandboxRunner() }, + command: "cat", args: [], cwd: rootDir, env: {}, + onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); }, + }); + try { + const source = await readFile(bridge!.agentCommand, "utf8"); + const port = Number(/port: (\d+)/.exec(source)![1]); + const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string; + for (const authenticated of [false, true]) { + const peer = net.createConnection({ host: "127.0.0.1", port }); + peer.on("error", () => {}); + const closed = new Promise((resolve) => peer.once("close", resolve)); + await new Promise((resolve) => peer.once("connect", resolve)); + if (authenticated) peer.end(JSON.stringify({ token, type: "hello" }) + "\n"); + else peer.end(); + await closed; + if (!authenticated) expect(logs).toEqual([]); + } + await waitForCondition(() => logs.length === 1, "Missing proxy-close diagnostic."); + await bridge!.stop(); + expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([ + "[paperclip] ACP process session terminal: source=proxy_close outcome=error exitCode=unknown signal=unknown.\n", + ]); + } finally { + await bridge?.stop(); + } + }, 10_000); + it("buffers sandbox process session output until the local proxy connects", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index aa66d00ddb8..42a05cbb895 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -2165,6 +2165,26 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { } }; let pollTimer: NodeJS.Timeout | null = null; + let terminalEvidenceRecorded = false; + const recordTerminalEvidence = ( + source: "remote_event" | "input_delivery" | "output_poll" | "output_stream" | "proxy_close", + event: { type?: string; code?: number | null; signal?: string | null }, + ) => { + if (terminalEvidenceRecorded) return; + terminalEvidenceRecorded = true; + // Remote frames are untrusted. Keep the retained diagnostic bounded and + // independent of messages, commands, paths, payloads, and provider errors. + const outcome = event.type === "exit" ? "exit" : "error"; + const code = typeof event.code === "number" && Number.isInteger(event.code) + && event.code >= 0 && event.code <= 255 ? event.code : "unknown"; + const signal = typeof event.signal === "string" && [ + "SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGABRT", "SIGFPE", "SIGKILL", + "SIGSEGV", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGBUS", "SIGXCPU", "SIGXFSZ", + ].includes(event.signal) ? event.signal : "unknown"; + logFailureWithoutWaiting( + `[paperclip] ACP process session terminal: source=${source} outcome=${outcome} exitCode=${code} signal=${signal}.\n`, + ); + }; const pendingRemoteEvents: Array<{ type?: string; stream?: "stdout" | "stderr"; @@ -2191,22 +2211,30 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => { if (!socket) return false; + // `stopping` can already be set by a terminal frame buffered before auth. + // Use the socket's outbound state instead: once end() has been called, + // another write would destroy it and truncate the frame still draining. + // This also covers input-delivery failure, which ends the socket directly. + if (socket.writableEnded || socket.destroyed) return true; socket.write(jsonLine(event)); - if (event.type === "exit") { + if (event.type === "exit" || event.type === "error") { stopping = true; + // Flush the terminal frame and all earlier output before closing. An + // immediate destroy can discard the cause and leave only connection_close. socket.end(); - } else if (event.type === "error") { - stopping = true; - socket.destroy(); } return true; }; - const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => { + const deliverRemoteEvent = ( + event: (typeof pendingRemoteEvents)[number], + source: "remote_event" | "output_poll" | "output_stream" = "remote_event", + ) => { if (event.type === "shutdownAck") { signalShutdownAcknowledged(); return; } + if (event.type === "exit" || event.type === "error") recordTerminalEvidence(source, event); if (socket) { writeRemoteEventToSocket(event); return; @@ -2248,6 +2276,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { nextSocket.on("close", () => { clearTimeout(authTimer); liveSockets.delete(nextSocket); + if (authenticated && !stopping) recordTerminalEvidence("proxy_close", { type: "error" }); }); nextSocket.on("data", (chunk) => { connectionBuffer += chunk; @@ -2304,11 +2333,17 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { } catch { stdinDeliveryFailed = true; stopping = true; + recordTerminalEvidence("input_delivery", { type: "error" }); const message = "ACP process session input delivery failed."; // Flush the diagnostic before closing; destroy() can discard it // and leave only ACP's generic connection_close error. Do not // expose provider error text, which may contain a command payload. - nextSocket.end(jsonLine({ type: "error", message })); + // A remote terminal frame may already be draining while this + // accepted stdin write fails. end(data) would write after end + // and discard that first failure just like socket.write(data). + if (!nextSocket.writableEnded && !nextSocket.destroyed) { + nextSocket.end(jsonLine({ type: "error", message })); + } // stop() awaits this input chain before sending shutdown. Run-log // persistence must not hold teardown open when it stalls or fails. logFailureWithoutWaiting(`[paperclip] ${message}\n`); @@ -2340,8 +2375,8 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`); - deliverRemoteEvent({ type: "error", message }); + // A stuck log sink must not strand the adapter waiting for this failure. + deliverRemoteEvent({ type: "error", message }, "output_poll"); return; } finally { if (!stopping) { @@ -2467,14 +2502,15 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { deliverRemoteEvent({ type: "exit", code: typeof result.exitCode === "number" ? result.exitCode : null, - }); + signal: result.signal, + }, "output_stream"); } } catch (error) { if (!stopping) { deliverRemoteEvent({ type: "error", message: error instanceof Error ? error.message : String(error), - }); + }, "output_stream"); } } })(); From f1c44b7b565b8cddb0567d091539d2e7cc517ec8 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 7 Oct 2026 13:29:44 -0700 Subject: [PATCH 23/55] Preserve remote work when required workspace restore fails (#15479) Persist exact source-retention obligations before run finalization and protect them across cancellation, restart and task changes. Require board-authorized repair evidence without replaying old work or changing current task ownership, state or locks. Hide an unavailable retry action and document operator recovery and retention costs. Validated with 674 scoped regressions, 224 combined integration tests, full typecheck/build, independent safety reviews and green CI with Greptile 5/5. No historical file recovery is claimed. Co-Authored-By: Paperclip --- doc/workspace-restore-locks.md | 43 ++++ .../shared/src/types/execution-projection.ts | 2 + packages/shared/src/validators/issue.test.ts | 14 ++ packages/shared/src/validators/issue.ts | 5 +- .../heartbeat-process-recovery.test.ts | 153 +++++++++++- ...ue-agent-mutation-ownership-routes.test.ts | 3 + .../legacy-workspace-restore-recovery.test.ts | 229 +++++++++++++++++ .../workspace-restore-repair-route.test.ts | 235 ++++++++++++++++++ server/src/routes/issues.ts | 48 +++- .../src/services/conversation-continuation.ts | 29 ++- server/src/services/execution-blocker.ts | 15 +- .../services/execution-recovery-resolution.ts | 48 +++- .../services/explicit-native-continuation.ts | 3 +- server/src/services/heartbeat.ts | 78 +++--- .../src/services/legacy-execution-recovery.ts | 103 +++++++- .../legacy-workspace-restore-recovery.ts | 82 ++++++ server/src/services/recovery/service.ts | 14 +- .../workspace-restore-reconciliation.test.ts | 157 ++++++++++++ .../workspace-restore-recovery-state.ts | 23 ++ .../ExecutionBlockerNotice.test.tsx | 14 ++ ui/src/components/ExecutionBlockerNotice.tsx | 2 +- 21 files changed, 1223 insertions(+), 77 deletions(-) create mode 100644 server/src/__tests__/legacy-workspace-restore-recovery.test.ts create mode 100644 server/src/__tests__/workspace-restore-repair-route.test.ts create mode 100644 server/src/services/legacy-workspace-restore-recovery.ts create mode 100644 server/src/services/workspace-restore-reconciliation.test.ts create mode 100644 server/src/services/workspace-restore-recovery-state.ts diff --git a/doc/workspace-restore-locks.md b/doc/workspace-restore-locks.md index 60a00fc0b7c..62acf88b793 100644 --- a/doc/workspace-restore-locks.md +++ b/doc/workspace-restore-locks.md @@ -54,3 +54,46 @@ is safe to remove from its age, an absent PID, or a successful task response. This change prevents new orphaned ownership. It cannot recover file changes that an earlier failed collection discarded. + +## Required remote restore failures + +When a legacy sandbox run cannot copy required workspace files back, the server +keeps the original allocation for repair. It writes the failed run, the exact +lease retention intent, and a board-owned repair hold in one transaction. The +hold survives reassignment and conversation resets and coexists with newer +recovery incidents; it never supersedes them. +Cleanup stops that allocation and verifies its provider receipt. It does not +use ordinary ephemeral destruction or return the source to warm reuse. Stop +failures use the existing cleanup retry and backoff policy. + +The retained source uses `retain_on_failure`. A stopped source can still incur +provider storage charges. It stays available until an operator recovers or +explicitly discards it; elapsed time never proves that its files were copied. +The task's `GET /api/issues/:id/recovery-actions` response lists these repair holds +alongside any newer active incident, including the original lease IDs. Inspect +those exact leases through `GET /api/environment-leases/:leaseId`, +confirm that cleanup stopped them, recover the missing files, and record +`workspaceRepairEvidence` through the existing recovery-action resolution API. +Send `POST /api/issues/:id/recovery-actions/resolve` with the exact recovery action +ID, `outcome: "restored"`, the task's **unchanged** +`sourceIssueStatus`, and `executionReconciliation` for the recorded source run. +Only a board operator can record this repair. It preserves the current task +owner, conversation generation, status, and execution locks. It does not queue a +continuation or replay the stopped run; after repair, any new work still follows +the normal admission rules. +The board currently shows repair guidance and an Inspect run link, not a repair +form. Recording the decision is an operator API/runbook workflow; the recovery +API response contains the exact action and lease IDs. Provider access may require +an instance administrator or Cloud support. This change does not add a repair UI. +Then use the provider's operator console to remove that exact retained +allocation when its files are no longer needed. Verify its immutable provider +allocation ID against the recorded lease before removal. The ordinary reusable +sandbox cleanup does not select `retain_on_failure` sources; do not use +environment deletion as proof that this allocation was removed. Keep the +environment and its provider credentials until recovery is complete. Recording a +repair decision does not automatically destroy it. + +A new message, Retry, or `/new` cannot clear this workspace repair hold. Ordinary +local lock-timeout retries and runs without a required remote copy-back failure +keep their existing policy. This change preserves future failures; it cannot +recover files from an allocation that was already destroyed. diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts index 02a92849144..ab65bbfb5b4 100644 --- a/packages/shared/src/types/execution-projection.ts +++ b/packages/shared/src/types/execution-projection.ts @@ -11,6 +11,8 @@ export interface ExecutionBlocker { canContinue?: boolean; /** Explicit Retry candidate; the server rechecks stop proof and execution gates. */ canRetry?: boolean; + /** Required workspace files must be recovered before another provider turn. */ + workspaceRepairRequired?: boolean; savedMessageCount?: number; } diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 20ec32b9631..27f688d3cc8 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -314,6 +314,20 @@ describe("issue validators", () => { ).toBe(false); }); + it.each(["backlog", "in_progress", "blocked", "cancelled"])("requires workspace evidence to record a repair at unchanged %s status", sourceIssueStatus => { + const input = { outcome: "restored", sourceIssueStatus }; + expect(resolveIssueRecoveryActionSchema.safeParse(input).success).toBe(false); + expect(resolveIssueRecoveryActionSchema.safeParse({ ...input, executionReconciliation: { + runId: "11111111-1111-4111-8111-111111111111", providerStopped: true, actionOutcome: "mixed", + outcomeEvidence: "Observed and reconciled the source run action outcomes.", + } }).success).toBe(false); + expect(resolveIssueRecoveryActionSchema.safeParse({ ...input, executionReconciliation: { + runId: "11111111-1111-4111-8111-111111111111", providerStopped: true, actionOutcome: "mixed", + outcomeEvidence: "Observed and reconciled the source run action outcomes.", + workspaceRepairEvidence: "Copied the retained source files and verified the restored files.", + } }).success).toBe(true); + }); + it("allows cancelled recovery resolutions to atomically restore the source issue status", () => { expect( resolveIssueRecoveryActionSchema.parse({ diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 8804e733abd..bbb44441d61 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -576,12 +576,15 @@ export const resolveIssueRecoveryActionSchema = z .optional(), actionId: z.string().guid().optional(), outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES), - sourceIssueStatus: z.enum(["todo", "done", "in_review", "blocked"]), + sourceIssueStatus: z.enum(ISSUE_STATUSES), resolutionNote: multilineTextSchema.optional().nullable(), }) .strict() .superRefine((value, ctx) => { if (value.outcome === "restored") { + // A retained-source repair records evidence without changing task state. + // The route verifies the exact server-owned source and unchanged status. + if (value.executionReconciliation?.workspaceRepairEvidence) return; if ( value.sourceIssueStatus !== "todo" && value.sourceIssueStatus !== "done" && diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 325ffbc0a9b..702c941fde1 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -8,7 +8,7 @@ import * as runEvents from "../services/heartbeat-run-events.js"; import { waitForPendingRunFailureReports } from "../services/run-failure-report.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { createHash, randomUUID } from "node:crypto"; -import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; +import { recordLegacyWorkspaceRestoreFailure, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; import { issueService } from "../services/issues.js"; import { getExecutionBlocker } from "../services/execution-blocker.js"; import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js"; @@ -7977,9 +7977,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { try { await vi.waitFor(async () => { const [row] = await db.execute<{ count: number }>(sql` - select count(*)::int as count from pg_stat_activity - where datname = current_database() and ${pid} = any(pg_blocking_pids(pid)) - and query ilike '%update%heartbeat_runs%' + select count(*)::int as count from pg_stat_activity activity + where datname = current_database() and ${pid} = any(pg_blocking_pids(activity.pid)) + and wait_event_type = 'Lock' + and exists (select 1 from pg_locks locks where locks.pid = activity.pid + and locks.relation = 'heartbeat_runs'::regclass) `); expect(row!.count).toBeGreaterThan(0); }); @@ -8054,6 +8056,63 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it("preserves a copy-back obligation recorded after Stop reads the running adapter", async () => { + const { runId, companyId, issueId } = await seedRunFixture({ runtimeMode: "legacy", adapterType: "codex_local" }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + const [environment] = await db.insert(environments).values({ name: `Stop restore race ${runId}`, driver: "sandbox" }).returning(); + const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId, + environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(), status: "active", + leasePolicy: "ephemeral", metadata: { driver: "sandbox" }, + }).returning(); + const control = createAdapterExecutionControl(); + adapterExecutionControls.set(runId, control); + let releaseWrite!: () => void; + const writeGate = new Promise(resolve => { releaseWrite = resolve; }); + let reportWrite!: () => void; + const writeCaptured = new Promise(resolve => { reportWrite = resolve; }); + const originalUpdate = db.update.bind(db); + const writeSpy = vi.spyOn(db, "update").mockImplementationOnce((table) => { + const update = originalUpdate(table); + const originalSet = update.set.bind(update); + vi.spyOn(update, "set").mockImplementationOnce((value) => { + const query = originalSet(value); + const originalWhere = query.where.bind(query); + vi.spyOn(query, "where").mockImplementationOnce((condition) => { + reportWrite(); + return writeGate.then(() => originalWhere(condition)) as unknown as ReturnType; + }); + return query; + }); + return update; + }); + const heartbeat = heartbeatService(db); + const stopping = heartbeat.cancelRun(runId); + try { + await writeCaptured; + // This commits after Stop captured its old result, before Stop's write. + await recordLegacyWorkspaceRestoreFailure(db, run, { workspaceRestoreFailure: "restore_failed" }); + releaseWrite(); + await vi.waitFor(() => expect(control.controller.signal.aborted).toBe(true)); + const [requested] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(requested.resultJson).toMatchObject({ workspaceRestoreFailure: "restore_failed", + workspaceRestoreRecovery: { leaseIds: [lease.id] }, executionCancellation: { state: "requested" } }); + expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)))[0]) + .toMatchObject({ leasePolicy: "retain_on_failure", status: "pending_cleanup" }); + const cancelled = await terminalizeLegacyExecution({ db, run: requested, status: "cancelled", reconcileIfNeeded: true, + patch: { resultJson: { executionCancellation: { state: "acknowledged" } } } }); + control.finish(); + await expect(stopping).resolves.toMatchObject({ status: "cancelled" }); + expect(cancelled?.resultJson).toMatchObject({ workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { leaseIds: [lease.id] } }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId!))).toHaveLength(1); + } finally { + releaseWrite(); + writeSpy.mockRestore(); + control.finish(); + adapterExecutionControls.delete(runId); + await stopping.catch(() => undefined); + } + }); + it("clears the detached warning when the run reports activity again", async () => { const { runId } = await seedRunFixture({ includeIssue: false, @@ -8254,9 +8313,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(duplicateSettled).toBe(false); if (failure === "write") { const error = new Error("owned cancellation write unavailable"); - writeSpy = adapterType === "codex_local" - ? vi.spyOn(db, "update").mockImplementationOnce(() => { throw error; }) - : vi.spyOn(db, "transaction").mockRejectedValueOnce(error); + // Both conversation and process cancellation now finalize under the + // task/run transaction. Fail that write before either owner resolves. + writeSpy = vi.spyOn(db, "transaction").mockRejectedValueOnce(error); } } finally { releaseTermination(); @@ -8297,6 +8356,86 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }, ); + it("preserves exact restore sources through late process Stop metadata with an oversized result", async () => { + const actualProcess = await vi.importActual("../adapters/process/execute.js"); + const actualSupervisor = await vi.importActual("../services/local-service-supervisor.js"); + let reportReady!: () => void; + const ready = new Promise(resolve => { reportReady = resolve; }); + let releaseTermination!: () => void; + const terminationGate = new Promise(resolve => { releaseTermination = resolve; }); + mockAdapterExecute.mockImplementationOnce((async (input: unknown) => { + const context = input as Parameters[0]; + const result = await actualProcess.execute({ ...context, onLog: async (stream, text) => { + await context.onLog(stream, text); + if (text.includes("restore stop ready")) reportReady(); + } }); + return { ...result, resultJson: { ...result.resultJson, lateAdapterReceipt: true } }; + }) as typeof mockAdapterExecute); + mockTerminateLocalService.mockImplementationOnce(async (...args) => { + await actualSupervisor.terminateLocalService(...args); + await terminationGate; + }); + const { runId, agentId, companyId, issueId } = await seedRunFixture({ + adapterType: "process", runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued", + }); + await db.update(agents).set({ adapterConfig: { command: process.execPath, + args: ["-e", "console.log('restore stop ready'); setInterval(() => {}, 1000)"], graceSec: 1, + } }).where(eq(agents.id, agentId)); + const originalFactory = instructionWorkingCopies.agentInstructionWorkingCopyService; + let completionReached = false; + const factory = vi.spyOn(instructionWorkingCopies, "agentInstructionWorkingCopyService").mockImplementation((...args) => { + const service = originalFactory(...args); + return { ...service, release: async (...releaseArgs) => { + await service.release(...releaseArgs); + if (releaseArgs[1] === runId && !completionReached) { + completionReached = true; + // Host completion reaches its owned-Stop await in microtasks after + // instruction cleanup. Settle Stop on the next turn, without a delay. + setImmediate(releaseTermination); + } + } }; + }); + const heartbeat = heartbeatService(db); + let stopping: ReturnType | undefined; + try { + await heartbeat.resumeQueuedRuns(); + await ready; + expect(runningProcesses.get(runId)?.child.pid).toBeTruthy(); + expect(adapterExecutionControls.has(runId)).toBe(false); + const [environment] = await db.insert(environments).values({ name: `Late restore Stop ${runId}`, driver: "sandbox" }).returning(); + const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId, + environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(), status: "active", + leasePolicy: "ephemeral", metadata: { driver: "sandbox" }, + }).returning(); + // Incompressible fixture data exercises the database-size projection, + // which keeps the recovery schema but omits its internal source IDs. + const payload = Array.from({ length: 8192 }, () => randomUUID()).join(""); + const [run] = await db.update(heartbeatRuns).set({ resultJson: { payload } }).where(eq(heartbeatRuns.id, runId)).returning(); + await recordLegacyWorkspaceRestoreFailure(db, run, { workspaceRestoreFailure: "restore_failed" }); + expect((await heartbeat.getRun(runId))?.resultJson?.workspaceRestoreRecovery).toEqual({ schema: "paperclip.workspace-restore-recovery.v1" }); + stopping = heartbeat.cancelRun(runId, "Stopped by test operator"); + await expect(stopping).resolves.toMatchObject({ status: "cancelled" }); + await heartbeat.waitForRunExecutionDrain(runId); + const [finished] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(completionReached).toBe(true); + expect(finished).toMatchObject({ status: "cancelled", resultJson: { + lateAdapterReceipt: true, + workspaceRestoreFailure: "restore_failed", + workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [lease.id] }, + cancellation: { expected: true }, + } }); + expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)))[0]) + .toMatchObject({ leasePolicy: "retain_on_failure", status: "pending_cleanup" }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(1); + } finally { + releaseTermination(); + await stopping?.catch(() => undefined); + if (runningProcesses.has(runId)) await heartbeat.cancelRun(runId).catch(() => undefined); + await heartbeat.waitForRunExecutionDrain(runId); + factory.mockRestore(); + } + }); + it.each([ { mode: "clean exit", diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 4452045d774..f6c0a9f6102 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -359,6 +359,9 @@ function createRunContextDb( orderBy: vi.fn(async () => []), limit: vi.fn(() => ({ then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + for: vi.fn(() => ({ + then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + })), })), for: vi.fn(() => ({ then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), diff --git a/server/src/__tests__/legacy-workspace-restore-recovery.test.ts b/server/src/__tests__/legacy-workspace-restore-recovery.test.ts new file mode 100644 index 00000000000..24d12c6674c --- /dev/null +++ b/server/src/__tests__/legacy-workspace-restore-recovery.test.ts @@ -0,0 +1,229 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { recordLegacyWorkspaceRestoreFailure, legacyExecutionNeedsReconciliationWithEvidence, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; +import { hasConversationContinuationPolicy, conversationRecoveryActionPredicate } from "../services/conversation-continuation.js"; +import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../services/legacy-workspace-restore-recovery.js"; +import { settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js"; +import { hasRequiredWorkspaceRecovery } from "../services/workspace-restore-recovery-state.js"; + +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); +const support = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("legacy remote workspace recovery", () => { + let db: ReturnType; + let temporary: Awaited>; + beforeAll(async () => { if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl); + else { temporary = await startEmbeddedPostgresTestDatabase("legacy-workspace-recovery-"); db = createDb(temporary.connectionString); } }, 30_000); + afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); }); + async function seed(options: { local?: boolean; taskDone?: boolean; reusable?: boolean } = {}) { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Workspace recovery", issuePrefix: companyId.slice(0, 8) }); + await db.insert(agents).values({ id: agentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Restore files", status: options.taskDone ? "done" : "in_progress", assigneeAgentId: agentId }); + const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "legacy", contextSnapshot: { issueId } }).returning(); + if (options.local) await db.insert(environments).values({ name: "Local", driver: "local" }).onConflictDoNothing(); + const [environment] = options.local ? await db.select().from(environments).where(eq(environments.driver, "local")) + : await db.insert(environments).values({ name: `Isolated test sandbox ${runId}`, driver: "sandbox" }).returning(); + const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId, environmentId: environment.id, + status: "active", leasePolicy: options.reusable ? "reuse_by_environment" : "ephemeral", + provider: options.local ? "local" : "daytona", providerLeaseId: randomUUID(), + metadata: { driver: options.local ? "local" : "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: !options.local }, + }).returning(); + return { companyId, runId, issueId, run, lease }; + } + const patch = { errorCode: "workspace_restore_failed", finishedAt: new Date(), resultJson: { + workspaceRestoreFailure: "restore_failed", conversationContinuation: "continue_conversation_v1", + } }; + const readLease = async (id: string) => (await db.select().from(environmentLeases).where(eq(environmentLeases.id, id)))[0]; + + it.each([false, true])("persists terminal status, stop-only source and board action atomically (task done=%s)", async taskDone => { + const f = await seed({ taskDone, reusable: true }); + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...f.run, status: "failed", ...patch })).toBe(true); + const failed = await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }); + expect(failed?.status).toBe("failed"); + expect(failed?.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [f.lease.id] }); + expect(hasConversationContinuationPolicy(failed?.resultJson)).toBe(false); + const lease = await readLease(f.lease.id); + expect(lease).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure", metadata: { + sandboxStopAndRetain: { companyId: f.companyId, runId: f.runId, leaseId: f.lease.id, providerLeaseId: f.lease.providerLeaseId }, + pendingCleanupInFlight: false, + } }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(action).toMatchObject({ ownerType: "board", evidence: { workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } }); + expect(await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.id, action.id), conversationRecoveryActionPredicate()))).toEqual([]); + // A new controller uses the durable stop-only intent. It cannot turn a + // successful stop into proof that the workspace files were restored. + const receipt = { schema: "paperclip.remote-termination.v1", runId: f.runId, companyId: f.companyId, + leaseId: lease.id, provider: lease.provider, providerLeaseId: lease.providerLeaseId, state: "stopped", confirmedAt: new Date().toISOString() }; + await settleStopOnlyCleanup(db, lease, { attemptId: String(lease.metadata?.pendingCleanupAttemptId), receipt }); + expect(await readLease(lease.id)).toMatchObject({ status: "released", leasePolicy: "retain_on_failure", metadata: { remoteExecutionTermination: { state: "stopped" } } }); + expect(hasRequiredWorkspaceRecovery(failed?.resultJson)).toBe(true); + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, failed!)).toBe(true); + // A late adapter metadata write cannot erase the source pin when its + // in-memory result predates the terminal transaction. + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...failed!, resultJson: patch.resultJson })).toBe(true); + const repeated = await terminalizeLegacyExecution({ db, run: failed!, status: "failed", patch }); + expect(repeated?.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [f.lease.id] }); + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))).toHaveLength(1); + }); + + it("leaves local lock retry and successful remote cleanup policy unchanged", async () => { + const local = await seed({ local: true }); + const failure = { ...patch, resultJson: { ...patch.resultJson, workspaceRestoreFailure: "restore_lock_timeout" } }; + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...local.run, status: "failed", ...failure })).toBe(false); + expect(await readLease(local.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" }); + const remote = await seed(); + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...remote.run, status: "succeeded", resultJson: {} })).toBe(false); + expect(await readLease(remote.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" }); + }); + + it("does not retain another run's lease or revive an already released source", async () => { + const f = await seed(), other = await seed(); + await db.update(environmentLeases).set({ status: "released", releasedAt: new Date() }).where(eq(environmentLeases.id, f.lease.id)); + expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...f.run, status: "failed", ...patch })).toBe(false); + await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }); + expect(await readLease(f.lease.id)).toMatchObject({ status: "released", leasePolicy: "ephemeral" }); + expect(await readLease(other.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" }); + }); + + it("keeps a competing physical owner out of the stop receipt", async () => { + const f = await seed(), other = await seed(); + await db.update(environmentLeases).set({ providerLeaseId: f.lease.providerLeaseId }).where(eq(environmentLeases.id, other.lease.id)); + await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }); + expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", metadata: { sandboxStopAndRetain: { runId: f.runId } } }); + expect(await readLease(other.lease.id)).toMatchObject({ status: "active", heartbeatRunId: other.runId }); + }); + + + it("keeps the original source if host finalization fails after adapter settlement", async () => { + const f = await seed(); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson); + const [current] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(current.status).toBe("running"); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toHaveLength(1); + expect(hasRequiredWorkspaceRecovery(current.resultJson)).toBe(true); + expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" }); + // A restart's process-loss finalization sees the durable obligation, even + // when its own result predates the restore failure. + const stale = { stopReason: "process_lost" }; + const failed = await terminalizeLegacyExecution({ db, run: f.run, status: "failed", reconcileIfNeeded: true, patch: { errorCode: "process_lost", resultJson: stale } }); + expect(hasRequiredWorkspaceRecovery(failed?.resultJson)).toBe(true); + expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" }); + expect(hasConversationContinuationPolicy(failed?.resultJson)).toBe(false); + }); + + + it("records a repair action when cancellation wins before adapter copy-back reports failure", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(), resultJson: { executionCancellation: { state: "acknowledged", proof: "provider_termination_receipt" } } }).where(eq(heartbeatRuns.id, f.runId)); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run.status).toBe("cancelled"); + expect(run.resultJson?.executionCancellation).toMatchObject({ state: "acknowledged", proof: "provider_termination_receipt" }); + expect(hasRequiredWorkspaceRecovery(run.resultJson)).toBe(true); + expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toHaveLength(1); + }); + + it.each(["write_condition", "native_takeover"])("preserves the existing terminal-write fence for %s", async scenario => { + const f = await seed(); + if (scenario === "native_takeover") await db.update(heartbeatRuns).set({ runtimeMode: "native" }).where(eq(heartbeatRuns.id, f.runId)); + const failed = await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch, + reconcileIfNeeded: true, ...(scenario === "write_condition" ? { writeConditions: [sql`false`] } : {}), + }); + expect(failed).toBeNull(); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("running"); + expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it.each([false, true])("keeps exact retained source IDs through a projected metadata update (merge=%s)", async mergeCurrent => { + const f = await seed(); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const projection = { workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1" }, presentationDecision: "updated" }; + const [updated] = await db.update(heartbeatRuns).set({ resultJson: preserveWorkspaceRestoreRecoveryMetadataSql(projection, mergeCurrent) }) + .where(eq(heartbeatRuns.id, f.runId)).returning(); + expect(updated.resultJson).toMatchObject({ presentationDecision: "updated", workspaceRestoreRecovery: run.resultJson!.workspaceRestoreRecovery }); + expect(updated.resultJson?.workspaceRestoreFailure).toBe("restore_failed"); + }); + + it("keeps ordinary metadata replacement and null semantics without a repair marker", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ resultJson: { oldValue: true } }).where(eq(heartbeatRuns.id, f.runId)); + const [replaced] = await db.update(heartbeatRuns).set({ resultJson: preserveWorkspaceRestoreRecoveryMetadataSql({ newValue: true }) }) + .where(eq(heartbeatRuns.id, f.runId)).returning(); + expect(replaced.resultJson).toEqual({ newValue: true }); + const [cleared] = await db.update(heartbeatRuns).set({ resultJson: preserveWorkspaceRestoreRecoveryMetadataSql(null) }) + .where(eq(heartbeatRuns.id, f.runId)).returning(); + expect(cleared.resultJson).toBeNull(); + }); + + it("rolls back terminal status and retention together on a failed transaction", async () => { + const f = await seed(); + await expect(db.transaction(async tx => { await terminalizeLegacyExecution({ db: tx as unknown as typeof db, run: f.run, status: "failed", patch }); throw new Error("outer transaction failed"); })).rejects.toThrow("outer transaction failed"); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("running"); + expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); it.each(["conversation_reset", "new_owner"])("late restore remains repair-blocked after %s", async scenario => { + const f = await seed(); + await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(), + contextSnapshot: { issueId: f.issueId, conversationSessionGeneration: 0 }, + resultJson: { executionCancellation: { state: "acknowledged" }, conversationContinuation: "continue_conversation_v1" }, + }).where(eq(heartbeatRuns.id, f.runId)); + if (scenario === "conversation_reset") await db.update(issues).set({ conversationAgentId: f.run.agentId, conversationUserId: "board-review-fixture", conversationState: "active", + conversationSessionGeneration: scenario === "conversation_reset" ? 1 : 0, + }).where(eq(issues.id, f.issueId)); + if (scenario === "new_owner") { + const id = randomUUID(); + await db.insert(agents).values({ id, companyId: f.companyId, name: "New owner", role: "engineer", adapterType: "codex_local" }); + await db.update(issues).set({ assigneeAgentId: id }).where(eq(issues.id, f.issueId)); + } + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(hasRequiredWorkspaceRecovery(run.resultJson)).toBe(true); + const lease = await readLease(f.lease.id); + expect(lease).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" }); + await settleStopOnlyCleanup(db, lease, { attemptId: String(lease.metadata?.pendingCleanupAttemptId), + receipt: { providerLeaseId: lease.providerLeaseId, state: "stopped" } }); + const { getExecutionBlocker } = await import("../services/execution-blocker.js"); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ + recoveryActionId: expect.any(String), canRetry: false, canContinue: false, + }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))) + .toHaveLength(1); + }); + + it("keeps a late source hold alongside a newer incident without replacing its execution", async () => { + const f = await seed(), newerRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.run.agentId, + status: "running", runtimeMode: "legacy", contextSnapshot: { issueId: f.issueId } }); + await db.update(issues).set({ executionRunId: newerRunId, checkoutRunId: newerRunId }).where(eq(issues.id, f.issueId)); + const [newerAction] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, + kind: "active_run_watchdog", status: "active", cause: "another_incident", fingerprint: `newer:${newerRunId}`, + evidence: { runId: newerRunId }, nextAction: "Inspect the current incident." }).returning(); + const failed = await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }); + await terminalizeLegacyExecution({ db, run: failed!, status: "failed", patch }); + const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(task).toMatchObject({ status: "in_progress", executionRunId: newerRunId, checkoutRunId: newerRunId }); + const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(actions).toHaveLength(2); + expect(actions.find(a => a.id === newerAction.id)).toEqual(newerAction); + const source = actions.find(a => a.evidence.runId === f.runId)!; + expect(source).toMatchObject({ status: "resolved", outcome: "blocked", ownerType: "board", + returnOwnerAgentId: null, attemptCount: 1, evidence: { automaticRecovery: { replay: "blocked" } } }); + const { markExecutionReconciliation } = await import("../services/execution-recovery-resolution.js"); + await markExecutionReconciliation(db, source, { runId: f.runId, providerStopped: true, actionOutcome: "mixed", + outcomeEvidence: "The original provider's actions were inspected.", + workspaceRepairEvidence: "The exact retained workspace was recovered and verified.", + }, "board-test", undefined, { workspaceRepairOnly: true }); + await terminalizeLegacyExecution({ db, run: failed!, status: "failed", patch }); + const [repaired] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, source.id)); + expect(repaired.evidence.automaticRecovery).toBeUndefined(); + expect(repaired.evidence.continuationDelivery).toBe("not_requested"); + expect(repaired.evidence.executionReconciliation).toMatchObject({ runId: f.runId }); + }); + +}); diff --git a/server/src/__tests__/workspace-restore-repair-route.test.ts b/server/src/__tests__/workspace-restore-repair-route.test.ts new file mode 100644 index 00000000000..dd1f0723ddd --- /dev/null +++ b/server/src/__tests__/workspace-restore-repair-route.test.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq, inArray } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, agents, agentWakeupRequests, companies, createDb, + environmentLeases, heartbeatRuns, issueRecoveryActions, issues, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { getExecutionBlocker } from "../services/execution-blocker.js"; +import { remoteTerminationReceipt } from "../services/remote-execution-termination.js"; +import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js"; + +const external = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); +const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); + +(support.supported ? describe : describe.skip)("retained workspace repair route", () => { + let db: ReturnType; + let temporary: Awaited> | undefined; + beforeAll(async () => { + if (external) db = createDb(external); + else { + temporary = await startEmbeddedPostgresTestDatabase("workspace-restore-repair-route-"); + db = createDb(temporary.connectionString); + } + }, 30_000); + afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); }); + + async function seed(options: { newerAction?: boolean; conversationReset?: boolean; noCurrentOwner?: boolean } = {}) { + const companyId = randomUUID(), originalAgentId = randomUUID(), currentAgentId = randomUUID(); + const issueId = randomUUID(), sourceRunId = randomUUID(), currentRunId = randomUUID(), leaseId = randomUUID(); + const actionId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Workspace repair API", issuePrefix: `W${companyId.slice(0, 7)}` }); + await db.insert(agents).values([ + { id: originalAgentId, companyId, name: "Original worker", role: "engineer", adapterType: "codex_local" }, + { id: currentAgentId, companyId, name: "Current worker", role: "engineer", adapterType: "codex_local" }, + ]); + const ownerId = options.conversationReset ? originalAgentId : currentAgentId; + await db.insert(issues).values({ id: issueId, companyId, title: "Preserve changed task", status: "in_progress", + assigneeAgentId: options.noCurrentOwner ? null : ownerId, + ...(options.conversationReset ? { conversationAgentId: originalAgentId, conversationUserId: "fixture-board", + conversationState: "active", conversationSessionGeneration: 2 } : {}), + }); + const workspaceRestoreRecovery = { schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, leaseIds: [leaseId] }; + await db.insert(heartbeatRuns).values([ + { id: sourceRunId, companyId, agentId: originalAgentId, runtimeMode: "legacy", status: "cancelled", + finishedAt: new Date("2000-01-01T00:00:00Z"), contextSnapshot: { issueId, conversationSessionGeneration: 1 }, + runnerProfileJson: { adapterDispatch: { adapterType: "codex_local" } }, + resultJson: { workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery, + conversationContinuation: "continue_conversation_v1" } }, + { id: currentRunId, companyId, agentId: ownerId, runtimeMode: "legacy", status: "running", + contextSnapshot: { issueId, conversationSessionGeneration: 2 } }, + ]); + await db.update(issues).set({ executionRunId: currentRunId, checkoutRunId: currentRunId, + executionLockedAt: new Date("2000-01-01T00:00:00Z"), executionAgentNameKey: "current-worker" }) + .where(eq(issues.id, issueId)); + const identity = { id: leaseId, companyId, heartbeatRunId: sourceRunId, provider: "daytona", providerLeaseId: `fixture-${leaseId}` }; + await db.insert(environmentLeases).values({ ...identity, issueId, status: "released", leasePolicy: "retain_on_failure", + releasedAt: new Date(), cleanupStatus: "success", metadata: { + remoteExecutionTermination: remoteTerminationReceipt(identity, { providerLeaseId: identity.providerLeaseId, state: "stopped" }), + } }); + await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId: issueId, + kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation", ownerType: "board", + returnOwnerAgentId: originalAgentId, fingerprint: `legacy-execution:${sourceRunId}`, + status: "resolved", outcome: "blocked", nextAction: "Repair the retained workspace before continuing.", + evidence: { runId: sourceRunId, workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery, + automaticRecovery: { replay: "blocked" } }, + }); + const newerId = options.newerAction ? randomUUID() : null; + if (newerId) await db.insert(issueRecoveryActions).values({ id: newerId, companyId, sourceIssueId: issueId, + kind: "configuration_validation", cause: "configuration_incomplete", ownerType: "board", status: "active", + fingerprint: `newer:${currentRunId}`, nextAction: "Review the current configuration.", evidence: { runId: currentRunId } }); + const [issueBefore] = await db.select().from(issues).where(eq(issues.id, issueId)); + const [leaseBefore] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId)); + const body = { actionId, outcome: "restored", sourceIssueStatus: issueBefore.status, + executionReconciliation: { runId: sourceRunId, providerStopped: true, actionOutcome: "mixed", + outcomeEvidence: "Reviewed the original action receipts without replaying any previous work.", + workspaceRepairEvidence: "Recovered and verified the required files from the exact retained sandbox." } }; + return { companyId, issueId, sourceRunId, currentRunId, originalAgentId, currentAgentId, ownerId, + leaseId, actionId, newerId, issueBefore, leaseBefore, body }; + } + + function appFor(actor: Record = { type: "board", source: "local_implicit" }) { + const wake = vi.fn(async () => null); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { (req as unknown as { actor: unknown }).actor = actor; next(); }); + app.use("/api", issueRoutes(db, {} as Parameters[1], { recoveryActionEnqueueWakeup: wake })); + app.use(errorHandler); + return { app, wake }; + } + + async function assertSourceUnchanged(f: Awaited>) { + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.issueBefore); + expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.leaseId)))[0]).toEqual(f.leaseBefore); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual([]); + expect(await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.id, [f.sourceRunId, f.currentRunId]))).toHaveLength(2); + } + + it.each(["reassigned", "conversation_reset", "unassigned"])("records board repair after %s without restarting or changing the current task", async scenario => { + const f = await seed({ conversationReset: scenario === "conversation_reset", noCurrentOwner: scenario === "unassigned" }); + const { app, wake } = appFor(); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull(); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)); + expect(action.evidence).toMatchObject({ workspaceRestoreRecovery: { leaseIds: [f.leaseId] }, + executionReconciliation: { runId: f.sourceRunId, workspaceRepairEvidence: f.body.executionReconciliation.workspaceRepairEvidence } }); + expect(action.evidence.automaticRecovery).toBeUndefined(); + expect(action.evidence.continuationDelivery).not.toBe("pending"); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull(); + const auditBefore = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + expect(auditBefore.length).toBeGreaterThan(0); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200); + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0]).toEqual(action); + expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId))).toEqual(auditBefore); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + }); + + it.each(["blocked", "done", "cancelled", "backlog"])("records repair without changing the current %s task", async status => { + const f = await seed(); + await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.currentRunId)); + const [before] = await db.update(issues).set({ status, executionRunId: null, checkoutRunId: null, + executionLockedAt: null, executionAgentNameKey: null }).where(eq(issues.id, f.issueId)).returning(); + const { app, wake } = appFor(); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`) + .send({ ...f.body, sourceIssueStatus: status }).expect(200); + await assertSourceUnchanged({ ...f, issueBefore: before }); + expect(wake).not.toHaveBeenCalled(); + }); + + it("repairs only the recorded source while a newer active recovery action remains unchanged", async () => { + const f = await seed({ newerAction: true }); + const [newerBefore] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.newerId!)); + const { app, wake } = appFor(); + const before = await request(app).get(`/api/issues/${f.issueId}/recovery-actions`).expect(200); + expect(before.body.active).toMatchObject({ id: f.newerId }); + expect(before.body.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: f.actionId, evidence: expect.objectContaining({ + workspaceRestoreRecovery: { schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, leaseIds: [f.leaseId] }, + }) }), expect.objectContaining({ id: f.newerId }), + ])); + const response = await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200); + expect(response.body.issue.activeRecoveryAction).toMatchObject({ id: f.newerId }); + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.newerId!)))[0]).toEqual(newerBefore); + const repeated = await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200); + expect(repeated.body.issue.activeRecoveryAction).toMatchObject({ id: f.newerId }); + const after = await request(app).get(`/api/issues/${f.issueId}/recovery-actions`).expect(200); + expect(after.body.active).toMatchObject({ id: f.newerId }); + expect(after.body.actions.map((action: { id: string }) => action.id)).toEqual([f.newerId]); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + }); + + it("rejects an agent even when it owns the current task and run", async () => { + const f = await seed(); + const { app, wake } = appFor({ type: "agent", source: "agent_jwt", companyId: f.companyId, + agentId: f.ownerId, runId: f.currentRunId }); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(403); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0].evidence) + .not.toHaveProperty("executionReconciliation"); + }); + + it("does not expose retained-source evidence to another company", async () => { + const f = await seed(), other = await seed(); + const { app } = appFor({ type: "agent", source: "agent_jwt", companyId: other.companyId, + agentId: other.ownerId, runId: other.currentRunId }); + const response = await request(app).get(`/api/issues/${f.issueId}/recovery-actions`).expect(404); + expect(JSON.stringify(response.body)).not.toContain(f.leaseId); + expect(JSON.stringify(response.body)).not.toContain(f.actionId); + }); + + it.each(["other_task", "other_company", "wrong_decision_run"])("rejects repair evidence for %s", async scenario => { + const f = await seed(); + const other = await seed(); + const foreignTaskId = scenario === "other_task" ? randomUUID() : other.issueId; + if (scenario === "other_task") await db.insert(issues).values({ id: foreignTaskId, companyId: f.companyId, + title: "Other task", status: "todo", assigneeAgentId: f.originalAgentId }); + if (scenario !== "wrong_decision_run") await db.update(heartbeatRuns).set({ + ...(scenario === "other_company" ? { companyId: other.companyId, agentId: other.originalAgentId } : {}), + contextSnapshot: { issueId: foreignTaskId }, + }).where(eq(heartbeatRuns.id, f.sourceRunId)); + const { app, wake } = appFor(); + const body = scenario === "wrong_decision_run" ? { ...f.body, + executionReconciliation: { ...f.body.executionReconciliation, runId: other.sourceRunId } } : f.body; + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(body).expect(409); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + }); + + it("does not authorize ordinary execution reconciliation through the repair-only request shape", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ resultJson: { conversationContinuation: "continue_conversation_v1" } }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.update(issueRecoveryActions).set({ evidence: { runId: f.sourceRunId, automaticRecovery: { replay: "blocked" } } }) + .where(eq(issueRecoveryActions.id, f.actionId)); + const { app, wake } = appFor(); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(409); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + }); + + it("rejects a client-supplied repair-only authority flag", async () => { + const f = await seed(); + const { app, wake } = appFor(); + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`) + .send({ ...f.body, workspaceRepairOnly: true }).expect(400); + await assertSourceUnchanged(f); + expect(wake).not.toHaveBeenCalled(); + }); + + it.each(["missing_repair_evidence", "unconfirmed_stop", "missing_source_marker", "status_change"])("rejects %s without resolving the hold", async scenario => { + const f = await seed(); + if (scenario === "unconfirmed_stop") await db.update(environmentLeases).set({ metadata: {} }).where(eq(environmentLeases.id, f.leaseId)); + if (scenario === "missing_source_marker") await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreFailure: "restore_failed" } }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + const { app, wake } = appFor(); + const body = scenario === "missing_repair_evidence" ? { ...f.body, + executionReconciliation: { ...f.body.executionReconciliation, workspaceRepairEvidence: undefined } } + : scenario === "status_change" ? { ...f.body, sourceIssueStatus: "todo" } : f.body; + await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(body) + .expect(scenario === "missing_repair_evidence" ? 400 : 409); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.issueBefore); + expect((await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.id, f.actionId), + eq(issueRecoveryActions.companyId, f.companyId))))[0].evidence).not.toHaveProperty("executionReconciliation"); + expect(wake).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bf3fcda0318..9883c2aa6f6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,3 +1,4 @@ +import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js"; import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js"; import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; @@ -9369,9 +9370,15 @@ export function issueRoutes( trigger: "read_projection", actor: getActorInfo(req), }); + const retainedHolds = await db.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, issue.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id), + sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`, + sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`, + )).orderBy(desc(issueRecoveryActions.createdAt)); res.json({ active, - actions: active ? [active] : [], + actions: [...(active ? [active] : []), ...retainedHolds + .filter(action => action.id !== active?.id).map(issueRecoveryActionReadModel)], }); }); @@ -9465,6 +9472,37 @@ export function issueRoutes( .then((rows) => rows[0] ?? null); if (!lockedIssue) throw notFound("Issue not found"); + // Retained files are a source-scoped obligation, independent of the + // current task owner/generation and any newer active recovery incident. + // Repair records evidence only: it must not reopen or replay old work. + const [workspaceRepairAction] = await tx.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, lockedIssue.companyId), + eq(issueRecoveryActions.sourceIssueId, lockedIssue.id), + actionId ? eq(issueRecoveryActions.id, actionId) : inArray(issueRecoveryActions.status, ["active", "escalated"]), + )).limit(1).for("update"); + if (workspaceRepairAction && hasRequiredWorkspaceRecovery(workspaceRepairAction.evidence)) { + assertBoard(req); + await requireRecoveryActionAuthority(req, lockedIssue, issueRecoveryActionReadModel(workspaceRepairAction), + { source: "recovery_action_resolution" }); + if (outcome !== "restored" || sourceIssueStatus !== lockedIssue.status) { + throw conflict("Record workspace repair without changing the current task status or requesting a retry."); + } + await validateExecutionReconciliation({ db: tx as unknown as Db, + companyId: lockedIssue.companyId, issueId: lockedIssue.id, agentId: lockedIssue.assigneeAgentId, + sourceRunId: workspaceRepairAction.evidence.runId, decision: executionReconciliation, + workspaceRepairOnly: true, + }); + if (workspaceRepairAction.evidence.executionReconciliation) { + return { issue: lockedIssue, recoveryAction: workspaceRepairAction, replayed: true, workspaceRepairOnly: true }; + } + await markExecutionReconciliation(tx as unknown as Db, workspaceRepairAction, + executionReconciliation!, actor.actorId, undefined, { workspaceRepairOnly: true }); + const [recoveryAction] = await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "restored", + resolutionNote: resolutionNote ?? null, resolvedAt: new Date(), updatedAt: new Date(), + }).where(eq(issueRecoveryActions.id, workspaceRepairAction.id)).returning(); + return { issue: lockedIssue, recoveryAction, chatRetry: null, workspaceRepairOnly: true }; + } + let activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue( lockedIssue.companyId, lockedIssue.id, @@ -9845,7 +9883,9 @@ export function issueRoutes( }); if (result.replayed) { res.json({ - issue: result.issue, + issue: "workspaceRepairOnly" in result + ? { ...result.issue, activeRecoveryAction: await recoveryActionsSvc.getActiveForIssue(result.issue.companyId, result.issue.id) } + : result.issue, recoveryAction: result.recoveryAction, }); return; @@ -9954,7 +9994,9 @@ export function issueRoutes( res.json({ issue: { ...result.issue, - activeRecoveryAction: null, + activeRecoveryAction: "workspaceRepairOnly" in result + ? await recoveryActionsSvc.getActiveForIssue(result.issue.companyId, result.issue.id) + : null, }, recoveryAction: result.recoveryAction, }); diff --git a/server/src/services/conversation-continuation.ts b/server/src/services/conversation-continuation.ts index 3dd737357f6..4730357c0ff 100644 --- a/server/src/services/conversation-continuation.ts +++ b/server/src/services/conversation-continuation.ts @@ -1,6 +1,8 @@ +import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js"; import { and, desc, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; import { environmentLeases, heartbeatRunEvents, heartbeatRuns, issueRecoveryActions, type Db } from "@paperclipai/db"; import { readProcessStartedAt } from "./hot-restart.js"; +import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; // These adapters accept a conversation turn. Retrying a process or webhook can // replay the action itself, so those adapters retain their recovery contract. @@ -16,7 +18,7 @@ export function isConversationAdapter(adapterType: string): boolean { export const CONVERSATION_CONTINUATION_POLICY = "continue_conversation_v1"; export function hasConversationContinuationPolicy(result: Record | null | undefined): boolean { - return result?.workspaceRestoreFailure !== "restore_unsafe_archive" && result?.conversationContinuation === CONVERSATION_CONTINUATION_POLICY; + return result?.workspaceRestoreFailure !== "restore_unsafe_archive" && !hasRequiredWorkspaceRecovery(result) && result?.conversationContinuation === CONVERSATION_CONTINUATION_POLICY; } /** Persisted by the server when it claims the run, before remote provisioning. */ @@ -52,7 +54,7 @@ export async function historicalAdapterType(db: Db, run: typeof heartbeatRuns.$i } export async function runUsedConversationAdapter(db: Db, run: typeof heartbeatRuns.$inferSelect): Promise { - if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive") return false; + if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive" || hasRequiredWorkspaceRecovery(run.resultJson)) return false; if (hasConversationContinuationPolicy(run.resultJson)) return true; const adapterType = await historicalAdapterType(db, run); return adapterType !== null && isConversationAdapter(adapterType); @@ -72,6 +74,7 @@ export function conversationRecoveryActionPredicate() { and coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueRecoveryActions.sourceIssueId}::text and ${heartbeatRuns.runtimeMode} = 'legacy' and coalesce(${heartbeatRuns.resultJson}->>'workspaceRestoreFailure', '') <> 'restore_unsafe_archive' + and coalesce(${heartbeatRuns.resultJson}->'workspaceRestoreRecovery'->>'schema', '') <> 'paperclip.workspace-restore-recovery.v1' and ${inArray(heartbeatRuns.status, ['failed', 'timed_out', 'interrupted', 'cancelled'])} and ${conversationRunPredicate()} and ${or( @@ -110,9 +113,29 @@ export async function getConversationOwnershipBlocker(db: Db, companyId: string, conversationRunPredicate(), sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueId}`, inArray(heartbeatRuns.status, ["failed", "timed_out", "interrupted", "cancelled"]), - or(isNotNull(heartbeatRuns.processPid), isNotNull(heartbeatRuns.processGroupId), activeLease), + or(isNotNull(heartbeatRuns.processPid), isNotNull(heartbeatRuns.processGroupId), activeLease, + sql`${heartbeatRuns.resultJson}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'`), )).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)); for (const { run, activeLease: leaseHeld } of candidates) { + if (hasRequiredWorkspaceRecovery(run.resultJson)) { + const leases = await db.select().from(environmentLeases).where(and( + eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id), + )); + const retainedIds = (run.resultJson!.workspaceRestoreRecovery as { leaseIds?: unknown }).leaseIds; + const remoteLeases = leases.filter(lease => lease.provider !== "local"); + const remoteStopped = Array.isArray(retainedIds) && retainedIds.length > 0 + && retainedIds.every(id => typeof id === "string" && remoteLeases.some(lease => + lease.id === id && hasRemoteTerminationReceipt(lease))) + && remoteLeases.every(hasRemoteTerminationReceipt); + if (!remoteStopped) return { + runId: run.id, agentId: run.agentId, cause: "execution_owner_active", + nextAction: "The retained workspace environment has not confirmed that it stopped. Wait for cleanup before continuing this task.", + }; + // These PIDs came from the sandbox. A matching process on this host is + // unrelated and must not hide the repair action or block a repaired task. + // A separate local lease keeps the existing host process checks below. + if (leases.every(lease => lease.provider !== "local")) continue; + } let pidAlive = run.processPid !== null && processMayBeAlive(run.processPid); if (pidAlive && run.processStartedAt) { // A recycled PID cannot keep an old task blocked. An unreadable identity diff --git a/server/src/services/execution-blocker.ts b/server/src/services/execution-blocker.ts index 14c316b5a00..b4efb1a7e6c 100644 --- a/server/src/services/execution-blocker.ts +++ b/server/src/services/execution-blocker.ts @@ -7,6 +7,7 @@ import { canContinueCancelledRun, readRunCancellation } from "./run-cancellation import { canRetryStoppedRun } from "./cancelled-native-startup.js"; import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js"; import { EXECUTION_RECONCILIATION_CAUSES, type ExecutionBlocker } from "@paperclipai/shared"; +import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; /** Resolved recovery bookkeeping can still carry an effective no-replay hold. */ export function executionBlockerPredicate() { @@ -24,13 +25,16 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st assigneeAgentId: issues.assigneeAgentId }).from(issues).where(and( eq(issues.companyId, companyId), eq(issues.id, issueId), )).limit(1); - // Resetting model context cannot make an unsafe workspace safe. This hold + // Resetting model context cannot repair or recover a workspace. This hold // survives conversation boundaries until the existing repair/reconciliation path clears it. const [restoreHold] = await db.select().from(issueRecoveryActions).where(and( eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId), executionBlockerPredicate(), - sql`${issueRecoveryActions.evidence}->>'workspaceRestoreFailure' = 'restore_unsafe_archive'`, + or( + sql`${issueRecoveryActions.evidence}->>'workspaceRestoreFailure' = 'restore_unsafe_archive'`, + sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`, + ), )).orderBy(desc(issueRecoveryActions.updatedAt)).limit(1); // A persisted user /new is an ordered context command, not a retry of uncertain work. // The normal issue execution lock still serializes it behind any active turn. @@ -80,9 +84,9 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st or consumed.context_snapshot->'wakeCommentIds' @> jsonb_build_array(${issueComments.id}::text)))`, )) : []; const cancellation = run ? readRunCancellation(run.resultJson) : null; - const eligibleContinuation = Boolean(run && conversation?.assigneeAgentId === run.agentId && + const eligibleContinuation = Boolean(!restoreHold && run && conversation?.assigneeAgentId === run.agentId && !["done", "cancelled"].includes(conversation.status) && canContinueCancelledRun(run)); - const canRetry = Boolean(run && conversation?.assigneeAgentId === run.agentId && + const canRetry = Boolean(!restoreHold && run && conversation?.assigneeAgentId === run.agentId && !["done", "cancelled"].includes(conversation.status) && await canRetryStoppedRun(db, run)); const runError = run && canRetry && isPreDispatchReviewWait(run) ? "Waiting for review; this continuation never started." @@ -110,7 +114,7 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st } else { nextAction = "Send a new chat message to continue this conversation."; } - } else if (run?.status === "cancelled" && !eligibleContinuation && !canRetry && action.cause === "legacy_execution_requires_reconciliation") { + } else if (!restoreHold && run?.status === "cancelled" && !eligibleContinuation && !canRetry && action.cause === "legacy_execution_requires_reconciliation") { nextAction += " Inspect the run before sending a new message to request continuation."; } @@ -125,6 +129,7 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st runError: runError?.slice(0, 1024) ?? null, canContinue: eligibleContinuation && !chatBinding, canRetry: canRetry && !chatBinding, + ...(restoreHold ? { workspaceRepairRequired: true } : {}), savedMessageCount: saved.length, }; } diff --git a/server/src/services/execution-recovery-resolution.ts b/server/src/services/execution-recovery-resolution.ts index afaff4de890..2bdf431e4a1 100644 --- a/server/src/services/execution-recovery-resolution.ts +++ b/server/src/services/execution-recovery-resolution.ts @@ -22,6 +22,8 @@ import { } from "@paperclipai/shared"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { isSupersededConversationRun } from "./agent-conversations.js"; +import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js"; +import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; /** An operator records observed outcomes; this is not permission to blindly retry. */ export async function validateExecutionReconciliation(input: { @@ -31,9 +33,11 @@ export async function validateExecutionReconciliation(input: { agentId: string | null; sourceRunId: unknown; decision: ExecutionReconciliation | undefined; + /** Board-only source repair: never authorize a continuation of this run. */ + workspaceRepairOnly?: boolean; }) { const { db, companyId, issueId, agentId, decision } = input; - if (!decision || decision.runId !== input.sourceRunId || !agentId) { + if (!decision || decision.runId !== input.sourceRunId || (!agentId && !input.workspaceRepairOnly)) { throw conflict( "Reconcile the recorded execution and its action outcomes before continuing this task.", ); @@ -59,11 +63,13 @@ export async function validateExecutionReconciliation(input: { review?.status === "pending" && review.currentParticipant?.type === "agent" && review.currentParticipant.agentId === run?.agentId; + const retainedWorkspace = hasRequiredWorkspaceRecovery(run?.resultJson); + const repairOnly = input.workspaceRepairOnly === true && retainedWorkspace; if ( + (input.workspaceRepairOnly && !retainedWorkspace) || !run || !task || - task.assigneeAgentId !== agentId || - (run.agentId !== agentId && !isCurrentReviewer) || + (!repairOnly && (task.assigneeAgentId !== agentId || (run.agentId !== agentId && !isCurrentReviewer))) || (run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId || !["failed", "interrupted", "timed_out", "cancelled"].includes(run.status) ) { @@ -75,10 +81,32 @@ export async function validateExecutionReconciliation(input: { (!decision.workspaceRepairEvidence || decision.workspaceRepairEvidence.trim().length < 20)) { throw conflict("Verify safe workspace staging or repair and record workspaceRepairEvidence before continuing this run."); } - for (const pid of [ + let checkLocalProcesses = true; + if (retainedWorkspace) { + const leases = await db.select().from(environmentLeases).where(and( + eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id), + )); + const recovery = run.resultJson!.workspaceRestoreRecovery as { leaseIds?: unknown }; + const retainedIds = recovery.leaseIds; + // The terminal transaction records the exact source allocations. A release + // timestamp is written before stop dispatch, so only matching provider + // acknowledgements can establish that repair will not race active writes. + if (!Array.isArray(retainedIds) || !retainedIds.length || + !retainedIds.every(id => typeof id === "string" && leases.some(lease => + lease.id === id && hasRemoteTerminationReceipt(lease))) || + leases.some(lease => lease.provider === "local" + ? !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed" + : !hasRemoteTerminationReceipt(lease))) { + throw conflict("The retained workspace environment has not confirmed that it stopped. Wait for cleanup before recording workspace repair."); + } + checkLocalProcesses = leases.some(lease => lease.provider === "local"); + } + // A retained remote source's PIDs belong to its sandbox, not this server. + // Its exact lease receipts above replace host-local process probes. + for (const pid of checkLocalProcesses ? [ run.processPid, run.processGroupId ? -run.processGroupId : null, - ]) { + ] : []) { if (!pid) continue; try { process.kill(pid, 0); @@ -120,11 +148,11 @@ export async function validateExecutionReconciliation(input: { throw conflict( "The previous execution environment has not finished releasing its authority.", ); - await buildExecutionContinuation({ + if (!repairOnly) await buildExecutionContinuation({ db, companyId, issueId, - agentId, + agentId: agentId!, context: { previousRunId: run.id }, summary: null, exposeLowTrustRaw: false, @@ -142,7 +170,11 @@ export async function markExecutionReconciliation( decision: ExecutionReconciliation, actorId: string, deliveryOwner?: { kind: "chat_failed_run_retry"; actionId: string }, + options?: { workspaceRepairOnly?: boolean }, ) { + if (options?.workspaceRepairOnly && (deliveryOwner || !hasRequiredWorkspaceRecovery(action.evidence))) { + throw conflict("Workspace repair must target the retained source without a continuation owner."); + } if (deliveryOwner) { const [retry] = await db .select() @@ -187,7 +219,7 @@ export async function markExecutionReconciliation( actorId, recordedAt: new Date().toISOString(), }, - continuationDelivery: deliveryOwner ? "delegated" : "pending", + continuationDelivery: options?.workspaceRepairOnly ? "not_requested" : deliveryOwner ? "delegated" : "pending", ...(deliveryOwner ? { continuationDeliveryOwner: deliveryOwner } : {}), }, }) diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts index bbfd4bb2c99..b2c6edb749b 100644 --- a/server/src/services/explicit-native-continuation.ts +++ b/server/src/services/explicit-native-continuation.ts @@ -1,3 +1,4 @@ +import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js"; import { createHash } from "node:crypto"; import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js"; import { canContinueCancelledRun } from "./run-cancellation.js"; @@ -197,7 +198,7 @@ export async function admitExplicitNativeContinuation(input: { if (!lockedRun || lockedRun.status !== run.status || lockedRun.agentId !== run.agentId || lockedRun.finishedAt?.getTime() !== run.finishedAt.getTime()) return null; run = lockedRun; - if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive") { + if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive" || hasRequiredWorkspaceRecovery(run.resultJson)) { return blocked("workspace_repair_required", "Verify safe workspace staging or repair before continuing. Your message is saved."); } const cancelledStartup = await isCancelledNativeStartup(db, run, coordinator); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b475c28fd52..f936fa8b00f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,6 @@ +import { preserveWorkspaceRestoreRecoveryMetadataSql } from "./legacy-workspace-restore-recovery.js"; +import { preserveWorkspaceRestoreRecoveryMetadata } from "./workspace-restore-recovery-state.js"; +import { recordLegacyWorkspaceRestoreFailure } from "./legacy-execution-recovery.js"; import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironmentProjection } from "../vendor/paperclip-runner/index.js"; import { activeIssueInteractionCondition, TASK_QUESTION_GUIDANCE } from "./issue-question-context.js"; import { createAgentIdentityRedactor } from "./agent-identity-redaction.js"; @@ -3507,6 +3510,8 @@ const heartbeatRunSafeResultJsonColumn = sql | null>` 'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024), 'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024) )) end, + 'workspaceRestoreRecovery', case when ${heartbeatRuns.resultJson} #>> '{workspaceRestoreRecovery,schema}' = 'paperclip.workspace-restore-recovery.v1' + then jsonb_build_object('schema', 'paperclip.workspace-restore-recovery.v1') end, 'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure' in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed') then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end, @@ -3586,6 +3591,7 @@ const heartbeatRunExecutionEvidenceColumn = sql | null>` 'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason', 'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource', 'workspaceRestoreFailure', ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure', + 'workspaceRestoreRecovery', ${heartbeatRuns.resultJson} -> 'workspaceRestoreRecovery', 'executionCancellation', ${heartbeatRuns.resultJson} -> 'executionCancellation', 'nativeCancellation', ${heartbeatRuns.resultJson} -> 'nativeCancellation', 'cancelledByActorType', ${heartbeatRuns.resultJson} -> 'cancelledByActorType', @@ -13001,6 +13007,10 @@ export function heartbeatService( .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); + if (previousStatus && patch?.resultJson !== undefined) patch = { ...patch, + resultJson: preserveWorkspaceRestoreRecoveryMetadata(previousStatus.resultJson, patch.resultJson), + }; + // Preserve the receipt-source fence when finalization enriches usage. A // late spool replay must still be able to complete an unfinished receipt. if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } }; @@ -13027,17 +13037,13 @@ export function heartbeatService( }; } const updated = - previousStatus && - await legacyExecutionNeedsReconciliationWithEvidence(db, { - ...previousStatus, - status, - ...patch, - }) + previousStatus && previousStatus.runtimeMode === "legacy" && isHeartbeatRunTerminalStatus(status) ? await terminalizeLegacyExecution({ db, run: previousStatus, status, patch, + reconcileIfNeeded: true, }) : await db .update(heartbeatRuns) @@ -13099,6 +13105,10 @@ export function heartbeatService( patch = { ...patch, resultJson: cancellationResultJson(previousStatus, status, patch?.resultJson, patch?.errorCode, patch?.error) }; } + if (previousStatus && patch?.resultJson !== undefined) patch = { ...patch, + resultJson: preserveWorkspaceRestoreRecoveryMetadata(previousStatus.resultJson, patch.resultJson), + }; + // Preserve the receipt-source fence when finalization enriches usage. A // late spool replay must still be able to complete an unfinished receipt. if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } }; @@ -13122,18 +13132,15 @@ export function heartbeatService( }; } const updated = - previousStatus && - await legacyExecutionNeedsReconciliationWithEvidence(db, { - ...previousStatus, - status, - ...patch, - }) + previousStatus && previousStatus.runtimeMode === "legacy" && isHeartbeatRunTerminalStatus(status) ? await terminalizeLegacyExecution({ db, run: previousStatus, status, patch, fromStatuses, + reconcileIfNeeded: true, + writeConditions: [nativeRunnerOwnershipNotHeldCondition(), ...(cancellationCondition ? [cancellationCondition] : [])], }) : await db .update(heartbeatRuns) @@ -13847,9 +13854,8 @@ export function heartbeatService( .update(heartbeatRuns) .set({ livenessReason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, - resultJson: withUnmanagedBackgroundTaskStopReason( - parseObject(run.resultJson), - ), + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || + ${JSON.stringify({ stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON })}::jsonb`, updatedAt: new Date(), }) .where(eq(heartbeatRuns.id, run.id)); @@ -15056,7 +15062,7 @@ export function heartbeatService( const updated = await db .update(heartbeatRuns) .set({ - resultJson, + resultJson: preserveWorkspaceRestoreRecoveryMetadataSql(resultJson, true), error: run.errorCode === DETACHED_PROCESS_ERROR_CODE ? null : run.error, errorCode: @@ -20589,6 +20595,7 @@ export function heartbeatService( let nativeWorkspaceSync: Awaited< ReturnType > = null; + let requiredWorkspaceRestoreEvidence: Record | null = null; let providerResourceDispositionForRun: ProviderResourceDisposition | undefined; let nativeLifecycleTelemetryForRun: @@ -25276,6 +25283,16 @@ export function heartbeatService( adapterResult = await guardedDispatch.resultPromise; } adapterResult = identityRedactor.redact(adapterResult); + if (run.runtimeMode === "legacy" && hasWorkspaceRestoreFailure(adapterResult.resultJson) + && executionTarget?.kind === "remote" && executionTarget.transport === "sandbox") { + requiredWorkspaceRestoreEvidence = { + workspaceRestoreFailure: adapterResult.resultJson!.workspaceRestoreFailure, + ...(adapterResult.resultJson?.workspaceRestoreDiagnostic ? { workspaceRestoreDiagnostic: adapterResult.resultJson.workspaceRestoreDiagnostic } : {}), + }; + // Retention is the fallback even if recording this receipt fails. + providerResourceDispositionForRun = "stop_and_retain"; + await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence); + } for (const stream of ["stdout", "stderr"] as const) { const tail = identityRedactor.finish(stream); if (tail) await appendIdentityRedactedLog(stream, tail); @@ -25863,7 +25880,9 @@ export function heartbeatService( .update(heartbeatRuns) .set({ ...finalRunPatch, - resultJson: cancellationResultJson(persistedRunWrite.run, status, finalRunPatch.resultJson, runErrorCode, runErrorMessage), + resultJson: preserveWorkspaceRestoreRecoveryMetadataSql( + cancellationResultJson(persistedRunWrite.run, status, finalRunPatch.resultJson, runErrorCode, runErrorMessage) ?? null, + ), usageJson: { ...parseObject(persistedRunWrite.run.usageJson), ...parseObject(finalRunPatch.usageJson) }, finishedAt: @@ -26071,10 +26090,7 @@ export function heartbeatService( await db .update(heartbeatRuns) .set({ - resultJson: { - ...persistedResultJson, - presentationDecision, - }, + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ presentationDecision })}::jsonb`, updatedAt: new Date(), }) .where(eq(heartbeatRuns.id, livenessRun.id)); @@ -26474,6 +26490,7 @@ export function heartbeatService( errorMessage: message, resultJson: { ...parseObject(stopSnapshot?.resultJson), + ...requiredWorkspaceRestoreEvidence, ...(workspaceValidationFailure?.resultJson ?? configurationIncompleteFailure?.resultJson ?? {}), @@ -29944,18 +29961,15 @@ export function heartbeatService( .set({ error: reason, errorCode, - resultJson: { - ...parseObject(run.resultJson), + // Stop may have read the row before adapter settlement saved + // a required-copyback receipt. Merge into current database JSON + // and leave those server-owned fields to the restore recorder. + resultJson: preserveWorkspaceRestoreRecoveryMetadataSql({ ...resultJson, - ...(!running - ? { - executionCancellation: { - state: "requested", - requestedAt: new Date().toISOString(), - }, - } - : {}), - }, + ...(!running ? { executionCancellation: { + state: "requested", requestedAt: new Date().toISOString(), + } } : {}), + }, true), updatedAt: new Date(), }) .where( diff --git a/server/src/services/legacy-execution-recovery.ts b/server/src/services/legacy-execution-recovery.ts index 64c4c7f2db8..9d56e1891e0 100644 --- a/server/src/services/legacy-execution-recovery.ts +++ b/server/src/services/legacy-execution-recovery.ts @@ -1,9 +1,11 @@ +import { hasRequiredWorkspaceRecovery, preserveWorkspaceRestoreRecoveryMetadata, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; +import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources } from "./legacy-workspace-restore-recovery.js"; import { isPreDispatchReviewWaitVerified } from "./pre-dispatch-review-wait.js"; import { hasWorkspaceRestoreFailure } from "@paperclipai/shared"; import { normalizeMaxTurnStopReason } from "./heartbeat-stop-metadata.js"; import { claimedAdapterType, hasConversationContinuationPolicy } from "./conversation-continuation.js"; import { randomUUID } from "node:crypto"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import { environmentLeases, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations, type Db } from "@paperclipai/db"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; @@ -25,7 +27,7 @@ export function legacyExecutionNeedsReconciliation( ) return false; // A fresh model turn cannot repair or verify unrestored files. - if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive") return true; + if (run.resultJson?.workspaceRestoreFailure === "restore_unsafe_archive" || hasRequiredWorkspaceRecovery(run.resultJson)) return true; // A fresh conversation turn lets the agent decide what remains. The retry // scheduler, not an action-outcome hold, owns the automatic attempt limit. if (hasConversationContinuationPolicy(run.resultJson)) return false; @@ -57,7 +59,8 @@ export function legacyExecutionNeedsReconciliation( /** Review-wait receipts are only exempt after retained execution evidence agrees. * The synchronous classifier stays conservative for callers without a DB proof. */ export async function legacyExecutionNeedsReconciliationWithEvidence(db: Db, run: Run): Promise { - return legacyExecutionNeedsReconciliation(run) && !(await isPreDispatchReviewWaitVerified(db, run)); + return await hasUnrestoredRemoteWorkspace(db, run) || + (legacyExecutionNeedsReconciliation(run) && !(await isPreDispatchReviewWaitVerified(db, run))); } /** Persist the failed legacy run, owned lock release and operator decision together. */ @@ -67,8 +70,13 @@ export async function terminalizeLegacyExecution(input: { status: string; patch?: Partial; fromStatuses?: string[]; + reconcileIfNeeded?: boolean; + writeConditions?: SQL[]; + /** Adapter settlement records a copy-back failure before host finalization. */ + recordRestoreFailureOnly?: boolean; }) { - const { db, run, status, patch } = input; + const { db, run, status } = input; + let patch = input.patch; const issueId = run.nativeIssueId ?? (typeof run.contextSnapshot?.issueId === "string" @@ -87,10 +95,19 @@ export async function terminalizeLegacyExecution(input: { ) .for("update") : []; - const [updated] = await tx + const [current] = await tx.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId), + )).for("update"); + if (!current || current.runtimeMode !== "legacy" || + (!input.recordRestoreFailureOnly && !(input.fromStatuses ?? [run.status]).includes(current.status))) return null; + if (patch?.resultJson !== undefined) patch = { ...patch, + resultJson: preserveWorkspaceRestoreRecoveryMetadata(current.resultJson, + input.recordRestoreFailureOnly ? { ...current.resultJson, ...patch.resultJson } : patch.resultJson), + }; + let [updated] = await tx .update(heartbeatRuns) .set({ - status, + status: input.recordRestoreFailureOnly ? current.status : status, ...patch, executionStatusDeliveryId: randomUUID(), updatedAt: new Date(), @@ -99,12 +116,24 @@ export async function terminalizeLegacyExecution(input: { and( eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId), - inArray(heartbeatRuns.status, input.fromStatuses ?? [run.status]), + eq(heartbeatRuns.status, current.status), + ...(input.writeConditions ?? []), ), ) .returning(); if (!updated) return null; - if (task?.executionRunId === run.id) + const retainedLeaseIds = await preserveLegacyWorkspaceRestoreSources(tx as unknown as Db, input.recordRestoreFailureOnly ? { ...updated, status: "failed" } : updated); + if (retainedLeaseIds.length) { + [updated] = await tx.update(heartbeatRuns).set({ resultJson: { + ...updated.resultJson, + workspaceRestoreRecovery: { schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, leaseIds: retainedLeaseIds }, + } }).where(and(eq(heartbeatRuns.id, updated.id), eq(heartbeatRuns.companyId, updated.companyId))).returning(); + } + const needsRecovery = input.recordRestoreFailureOnly + ? retainedLeaseIds.length > 0 || hasRequiredWorkspaceRecovery(updated.resultJson) + : !input.reconcileIfNeeded || await legacyExecutionNeedsReconciliationWithEvidence(tx as unknown as Db, updated); + if (!needsRecovery) return updated; + if (!input.recordRestoreFailureOnly && task?.executionRunId === run.id) await tx .update(issues) .set({ @@ -113,11 +142,45 @@ export async function terminalizeLegacyExecution(input: { executionLockedAt: null, }) .where(eq(issues.id, task.id)); - if (task?.checkoutRunId === run.id) + if (!input.recordRestoreFailureOnly && task?.checkoutRunId === run.id) await tx .update(issues) .set({ checkoutRunId: null }) .where(eq(issues.id, task.id)); + if (task && hasRequiredWorkspaceRecovery(updated.resultJson)) { + // File recovery belongs to this exact source, even if cancellation let a + // newer owner or conversation generation start before copy-back settled. + // A resolved no-replay hold can coexist with another active incident; the + // one-active-action index must never make us supersede either obligation. + const [existing] = await tx.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, task.id), + eq(issueRecoveryActions.fingerprint, `legacy-execution:${run.id}`), + )).orderBy(desc(issueRecoveryActions.createdAt)).limit(1).for("update"); + const decision = existing?.evidence.executionReconciliation as Record | undefined; + if (hasRequiredWorkspaceRecovery(existing?.evidence) && decision?.runId === run.id) return updated; + const now = new Date(); + const evidence = { + ...existing?.evidence, + runId: run.id, + originalFailureCode: updated.errorCode, + workspaceRestoreFailure: updated.resultJson!.workspaceRestoreFailure, + workspaceRestoreRecovery: updated.resultJson!.workspaceRestoreRecovery, + executionReconciliation: undefined, + continuationDelivery: "invalidated", + automaticRecovery: { policy: "preserve_without_replay_v1", runId: run.id, replay: "blocked", + actionOutcome: "unknown", recordedAt: now.toISOString() }, + }; + const values = { status: "resolved", outcome: "blocked", ownerType: "board", ownerAgentId: null, + ownerUserId: null, returnOwnerAgentId: null, evidence, resolvedAt: now, updatedAt: now, + wakePolicy: null, monitorPolicy: null, + nextAction: "The original sandbox is retained for workspace repair. Verify its exact stop receipt, recover the missing files, and record workspaceRepairEvidence without changing the current task. Repair does not replay the stopped run. Explicitly remove the retained allocation after recovery.", + }; + if (existing) await tx.update(issueRecoveryActions).set(values).where(eq(issueRecoveryActions.id, existing.id)); + else await tx.insert(issueRecoveryActions).values({ ...values, companyId: run.companyId, + sourceIssueId: task.id, kind: "active_run_watchdog", cause: LEGACY_RECOVERY_CAUSE, + fingerprint: `legacy-execution:${run.id}`, attemptCount: 1 }); + return updated; + } const review = task?.status === "in_review" ? parseIssueExecutionState(task.executionState) : null; const isCurrentReviewer = review?.status === "pending" && review.currentParticipant?.type === "agent" && review.currentParticipant.agentId === run.agentId; @@ -125,7 +188,7 @@ export async function terminalizeLegacyExecution(input: { task && !isSupersededConversationRun(task, updated) && (task.assigneeAgentId === run.agentId || isCurrentReviewer) && - !["done", "cancelled"].includes(task.status) + (!["done", "cancelled"].includes(task.status) || retainedLeaseIds.length > 0) ) { // Periodic stranded-work checks may revisit this terminal run before its // reconciled continuation is dispatched. Preserve the recorded decision @@ -135,11 +198,15 @@ export async function terminalizeLegacyExecution(input: { eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, task.id), eq(issueRecoveryActions.status, "resolved"), + ...(hasRequiredWorkspaceRecovery(updated.resultJson) ? [ + sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`, + ] : []), or( sql`${issueRecoveryActions.evidence}->'executionReconciliation'->>'runId' = ${run.id}`, and( sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`, - sql`${issueRecoveryActions.evidence}->>'workspaceRestoreFailure' = 'restore_unsafe_archive'`, + or(sql`${issueRecoveryActions.evidence}->>'workspaceRestoreFailure' = 'restore_unsafe_archive'`, + sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`), sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`, ), ), @@ -158,10 +225,13 @@ export async function terminalizeLegacyExecution(input: { ...(isCurrentReviewer ? { reviewParticipantAgentId: run.agentId } : {}), originalFailureCode: updated.errorCode, ...(hasWorkspaceRestoreFailure(updated.resultJson) ? { workspaceRestoreFailure: updated.resultJson!.workspaceRestoreFailure } : {}), + ...(hasRequiredWorkspaceRecovery(updated.resultJson) ? { workspaceRestoreRecovery: updated.resultJson!.workspaceRestoreRecovery } : {}), adapterRecovery: "unsupported_or_unknown", attempt: executionFailureRetryCount(run) + 1, }, - nextAction: hasWorkspaceRestoreFailure(updated.resultJson) + nextAction: hasRequiredWorkspaceRecovery(updated.resultJson) + ? "The original sandbox is retained for workspace repair. Verify that it has stopped, recover the missing files, record workspaceRepairEvidence, and explicitly remove the retained allocation when recovery is complete. A new agent turn cannot prove file recovery." + : hasWorkspaceRestoreFailure(updated.resultJson) ? "Verify safe workspace staging or repair, then reconcile the stopped run before continuing. Saved work and approval decisions remain in force." : "Inspect the stopped provider and recorded actions, then reconcile their outcomes before continuing. This adapter has not established a safe resume checkpoint.", maxAttempts: 3, @@ -237,3 +307,12 @@ export async function settleInterruptedNativeBootstrap( return settled; }); } + +/** Capture the required-file obligation before unrelated host writes. Keeps + * execution ownership while the adapter's host finalization is still running. */ +export async function recordLegacyWorkspaceRestoreFailure(db: Db, run: Run, evidence: Record) { + if (run.runtimeMode !== "legacy" || !hasWorkspaceRestoreFailure(evidence)) return; + await terminalizeLegacyExecution({ db, run, status: run.status, + recordRestoreFailureOnly: true, patch: { resultJson: evidence }, + }); +} diff --git a/server/src/services/legacy-workspace-restore-recovery.ts b/server/src/services/legacy-workspace-restore-recovery.ts new file mode 100644 index 00000000000..9f7822375af --- /dev/null +++ b/server/src/services/legacy-workspace-restore-recovery.ts @@ -0,0 +1,82 @@ +import { and, eq, inArray, isNotNull, ne, or, sql } from "drizzle-orm"; +import { environmentLeases, environments, heartbeatRuns, type Db } from "@paperclipai/db"; +import { hasWorkspaceRestoreFailure, WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; +import { prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js"; + +import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; + +type Run = typeof heartbeatRuns.$inferSelect; + +function needsRemoteRestore(run: Run): boolean { + return run.runtimeMode === "legacy" && ["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) + && hasWorkspaceRestoreFailure(run.resultJson); +} + +function leaseScope(run: Run) { + return and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id), + or(eq(environmentLeases.status, "active"), and( + sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`, + sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'runId' = ${run.id}`)), + isNotNull(environmentLeases.providerLeaseId), + isNotNull(environmentLeases.provider), ne(environmentLeases.provider, "local"), + or(eq(environments.driver, "sandbox"), sql`${environmentLeases.metadata}->>'driver' = 'sandbox'`, + sql`${environmentLeases.metadata}->>'sandboxProviderPlugin' = 'true'`)); +} + +/** A local lock timeout has no remote source to retain and keeps its old policy. */ +export async function hasUnrestoredRemoteWorkspace(db: Db, run: Run): Promise { + if (!needsRemoteRestore(run)) return false; + if (hasRequiredWorkspaceRecovery(run.resultJson)) return true; + const [lease] = await db.select({ id: environmentLeases.id }).from(environmentLeases) + .leftJoin(environments, eq(environments.id, environmentLeases.environmentId)).where(leaseScope(run)).limit(1); + return !!lease; +} + +/** Call in the terminal-status transaction. No cleanup worker may observe the + * failed run without its exact stop-only intent. The original allocation must + * not enter warm reuse; it belongs to the board's workspace repair action. */ +export async function preserveLegacyWorkspaceRestoreSources(db: Db, run: Run): Promise { + if (!needsRemoteRestore(run)) return []; + const rows = await db.select({ lease: environmentLeases }).from(environmentLeases) + .leftJoin(environments, eq(environments.id, environmentLeases.environmentId)) + .where(leaseScope(run)).for("update", { of: environmentLeases }); + const retained: string[] = []; + for (const { lease } of rows) { + if (lease.status !== "active") { retained.push(lease.id); continue; } + // The stop-only worker rechecks exact allocation ownership, including + // competing leases, before dispatch. Persist the intent even when cleanup + // must wait, so no terminal-run sweep can fall back to destruction. + const prepared = await prepareSandboxStopAndRetain(db, lease); + if (!prepared) throw new Error("Workspace recovery lease ownership changed before terminal commit."); + await db.update(environmentLeases).set({ leasePolicy: "retain_on_failure", + metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ workspaceRestoreRecovery: { + schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, runId: run.id, + } })}::jsonb`, + }).where(and( + eq(environmentLeases.id, prepared.id), eq(environmentLeases.companyId, run.companyId), + eq(environmentLeases.heartbeatRunId, run.id), eq(environmentLeases.status, "pending_cleanup"), + )); + retained.push(lease.id); + } + return retained; +} + +/** Metadata writers may use safe projections or snapshots from before copy-back + * settled. Only the restore recorder owns these fields; preserve their current + * database values rather than a stale or schema-only incoming projection. */ +export function preserveWorkspaceRestoreRecoveryMetadataSql( + incoming: Record | null, + mergeCurrent = false, +) { + const payload = sql`${JSON.stringify(incoming)}::jsonb`; + const result = mergeCurrent + ? sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || coalesce(nullif(${payload}, 'null'::jsonb), '{}'::jsonb)` + : payload; + return sql`case when + ${heartbeatRuns.resultJson}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA} + and ${inArray(sql`${heartbeatRuns.resultJson}->>'workspaceRestoreFailure'`, [...WORKSPACE_RESTORE_FAILURE_CODES])} + then coalesce(nullif(${result}, 'null'::jsonb), '{}'::jsonb) || jsonb_build_object( + 'workspaceRestoreFailure', ${heartbeatRuns.resultJson}->'workspaceRestoreFailure', + 'workspaceRestoreRecovery', ${heartbeatRuns.resultJson}->'workspaceRestoreRecovery') + else ${result} end`; +} diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 4a374ef5cbb..ff2de0c3362 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -4182,16 +4182,22 @@ export function recoveryService( classification, ); - await db + const classificationMetadata = withAdapterFailureRecoveryClassification( + { ...latestRun, resultJson: {} }, classification, + ).resultJson; + const [updated] = await db .update(heartbeatRuns) .set({ errorCode: classifiedRun.errorCode, - resultJson: parseObject(classifiedRun.resultJson), + // Classification owns retry metadata, not workspace repair receipts + // that may have committed after this run was selected. + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify(classificationMetadata)}::jsonb`, updatedAt: new Date(), }) - .where(eq(heartbeatRuns.id, latestRun.id)); + .where(eq(heartbeatRuns.id, latestRun.id)) + .returning({ resultJson: heartbeatRuns.resultJson, errorCode: heartbeatRuns.errorCode }); - return classifiedRun; + return updated ? { ...classifiedRun, ...updated } : classifiedRun; } function withAdapterFailureRecoveryClassification( diff --git a/server/src/services/workspace-restore-reconciliation.test.ts b/server/src/services/workspace-restore-reconciliation.test.ts new file mode 100644 index 00000000000..873a06a3e9d --- /dev/null +++ b/server/src/services/workspace-restore-reconciliation.test.ts @@ -0,0 +1,157 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, companies, createDb, environmentLeases, heartbeatRuns, + issueComments, issueRecoveryActions, issues, +} from "@paperclipai/db"; +import type { ExecutionReconciliation } from "@paperclipai/shared"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { getExecutionBlocker } from "./execution-blocker.js"; +import { markExecutionReconciliation, validateExecutionReconciliation } from "./execution-recovery-resolution.js"; +import { remoteTerminationReceipt } from "./remote-execution-termination.js"; +import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; + +const external = process.env.PAPERCLIP_TEST_DATABASE_URL; +const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("retained workspace repair admission", () => { + let database: Awaited> | undefined; + let db: ReturnType; + beforeAll(async () => { + if (external) db = createDb(external); + else { + database = await startEmbeddedPostgresTestDatabase("workspace-restore-reconciliation-"); + db = createDb(database.connectionString); + } + }, 30_000); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const sourceRunId = randomUUID(), leaseId = randomUUID(), actionId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Workspace repair", issuePrefix: `W${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", adapterType: "claude_local" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Recover saved work", status: "blocked", assigneeAgentId: agentId }); + const workspaceRestoreRecovery = { schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, leaseIds: [leaseId] }; + await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, runtimeMode: "legacy", + status: "failed", errorCode: "workspace_restore_failed", contextSnapshot: { issueId }, + finishedAt: new Date("2000-01-01T00:00:00Z"), runnerProfileJson: { adapterDispatch: { adapterType: "claude_local" } }, + resultJson: { workspaceRestoreFailure: "restore_failed", conversationContinuation: "continue_conversation_v1", workspaceRestoreRecovery }, + }); + const identity = { id: leaseId, companyId, heartbeatRunId: sourceRunId, provider: "daytona", providerLeaseId: `sandbox-${leaseId}` }; + await db.insert(environmentLeases).values({ ...identity, leasePolicy: "retain_on_failure", status: "released", + releasedAt: new Date(), cleanupStatus: "success", metadata: { + remoteExecutionTermination: remoteTerminationReceipt(identity, { providerLeaseId: identity.providerLeaseId, state: "stopped" }), + }, + }); + await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId: issueId, + kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation", fingerprint: sourceRunId, + status: "resolved", outcome: "blocked", nextAction: "Recover the missing workspace files and record repair evidence.", + evidence: { runId: sourceRunId, workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery, automaticRecovery: { replay: "blocked" } }, + }); + const decision: ExecutionReconciliation = { runId: sourceRunId, providerStopped: true, actionOutcome: "mixed", + outcomeEvidence: "Inspected the saved run and confirmed its external action outcomes.", + workspaceRepairEvidence: "Recovered the missing files from the retained sandbox and verified their contents.", + }; + return { companyId, agentId, issueId, sourceRunId, leaseId, actionId, identity, decision }; + } + + it.each([ + { scenario: "reset_command", status: "failed" }, + { scenario: "existing_boundary", status: "failed" }, + { scenario: "reset_command", status: "cancelled" }, + ])("keeps a workspace hold through $scenario after $status without offering a task retry", async ({ scenario, status }) => { + const f = await seed(), commentId = randomUUID(); + await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.insert(issueComments).values({ id: commentId, companyId: f.companyId, issueId: f.issueId, + authorType: "user", authorUserId: "board", body: "/new" }); + await db.update(issues).set({ conversationAgentId: f.agentId, conversationUserId: "board", conversationState: "active", + ...(scenario === "existing_boundary" ? { conversationBoundaryCommentId: commentId } : {}), + }).where(eq(issues.id, f.issueId)); + const blocker = await getExecutionBlocker(db, f.companyId, f.issueId, + scenario === "reset_command" ? { conversationResetCommentId: commentId } : undefined); + expect(blocker).toMatchObject({ recoveryActionId: f.actionId, runId: f.sourceRunId, + canRetry: false, canContinue: false, workspaceRepairRequired: true, nextAction: "Recover the missing workspace files and record repair evidence." }); + }); + + it("accepts verified repair after exact remote stop without interpreting the sandbox PID on this server", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId)); + await expect(validateExecutionReconciliation({ db, ...f })).resolves.toMatchObject({ id: f.sourceRunId }); + }); + + it.each(["pending_stop", "wrong_receipt", "missing_source", "unrecorded_source", "empty_sources"])("rejects repair with %s even when releasedAt is populated", async scenario => { + const f = await seed(); + if (scenario === "pending_stop") { + await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed" }).where(eq(environmentLeases.id, f.leaseId)); + } else if (scenario === "wrong_receipt") { + await db.update(environmentLeases).set({ metadata: { remoteExecutionTermination: remoteTerminationReceipt({ ...f.identity, id: randomUUID() }, + { providerLeaseId: f.identity.providerLeaseId, state: "stopped" }) } }).where(eq(environmentLeases.id, f.leaseId)); + } else if (scenario === "missing_source") { + await db.delete(environmentLeases).where(eq(environmentLeases.id, f.leaseId)); + } else { + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.update(heartbeatRuns).set({ resultJson: { ...run.resultJson, + workspaceRestoreRecovery: { schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, leaseIds: scenario === "empty_sources" ? [] : [randomUUID()] }, + } }).where(eq(heartbeatRuns.id, f.sourceRunId)); + } + await expect(validateExecutionReconciliation({ db, ...f })).rejects.toThrow("has not confirmed that it stopped"); + expect(await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.id, f.actionId), + eq(issueRecoveryActions.status, "resolved")))).toHaveLength(1); + }); + + it("requires workspace repair evidence even after stop succeeds", async () => { + const f = await seed(); + await expect(validateExecutionReconciliation({ db, ...f, decision: { ...f.decision, workspaceRepairEvidence: undefined } })) + .rejects.toThrow("workspaceRepairEvidence"); + }); + + it("clears the replay hold only after validated repair is recorded, preserving the source and recovery evidence", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId)); + const repaired = await validateExecutionReconciliation({ db, ...f }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ recoveryActionId: f.actionId }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)); + await markExecutionReconciliation(db, action, f.decision, "board"); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull(); + const [recorded] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)); + expect(recorded.evidence).toMatchObject({ workspaceRestoreRecovery: repaired.resultJson!.workspaceRestoreRecovery, + executionReconciliation: { runId: f.sourceRunId, workspaceRepairEvidence: f.decision.workspaceRepairEvidence }, + continuationDelivery: "pending" }); + const [source] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.leaseId)); + expect(source).toMatchObject({ status: "released", leasePolicy: "retain_on_failure", + metadata: { remoteExecutionTermination: { state: "stopped" } } }); + }); + + it("preserves the host process check for local restore failures", async () => { + const f = await seed(); + await db.delete(environmentLeases).where(eq(environmentLeases.id, f.leaseId)); + await db.update(heartbeatRuns).set({ processPid: process.pid, resultJson: { workspaceRestoreFailure: "restore_lock_timeout" } }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + await expect(validateExecutionReconciliation({ db, ...f })).rejects.toThrow("previous provider is still running"); + }); + + it("does not let a remote stop receipt waive a separate local execution's process check", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ processPid: process.pid }).where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.insert(environmentLeases).values({ companyId: f.companyId, heartbeatRunId: f.sourceRunId, + provider: "local", leasePolicy: "ephemeral", status: "released", releasedAt: new Date(), cleanupStatus: "success" }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ + recoveryActionId: null, cause: "execution_owner_active", nextAction: expect.stringContaining("provider process is still running"), + }); + await expect(validateExecutionReconciliation({ db, ...f })).rejects.toThrow("previous provider is still running"); + }); + + it.each(["pending_stop", "wrong_receipt", "missing_source"])("keeps remote ownership blocked with %s even without a host PID", async scenario => { + const f = await seed(); + if (scenario === "pending_stop") { + await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed" }).where(eq(environmentLeases.id, f.leaseId)); + } else if (scenario === "wrong_receipt") { + await db.update(environmentLeases).set({ metadata: { remoteExecutionTermination: remoteTerminationReceipt({ ...f.identity, id: randomUUID() }, + { providerLeaseId: f.identity.providerLeaseId, state: "stopped" }) } }).where(eq(environmentLeases.id, f.leaseId)); + } else await db.delete(environmentLeases).where(eq(environmentLeases.id, f.leaseId)); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ + recoveryActionId: null, cause: "execution_owner_active", nextAction: expect.stringContaining("has not confirmed that it stopped"), + }); + }); +}); diff --git a/server/src/services/workspace-restore-recovery-state.ts b/server/src/services/workspace-restore-recovery-state.ts new file mode 100644 index 00000000000..9c532a9ca77 --- /dev/null +++ b/server/src/services/workspace-restore-recovery-state.ts @@ -0,0 +1,23 @@ +import { hasWorkspaceRestoreFailure } from "@paperclipai/shared"; + +export const LEGACY_WORKSPACE_RECOVERY_KEY = "workspaceRestoreRecovery"; +export const LEGACY_WORKSPACE_RECOVERY_SCHEMA = "paperclip.workspace-restore-recovery.v1"; + +export function hasRequiredWorkspaceRecovery(result: Record | null | undefined): boolean { + const value = result?.[LEGACY_WORKSPACE_RECOVERY_KEY]; + return hasWorkspaceRestoreFailure(result) && !!value && typeof value === "object" && !Array.isArray(value) + && (value as Record).schema === LEGACY_WORKSPACE_RECOVERY_SCHEMA; +} + + + +/** Late adapter/projection writes cannot certify recovery or remove its pin. */ +export function preserveWorkspaceRestoreRecoveryMetadata( + current: Record | null | undefined, + incoming: Record | null | undefined, +): Record | null | undefined { + return hasRequiredWorkspaceRecovery(current) ? { ...incoming, + workspaceRestoreFailure: current!.workspaceRestoreFailure, + workspaceRestoreRecovery: current!.workspaceRestoreRecovery, + } : incoming; +} diff --git a/ui/src/components/ExecutionBlockerNotice.test.tsx b/ui/src/components/ExecutionBlockerNotice.test.tsx index d5d2b0a95dc..3960f71070b 100644 --- a/ui/src/components/ExecutionBlockerNotice.test.tsx +++ b/ui/src/components/ExecutionBlockerNotice.test.tsx @@ -99,6 +99,20 @@ describe("stopped task recovery notice", () => { expect(link.getAttribute("href")).toBe("/agents/agent/runs/failed-run"); expect(agentsApi.retryFailedRun).not.toHaveBeenCalled(); }); + it("keeps workspace repair guidance and inspection without offering the legacy Retry fallback", async () => { + await act(async () => root.render( + + )); + expect(container.textContent).toContain("Recover the missing files"); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector("a")?.textContent).toBe("Inspect run"); + expect(agentsApi.retryFailedRun).not.toHaveBeenCalled(); + }); + it("retries the exact failed run and refreshes the task", async () => { vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never); await act(async () => container.querySelector("button")!.click()); diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 254a33e3f04..c157a359b5d 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -47,7 +47,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried Inspect run )} - {(!requiresInspection || blocker.canRetry) && blocker.agentId && blocker.runId && + {!blocker.workspaceRepairRequired && (!requiresInspection || blocker.canRetry) && blocker.agentId && blocker.runId && ((blocker.cause === "legacy_execution_requires_reconciliation" && failedRun) || blocker.canContinue || blocker.canRetry) && (