From 9bd820e6872eab293bb09e9fc27cdf5cbbd17eb5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:01:29 -0500 Subject: [PATCH 01/85] feat(storybook): mirror the complete feature-map index (#16052) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The feature map describes its user-facing capabilities and verification recipes. > - Storybook provides isolated UI examples for those capabilities. > - The previous organization showed only features with primary story suites. > - This pull request derives the complete Storybook index from the upstream map. > - Readers can find the same features in both places and see related examples or explicit gaps. ## Linked Issues or Issue Description Refs #16046 and #16049. **What existing behavior does this improve?** Storybook navigation and the downstream feature catalog. **Current behavior** The sidebar has 28 primary feature homes. The feature map lists 35. The main guide groups suites alphabetically by area slug. Related examples can live under a different feature. **Proposed behavior** Use the feature map's eight sections and 35 features in the same order. Give each feature an Overview page with all its capabilities, related example links, and missing-example messages. Keep shared foundations outside the product index. Preserve all existing stories and URLs. **Reason and benefit** Readers can move between the feature map and Storybook without learning a second index. A feature without cataloged examples remains visible. **Breaking changes** None to existing story IDs or product behavior. Storybook start and build now reject stale generated feature pages and ordering. Run the catalog generator after upstream changes, inspect the output, and review affected source fingerprints. ## What Changed - Generate 35 feature Overview stories and capability data from the upstream map. Generate the inline sidebar order required by Storybook. - Replace the main guide with the full ordered feature index and search. Link all related suites from feature pages, even across primary homes. - Preserve all 223 authored files, 2,210 story IDs, and the existing docs ID. Keep status, implementation, and fixture classifications visible. - Put the generated Markdown catalog in feature-map order. Keep generated guides outside example counts. - Update maintenance and review skills, documentation, and generator tests. Keep the feature map independent of Storybook. ## Verification - Passed: 16 catalog and generator tests; map checks; token gates; both skill validators. - Passed: `pnpm -r typecheck`, `pnpm build`, and the Storybook build. - Built-index comparison preserved all 2,211 previous entries and added only 35 feature overviews. Feature order matches the map. - Browser: inspected the complete index and sidebar, searched for Goals, opened its overview, and followed a related suite under Projects. The original suite renders. - Passed: all 8,170 UI tests in 728 files. Browser checks also covered empty search, capability-ID search, missing-example pages, the original editable-text bookmark, the CLI sidebar label, and searching/opening shared foundations. - The broad local `pnpm test:run` was stopped after a failure in the unchanged GitHub chat test `authorizes description edits as the editor, never inheriting the original author's access`. That exact test passed alone without code or expectation changes. A complete local repository suite pass is not established. - Original revision CI passed after one runner retry. The merged revision `5ca01c70716f23974680a8019ddc613035d11bc4` passed fresh CI. Local UI typecheck, catalog checks, 16 generator tests, map checks and token gates pass. - Greptile: 5/5 on final commit `5ca01c70716f23974680a8019ddc613035d11bc4`, with all findings resolved. - Master merge: inspected source drift in 61 catalog entries and regenerated INDEX, preserving upstream notes. Marked the sidebar suite that mounts IssueDetail unreviewed for the new activity query; recorded limits for composer and channel-default changes. No stories removed. - Final Storybook build passed. Browser filename searches for `new-task-composer` and `forms-editors` return the expected feature and shared example. - No visual-baseline or full production acceptance claim. The deeper authored scenario groups remain intact. ## Risks - The parser depends on the feature-map Markdown contract. It rejects unknown index rows, omitted feature recipes, and unexpected generated files. - A suite can reference several capabilities. A link proves catalog association, not complete behavior coverage or production parity. - Storybook treats `/` as a folder separator. Sidebar labels use a comma for that character; the guide and page headings retain the upstream name. - No database, API, authorization, or production component changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser inspection. The exact runtime model ID 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 #` / `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 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .agents/skills/maintain-storybook/SKILL.md | 15 +- .agents/skills/review-storybook/SKILL.md | 7 + doc/DEVELOPING.md | 9 + maps/storybook-map/INDEX.md | 1033 ++++++++------- maps/storybook-map/README.md | 27 +- maps/storybook-map/REVIEW.md | 53 + maps/storybook-map/catalog.json | 377 +++--- scripts/storybook-catalog.mjs | 50 +- scripts/storybook-catalog.test.mjs | 45 +- scripts/storybook-feature-index.mjs | 126 ++ scripts/storybook-feature-index.test.mjs | 73 ++ ui/package.json | 4 +- ui/storybook/.storybook/main.ts | 2 +- ui/storybook/.storybook/preview.tsx | 70 +- ui/storybook/generated/feature-index.json | 1144 +++++++++++++++++ .../generated/features/access.stories.tsx | 13 + .../generated/features/activity.stories.tsx | 13 + .../generated/features/agent-chat.stories.tsx | 13 + .../generated/features/agents.stories.tsx | 13 + .../features/app-permissions.stories.tsx | 13 + .../features/budgets-costs.stories.tsx | 13 + .../generated/features/cases.stories.tsx | 13 + .../features/chat-channels.stories.tsx | 13 + .../features/cli-operations.stories.tsx | 13 + .../generated/features/companies.stories.tsx | 13 + .../features/connection-setup.stories.tsx | 13 + .../features/developer-labs.stories.tsx | 13 + .../features/documents-artifacts.stories.tsx | 13 + .../execution-environments.stories.tsx | 13 + .../features/gateways-profiles.stories.tsx | 13 + .../generated/features/goals.stories.tsx | 13 + .../features/inbox-search.stories.tsx | 13 + .../features/instance-operations.stories.tsx | 13 + .../navigation-preferences.stories.tsx | 13 + .../generated/features/onboarding.stories.tsx | 13 + .../generated/features/pipelines.stories.tsx | 13 + .../generated/features/plugins.stories.tsx | 13 + .../generated/features/projects.stories.tsx | 13 + .../questions-and-approvals.stories.tsx | 13 + .../generated/features/recovery.stories.tsx | 13 + .../generated/features/routines.stories.tsx | 13 + .../features/runs-adapters.stories.tsx | 13 + .../generated/features/secrets.stories.tsx | 13 + .../generated/features/skills.stories.tsx | 13 + .../features/status-cards.stories.tsx | 13 + .../generated/features/steering.stories.tsx | 13 + .../features/task-coordination.stories.tsx | 13 + .../generated/features/tasks.stories.tsx | 13 + .../generated/features/teams.stories.tsx | 13 + .../generated/features/workspaces.stories.tsx | 13 + .../stories/overview/overview.stories.tsx | 87 +- .../support/feature-map/FeatureOverview.tsx | 148 +++ 52 files changed, 2943 insertions(+), 782 deletions(-) create mode 100644 scripts/storybook-feature-index.mjs create mode 100644 scripts/storybook-feature-index.test.mjs create mode 100644 ui/storybook/generated/feature-index.json create mode 100644 ui/storybook/generated/features/access.stories.tsx create mode 100644 ui/storybook/generated/features/activity.stories.tsx create mode 100644 ui/storybook/generated/features/agent-chat.stories.tsx create mode 100644 ui/storybook/generated/features/agents.stories.tsx create mode 100644 ui/storybook/generated/features/app-permissions.stories.tsx create mode 100644 ui/storybook/generated/features/budgets-costs.stories.tsx create mode 100644 ui/storybook/generated/features/cases.stories.tsx create mode 100644 ui/storybook/generated/features/chat-channels.stories.tsx create mode 100644 ui/storybook/generated/features/cli-operations.stories.tsx create mode 100644 ui/storybook/generated/features/companies.stories.tsx create mode 100644 ui/storybook/generated/features/connection-setup.stories.tsx create mode 100644 ui/storybook/generated/features/developer-labs.stories.tsx create mode 100644 ui/storybook/generated/features/documents-artifacts.stories.tsx create mode 100644 ui/storybook/generated/features/execution-environments.stories.tsx create mode 100644 ui/storybook/generated/features/gateways-profiles.stories.tsx create mode 100644 ui/storybook/generated/features/goals.stories.tsx create mode 100644 ui/storybook/generated/features/inbox-search.stories.tsx create mode 100644 ui/storybook/generated/features/instance-operations.stories.tsx create mode 100644 ui/storybook/generated/features/navigation-preferences.stories.tsx create mode 100644 ui/storybook/generated/features/onboarding.stories.tsx create mode 100644 ui/storybook/generated/features/pipelines.stories.tsx create mode 100644 ui/storybook/generated/features/plugins.stories.tsx create mode 100644 ui/storybook/generated/features/projects.stories.tsx create mode 100644 ui/storybook/generated/features/questions-and-approvals.stories.tsx create mode 100644 ui/storybook/generated/features/recovery.stories.tsx create mode 100644 ui/storybook/generated/features/routines.stories.tsx create mode 100644 ui/storybook/generated/features/runs-adapters.stories.tsx create mode 100644 ui/storybook/generated/features/secrets.stories.tsx create mode 100644 ui/storybook/generated/features/skills.stories.tsx create mode 100644 ui/storybook/generated/features/status-cards.stories.tsx create mode 100644 ui/storybook/generated/features/steering.stories.tsx create mode 100644 ui/storybook/generated/features/task-coordination.stories.tsx create mode 100644 ui/storybook/generated/features/tasks.stories.tsx create mode 100644 ui/storybook/generated/features/teams.stories.tsx create mode 100644 ui/storybook/generated/features/workspaces.stories.tsx create mode 100644 ui/storybook/support/feature-map/FeatureOverview.tsx diff --git a/.agents/skills/maintain-storybook/SKILL.md b/.agents/skills/maintain-storybook/SKILL.md index 5735eb5e56b..822fdcd8981 100644 --- a/.agents/skills/maintain-storybook/SKILL.md +++ b/.agents/skills/maintain-storybook/SKILL.md @@ -58,6 +58,17 @@ groupings beneath that prefix. Put shared fixture modules in `ui/storybook/suppo Keep the catalog's `stableId` equal to the explicit CSF meta ID even when the title changes. Unmapped component examples belong under Foundations; the catalog guide belongs under Overview. The checker verifies these conventions. + +Keep the main guide and every feature Overview congruent with the upstream index: +same section/feature names and order, all capabilities in recipe order, related +suite links across primary homes, and explicit missing examples. Regenerate +`ui/storybook/generated/` and the preview config's inline `storySort.order` through +the catalog command; never hand-edit generated pages or ordering +or add generated guides to the authored catalog. Generated guides are not example +coverage. Preserve their `feature-map---overview` IDs. When an upstream area +is removed or renamed, inspect unexpected generated files and resolve them +explicitly; the generator will not silently delete them. + Keep review links and snapshot names working. Prefer classification/index fixes before bulk directory moves. Record every directly rendered production subject in the catalog's review sources, including relative imports and primitives when they @@ -66,7 +77,9 @@ Update review fingerprints only after inspecting the changed source; do not refr to silence drift checks. Update the catalog and run `node scripts/storybook-catalog.mjs --write` to refresh -its generated index, then run the checker without flags. The script validates +its generated indexes and feature pages, then run the checker without flags and +`node --test scripts/storybook-catalog.test.mjs scripts/storybook-feature-index.test.mjs`. +The script validates inventory/references/review fingerprints, not semantic parity or runtime health. Use `review-storybook` for affected rendering and interactions. Reuse the existing Storybook/Playwright tools; add no separate launcher, runtime, or baseline system. diff --git a/.agents/skills/review-storybook/SKILL.md b/.agents/skills/review-storybook/SKILL.md index cdc1bc07bc6..216701e4924 100644 --- a/.agents/skills/review-storybook/SKILL.md +++ b/.agents/skills/review-storybook/SKILL.md @@ -17,6 +17,13 @@ variants where affected. Record chosen files/exports and omitted scope. For a full source review, account for every file; do not describe sampling as full render or interaction coverage. +When reviewing navigation/index changes, compare the guide and sidebar with the +feature-map README's full ordered index. Open a feature without examples and a +feature whose related suite lives elsewhere; check capability order, gap messages, +classifications, recipe links, and actual suite navigation. Compare built entry IDs +before/after so existing links survive. Generated Overview pages do not establish +new example coverage. + Use [the existing Storybook commands](../../../doc/DEVELOPING.md#storybook): `pnpm build-storybook`, or an owned Storybook dev server on an available local port. Use the built `ui/storybook-static/index.json` for actual story IDs and diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 36b4cdc2b7e..351d9f93246 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -129,6 +129,15 @@ the feature map's sections and feature names. Shared fixtures live in `ui/storybook/support/`. Keep each suite's explicit meta `id` and catalog `stableId` when changing titles or locations so existing review links remain valid. +Storybook's Overview mirrors every feature in the feature-map README, including +features without examples. Each feature's generated Overview lists its capabilities +in recipe order, related suites across primary homes, and missing examples. +`ui/storybook/generated/` holds the derived pages; the preview config's inline +`storySort.order` is generated too. Regenerate these outputs +with `node scripts/storybook-catalog.mjs --write`, not manual edits. Storybook +start/build rejects stale generated output. These guide pages are separate from +the authored story inventory and do not count as visual or behavioral coverage. + Start from the downstream [Storybook feature index](../maps/storybook-map/INDEX.md) to find existing stories by capability. Its [contract](../maps/storybook-map/README.md) separates production subjects, prototype implementations, simulated data, and diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index 0d334dc32c7..43a39b5a4c9 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -8,111 +8,40 @@ Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file. -## access +## Getting started and access + +### Onboarding and first work + +Instance setup, company wizard, first agent and task. + +[Feature recipe](../feature-map/onboarding.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Getting started and access/Onboarding and first work/Onboarding/Agent arc](../../ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx) | `onboarding/adapter`, `onboarding/first-task` | unreviewed / production / fixtures | 27 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Getting started and access/Onboarding and first work/Onboarding/Character](../../ui/storybook/stories/onboarding/onboarding-character.stories.tsx) | `onboarding/company` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Getting started and access/Onboarding and first work/Onboarding/Connect a model](../../ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx) | `onboarding/adapter`, `onboarding/first-task` | historical / prototype / fixtures | 5 / 0 | Older tile/mode preview; onboarding-agent-arc mounts the production wizard. | + +### Login, invitations, and access + +Sessions, bootstrap, membership, roles, and CLI authorization. [Feature recipe](../feature-map/access.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. | -| [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 22 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 26 / 10 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 11 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 12 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 19 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 21 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 22 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 26 / 10 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 11 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 12 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 19 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 21 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -## activity +### Companies and portability -[Feature recipe](../feature-map/activity.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) | `activity/activity`, `gateways-profiles/activity` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) | `activity/activity`, `gateways-profiles/activity` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc](../../ui/storybook/stories/activity/data-viz-misc.stories.tsx) | `activity/overview` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Oversight and operation/Dashboards and audit trails/Pages/Work Timeline](../../ui/storybook/stories/activity/work-timeline.stories.tsx) | `activity/timeline` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## agent-chat - -[Feature recipe](../feature-map/agent-chat.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | current / production / fixtures | 9 / 1 | Wrapper mounts production Layout and AgentChat; conversation selection and sends use in-memory routes. Directory name is not prototype evidence. | -| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | mixed / mixed / fixtures | 17 / 0 | Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. | -| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | current / production / fixtures | 10 / 1 | Support module re-exports production AgentConversationSidebar; search/selection use fixture callbacks. | -| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | historical / mixed / fixtures | 9 / 3 | Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful. | - -## agents - -[Feature recipe](../feature-map/agents.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) | `agents/instructions` | unreviewed / production / fixtures | 9 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 19 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) | `agents/lifecycle`, `agents/instructions` | mixed / mixed / fixtures | 15 / 0 | Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents](../../ui/storybook/stories/agents/built-in-agents.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 20 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) | `agents/hire` | historical / prototype / fixtures | 37 / 1 | Retained local wizard predates production ui/src/components/new-agent flow; shared fields do not make the wizard current. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 15 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## app-permissions - -[Feature recipe](../feature-map/app-permissions.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) | `app-permissions/test-action`, `app-permissions/action-policy` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) | `app-permissions/test-action`, `app-permissions/action-policy` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts](../../ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx) | `app-permissions/account-maintenance` | current / production / fixture-server | 1 / 0 | Production Browse requires owned tests/aggregator-accounts/test-drive.ts on localhost:4310; providers are simulated. Not an offline story. | -| [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) | `app-permissions/agent-access`, `app-permissions/action-policy` | unreviewed / production / fixtures | 12 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## budgets-costs - -[Feature recipe](../feature-map/budgets-costs.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## chat-channels - -[Feature recipe](../feature-map/chat-channels.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here. | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. | -| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. | -| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | - -## companies +Company switching/settings, archival, package import/export. [Feature recipe](../feature-map/companies.md) @@ -124,41 +53,112 @@ Status describes product alignment; implementation describes the rendered subjec | [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | unreviewed / production / fixtures | 9 / 0 | Production settings view with simulated callbacks. Excluded exported fixture helpers and added the onConnectionChange spy required during rendering; full save/API state parity remains unreviewed. | | [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | current / production / fixtures | 4 / 0 | Shared production DecisionHistoryTable receives fastResponse=true, matching FastResponseHistory presentation; API loading/error and feature-gate wrapper are outside this story. | -## connection-setup +## Tasks and conversations -[Feature recipe](../feature-map/connection-setup.md) +### Task creation and lifecycle + +Creation, assignment, lists, properties, comments, and completion. + +[Feature recipe](../feature-map/tasks.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) | `connection-setup/channel-setup`, `gateways-profiles/client-connect` | unreviewed / production / fixtures | 11 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry](../../ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx) | `connection-setup/catalog` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) | `connection-setup/catalog` | proposed / prototype / fixtures | 12 / 1 | Local catalog composition uses shipped cards and filters; retains design exploration purpose, not acceptance of the Apps route. | -| [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) | `connection-setup/catalog` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) | `connection-setup/credentials` | current / production / fixtures | 9 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 27 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 20 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 9 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) | `connection-setup/credentials`, `routines/triggers` | unreviewed / production / fixtures | 27 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) | `connection-setup/task-request` | unreviewed / production / fixtures | 78 / 23 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) | `connection-setup/handoff` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 10 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 14 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) | `tasks/create`, `runs-adapters/availability` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) | `tasks/create`, `runs-adapters/availability` | mixed / mixed / fixtures | 57 / 30 | Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) | `tasks/organize`, `questions-and-approvals/governance`, `budgets-costs/budgets`, `activity/activity` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) | `tasks/create`, `agents/hire`, `projects/lifecycle`, `goals/hierarchy` | unreviewed / production / fixtures | 17 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) | `tasks/properties` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) | `tasks/properties`, `tasks/organize` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) | `tasks/discussion` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -## developer-labs +### Delegation, dependencies, and signoff -[Feature recipe](../feature-map/developer-labs.md) +Child work, prerequisites, reviewers, and task-tree controls. + +[Feature recipe](../feature-map/task-coordination.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages](../../ui/storybook/stories/developer-labs/ux-labs.stories.tsx) | `developer-labs/interaction-labs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions](../../ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx) | `task-coordination/signoff` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces](../../ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) | `task-coordination/delegation`, `projects/task-context` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) | `task-coordination/dependencies`, `task-coordination/delegation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces](../../ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -## documents-artifacts +### Inbox, decisions, and search + +Personal triage, decision queues, unread/blocked views, and search. + +[Feature recipe](../feature-map/inbox-search.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) | `inbox-search/triage`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) | `inbox-search/search` | unreviewed / prototype / fixtures | 1 / 0 | Local search-page composition mounts production result components; does not establish parity with the production Search route. | +| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Agent conversations and project handoff + +Persistent conversations, discovery, handoff, and gated board chat. + +[Feature recipe](../feature-map/agent-chat.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | unreviewed / production / fixtures | 9 / 1 | Wrapper mounts production Layout and AgentChat; conversation selection and sends use in-memory routes. Directory name is not prototype evidence. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | mixed / mixed / fixtures | 17 / 0 | Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | current / production / fixtures | 10 / 1 | Support module re-exports production AgentConversationSidebar; search/selection use fixture callbacks. | +| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | historical / mixed / fixtures | 9 / 3 | Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | + +### Questions and approvals + +Question/plan responses, formal approvals, and app-tool review. + +[Feature recipe](../feature-map/questions-and-approvals.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `agent-chat/history` | unreviewed / production / fixtures | 9 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Composer/Question flow](../../ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx) | `questions-and-approvals/questions` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) | `questions-and-approvals/tool-review`, `connection-setup/task-request` | unreviewed / production / fixtures | 25 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) | `questions-and-approvals/plan-review`, `task-coordination/delegation` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 53 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 12 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | current / production / fixtures | 15 / 5 | Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `tasks/discussion` | unreviewed / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | + +### Steering and queued messages + +Follow-ups, queue edits, interruption, and pause/resume. + +[Feature recipe](../feature-map/steering.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) | `steering/queue`, `steering/edit-order-discard`, `steering/steer`, `steering/interrupt` | current / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff](../../ui/storybook/stories/steering/interrupt-handoff.stories.tsx) | `steering/interrupt` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Steering and queued messages/Composer/Paused task takeover](../../ui/storybook/stories/steering/paused-composer.stories.tsx) | `steering/stop-and-pause` | historical / prototype / fixtures | 8 / 0 | Retained local paused takeover; task-execution-controls exercises the shipped pause/stop controls. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Steering and queued messages/Composer/Execution controls](../../ui/storybook/stories/steering/task-execution-controls.stories.tsx) | `steering/stop-and-pause` | current / production / fixtures | 22 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | + +### Documents, attachments, and work products + +Versioned documents, annotations, files, outputs, and artifact library. [Feature recipe](../feature-map/documents-artifacts.md) @@ -186,127 +186,39 @@ Status describes product alignment; implementation describes the rendered subjec | [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) | `documents-artifacts/library`, `documents-artifacts/work-products` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs](../../ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -## foundations-and-guides +## Agents and reusable capabilities + +### Hiring, configuration, and organization + +Agent identity, instructions, reporting lines, lifecycle, and built-ins. + +[Feature recipe](../feature-map/agents.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Foundations/Components/Forms & Editors](../../ui/storybook/stories/foundations/forms-editors.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Primitive Matrix](../../ui/storybook/stories/foundations/foundations.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Primitive Coverage](../../ui/storybook/stories/foundations/primitives-coverage.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Status Language](../../ui/storybook/stories/foundations/status-language.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Overview/Storybook Guide](../../ui/storybook/stories/overview/overview.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / guide / static | 1 / 0 | Generated inventory counts replace stale hand-maintained Planned/Covered claims; no execution coverage is implied. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) | `agents/instructions` | unreviewed / production / fixtures | 9 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 19 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) | `agents/lifecycle`, `agents/instructions` | mixed / mixed / fixtures | 15 / 0 | Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents](../../ui/storybook/stories/agents/built-in-agents.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 20 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) | `agents/hire` | historical / prototype / fixtures | 37 / 1 | Retained local wizard predates production ui/src/components/new-agent flow; shared fields do not make the wizard current. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 15 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -## gateways-profiles +### Runs, harnesses, and model accounts -[Feature recipe](../feature-map/gateways-profiles.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles](../../ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx) | `gateways-profiles/profiles` | unreviewed / production / fixtures | 18 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx) | `gateways-profiles/gateway` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections](../../ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 9 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 25 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 7 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 14 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## inbox-search - -[Feature recipe](../feature-map/inbox-search.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) | `inbox-search/triage`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) | `inbox-search/search` | unreviewed / prototype / fixtures | 1 / 0 | Local search-page composition mounts production result components; does not establish parity with the production Search route. | -| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## instance-operations - -[Feature recipe](../feature-map/instance-operations.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Oversight and operation/Instance operations/Product/Dev Ops Surfaces](../../ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx) | `instance-operations/server-service` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## navigation-preferences - -[Feature recipe](../feature-map/navigation-preferences.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard](../../ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx) | `navigation-preferences/announcements` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx) | `navigation-preferences/navigation` | proposed / prototype / fixtures | 4 / 2 | Local composition retained for its original design purpose; full production parity remains unreviewed. | -| [Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout](../../ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx) | `navigation-preferences/navigation` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## onboarding - -[Feature recipe](../feature-map/onboarding.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Getting started and access/Onboarding and first work/Onboarding/Agent arc](../../ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx) | `onboarding/adapter`, `onboarding/first-task` | unreviewed / production / fixtures | 27 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Onboarding and first work/Onboarding/Character](../../ui/storybook/stories/onboarding/onboarding-character.stories.tsx) | `onboarding/company` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Onboarding and first work/Onboarding/Connect a model](../../ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx) | `onboarding/adapter`, `onboarding/first-task` | historical / prototype / fixtures | 5 / 0 | Older tile/mode preview; onboarding-agent-arc mounts the production wizard. | - -## projects - -[Feature recipe](../feature-map/projects.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 18 / 5 | RepositoryEditor is shipped, but NewProjectPrototype composes its own creation flow. Preserve proposal until route parity is checked. | -| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | -| [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 12 / 0 | Production properties plus local configuration page composition; repository interaction is simulated. | -| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | - -## questions-and-approvals - -[Feature recipe](../feature-map/questions-and-approvals.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `agent-chat/history` | unreviewed / production / fixtures | 9 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Composer/Question flow](../../ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx) | `questions-and-approvals/questions` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) | `questions-and-approvals/tool-review`, `connection-setup/task-request` | unreviewed / production / fixtures | 25 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) | `questions-and-approvals/plan-review`, `task-coordination/delegation` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 53 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 12 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | current / production / fixtures | 15 / 5 | Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `tasks/discussion` | unreviewed / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## recovery - -[Feature recipe](../feature-map/recovery.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Projects and execution/Recovery/App/Connection recovery](../../ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx) | `recovery/reconnect` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Task chat/Recovery notice](../../ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx) | `recovery/diagnosis` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Tasks/Execution recovery](../../ui/storybook/stories/recovery/execution-recovery.stories.tsx) | `recovery/diagnosis` | unreviewed / production / fixtures | 18 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) | `recovery/diagnosis`, `task-coordination/dependencies` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | current / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Workspace branch recovery/01 Repair card](../../ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 8 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Workspace branch recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## routines - -[Feature recipe](../feature-map/routines.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) | `routines/definition`, `secrets/runtime-delivery` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 21 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 16 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## runs-adapters +Adapter/model setup, account validation, transcripts, and run history. [Feature recipe](../feature-map/runs-adapters.md) @@ -328,19 +240,9 @@ Status describes product alignment; implementation describes the rendered subjec | [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components](../../ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx) | `runs-adapters/validation` | unreviewed / production / fixtures | 5 / 5 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. | | [Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals](../../ui/storybook/stories/runs-adapters/session-goals.stories.tsx) | `runs-adapters/session` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -## secrets +### Skills and Skill Studio -[Feature recipe](../feature-map/secrets.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) | `secrets/runtime-delivery`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) | `secrets/runtime-delivery`, `recovery/diagnosis` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor](../../ui/storybook/stories/secrets/environment-variables-editor.stories.tsx) | `secrets/runtime-delivery` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Secrets and proposals/Product/Secrets](../../ui/storybook/stories/secrets/secrets.stories.tsx) | `secrets/company-secrets`, `secrets/vault-import` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Secrets and proposals/Product/User secrets](../../ui/storybook/stories/secrets/user-secrets.stories.tsx) | `secrets/user-secrets` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## skills +Discovery, sources, authoring, revisions, tests, and agent policies. [Feature recipe](../feature-map/skills.md) @@ -359,58 +261,9 @@ Status describes product alignment; implementation describes the rendered subjec | [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) | `skills/discovery` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid](../../ui/storybook/stories/skills/skills-store-discovery.stories.tsx) | `skills/discovery` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -## steering +### Team packages and installation -[Feature recipe](../feature-map/steering.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) | `steering/queue`, `steering/edit-order-discard`, `steering/steer`, `steering/interrupt` | current / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff](../../ui/storybook/stories/steering/interrupt-handoff.stories.tsx) | `steering/interrupt` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Steering and queued messages/Composer/Paused task takeover](../../ui/storybook/stories/steering/paused-composer.stories.tsx) | `steering/stop-and-pause` | historical / prototype / fixtures | 8 / 0 | Retained local paused takeover; task-execution-controls exercises the shipped pause/stop controls. | -| [Tasks and conversations/Steering and queued messages/Composer/Execution controls](../../ui/storybook/stories/steering/task-execution-controls.stories.tsx) | `steering/stop-and-pause` | current / production / fixtures | 22 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## task-coordination - -[Feature recipe](../feature-map/task-coordination.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions](../../ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx) | `task-coordination/signoff` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces](../../ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) | `task-coordination/delegation`, `projects/task-context` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) | `task-coordination/dependencies`, `task-coordination/delegation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces](../../ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## tasks - -[Feature recipe](../feature-map/tasks.md) - -| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | -| --- | --- | --- | --- | --- | -| [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 14 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) | `tasks/create`, `runs-adapters/availability` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) | `tasks/create`, `runs-adapters/availability` | mixed / mixed / fixtures | 57 / 30 | Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. | -| [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) | `tasks/organize`, `questions-and-approvals/governance`, `budgets-costs/budgets`, `activity/activity` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) | `tasks/create`, `agents/hire`, `projects/lifecycle`, `goals/hierarchy` | unreviewed / production / fixtures | 17 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) | `tasks/properties` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) | `tasks/properties`, `tasks/organize` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) | `tasks/discussion` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | - -## teams +Catalog preview/install via CLI/API; catalog UI availability called out. [Feature recipe](../feature-map/teams.md) @@ -418,131 +271,341 @@ Status describes product alignment; implementation describes the rendered subjec | --- | --- | --- | --- | --- | | [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) | `teams/preview`, `teams/discovery` | unreviewed / production / fixtures | 12 / 0 | Renders shipped TeamCatalog source, but feature map documents current CLI/API access and no top-level catalog route. | -## workspaces +## Projects and execution + +### Projects and repositories + +Project lifecycle, task context, repository configuration, and defaults. + +[Feature recipe](../feature-map/projects.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 18 / 5 | RepositoryEditor is shipped, but NewProjectPrototype composes its own creation flow. Preserve proposal until route parity is checked. | +| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | +| [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 12 / 0 | Production properties plus local configuration page composition; repository interaction is simulated. | +| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | + +### Goals and work alignment + +Goal hierarchy, ownership, status, and project/task context. + +[Feature recipe](../feature-map/goals.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +### Workspaces, services, and files + +Provisioning, task bindings, services/logs, Git/files, and closure. [Feature recipe](../feature-map/workspaces.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | | [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | | [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | | [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) | `workspaces/services` | unreviewed / production / fixtures | 16 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | +### Execution environments + +Local, SSH, sandbox providers, target probes, and custom images. + +[Feature recipe](../feature-map/execution-environments.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +### Recovery + +Stopped work, workspace repair, bounded continuation, and reconnect. + +[Feature recipe](../feature-map/recovery.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Projects and execution/Recovery/App/Connection recovery](../../ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx) | `recovery/reconnect` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Task chat/Recovery notice](../../ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx) | `recovery/diagnosis` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Tasks/Execution recovery](../../ui/storybook/stories/recovery/execution-recovery.stories.tsx) | `recovery/diagnosis` | unreviewed / production / fixtures | 18 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) | `recovery/diagnosis`, `task-coordination/dependencies` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | current / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Workspace branch recovery/01 Repair card](../../ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 8 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Workspace branch recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | + +## Apps, channels, and extensions + +### Connection setup + +Catalog, MCP links, task requests, authentication, and setup resumption. + +[Feature recipe](../feature-map/connection-setup.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) | `connection-setup/channel-setup`, `gateways-profiles/client-connect` | unreviewed / production / fixtures | 11 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry](../../ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx) | `connection-setup/catalog` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) | `connection-setup/catalog` | proposed / prototype / fixtures | 12 / 1 | Local catalog composition uses shipped cards and filters; retains design exploration purpose, not acceptance of the Apps route. | +| [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) | `connection-setup/catalog` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) | `connection-setup/credentials` | current / production / fixtures | 9 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 27 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 20 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 9 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) | `connection-setup/credentials`, `routines/triggers` | unreviewed / production / fixtures | 27 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) | `connection-setup/task-request` | unreviewed / production / fixtures | 78 / 23 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) | `connection-setup/handoff` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 10 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### App access and action permissions + +Agent grants, off/ask/allowed actions, discovery, tests, and revocation. + +[Feature recipe](../feature-map/app-permissions.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) | `app-permissions/test-action`, `app-permissions/action-policy` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) | `app-permissions/test-action`, `app-permissions/action-policy` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts](../../ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx) | `app-permissions/account-maintenance` | current / production / fixture-server | 1 / 0 | Production Browse requires owned tests/aggregator-accounts/test-drive.ts on localhost:4310; providers are simulated. Not an offline story. | +| [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) | `app-permissions/agent-access`, `app-permissions/action-policy` | unreviewed / production / fixtures | 12 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### External chat and email + +Provider-specific identity, threads, files, delivery, and endpoint upkeep. + +[Feature recipe](../feature-map/chat-channels.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here. | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | +| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | +| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | + +### Tool gateways and access profiles + +Tool exposure, client configuration, tokens, profiles, and activity. + +[Feature recipe](../feature-map/gateways-profiles.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles](../../ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx) | `gateways-profiles/profiles` | unreviewed / production / fixtures | 18 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx) | `gateways-profiles/gateway` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections](../../ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 9 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 25 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 7 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 14 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Secrets and proposals + +Company/user credentials, grants, proposals, and vault import. + +[Feature recipe](../feature-map/secrets.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) | `secrets/runtime-delivery`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) | `secrets/runtime-delivery`, `recovery/diagnosis` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor](../../ui/storybook/stories/secrets/environment-variables-editor.stories.tsx) | `secrets/runtime-delivery` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Secrets and proposals/Product/Secrets](../../ui/storybook/stories/secrets/secrets.stories.tsx) | `secrets/company-secrets`, `secrets/vault-import` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Secrets and proposals/Product/User secrets](../../ui/storybook/stories/secrets/user-secrets.stories.tsx) | `secrets/user-secrets` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Plugins + +Installation/configuration, contributed pages/tools, and lifecycle. + +[Feature recipe](../feature-map/plugins.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +## Automation and structured work + +### Routines, schedules, and triggers + +Definitions, variables, scheduled/webhook/manual runs, and history. + +[Feature recipe](../feature-map/routines.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) | `routines/definition`, `secrets/runtime-delivery` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 21 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 16 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Pipelines, review queues, and learnings + +Experimental stages, automation, items, review, and learning records. + +[Feature recipe](../feature-map/pipelines.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +### Cases + +Experimental structured fields, relationships, revisions, and task links. + +[Feature recipe](../feature-map/cases.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +### Status cards + +Experimental summaries, watched work, refresh history, and settings. + +[Feature recipe](../feature-map/status-cards.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +## Oversight and operation + +### Costs and budgets + +Spend reports, scoped limits, incidents, hard stops, and resumption. + +[Feature recipe](../feature-map/budgets-costs.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Dashboards and audit trails + +Company health, live work, activity, runs, routines, and timeline. + +[Feature recipe](../feature-map/activity.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) | `activity/activity`, `gateways-profiles/activity` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) | `activity/activity`, `gateways-profiles/activity` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc](../../ui/storybook/stories/activity/data-viz-misc.stories.tsx) | `activity/overview` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Dashboards and audit trails/Pages/Work Timeline](../../ui/storybook/stories/activity/work-timeline.stories.tsx) | `activity/timeline` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Navigation, profile, and announcements + +Sidebar state, favorites/recents, personal identity, and dismissals. + +[Feature recipe](../feature-map/navigation-preferences.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard](../../ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx) | `navigation-preferences/announcements` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx) | `navigation-preferences/navigation` | proposed / prototype / fixtures | 4 / 2 | Local composition retained for its original design purpose; full production parity remains unreviewed. | +| [Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout](../../ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx) | `navigation-preferences/navigation` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### Instance operations + +Installation, updates, service health, configuration, and backups. + +[Feature recipe](../feature-map/instance-operations.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Oversight and operation/Instance operations/Product/Dev Ops Surfaces](../../ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx) | `instance-operations/server-service` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +### CLI/API and local worktrees + +Explicit context, resource commands, outputs, runs, and isolated instances. + +[Feature recipe](../feature-map/cli-operations.md) + +No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature. + + +## Contributor surfaces + +### Developer previews and diagnostic labs + +Design examples, interaction fixtures, performance checks; not production acceptance. + +[Feature recipe](../feature-map/developer-labs.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages](../../ui/storybook/stories/developer-labs/ux-labs.stories.tsx) | `developer-labs/interaction-labs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +## Foundations + +### Shared foundations and guides + +Shared components and contributor guidance outside the product feature index. + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Foundations/Components/Forms & Editors](../../ui/storybook/stories/foundations/forms-editors.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Foundations/Primitive Matrix](../../ui/storybook/stories/foundations/foundations.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Foundations/Primitive Coverage](../../ui/storybook/stories/foundations/primitives-coverage.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Foundations/Status Language](../../ui/storybook/stories/foundations/status-language.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Overview/Storybook Guide](../../ui/storybook/stories/overview/overview.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / guide / static | 1 / 0 | Matches the upstream feature index and order, including features with no cataloged examples. Related suite links and classifications do not imply execution coverage. | + ## Capability lookup References are navigation links, not proof of parity. An absent link means no cataloged story, not a missing product feature. CLI/operator capabilities may need no Storybook example. -- `access/login`: No cataloged story. -- `access/invitations`: [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) (unreviewed) -- `access/membership`: No cataloged story. -- `access/resources`: [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) (unreviewed) -- `access/cli-auth`: No cataloged story. -- `activity/overview`: [Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc](../../ui/storybook/stories/activity/data-viz-misc.stories.tsx) (unreviewed) -- `activity/activity`: [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) (unreviewed), [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed) -- `activity/runs`: No cataloged story. -- `activity/routine-audit`: No cataloged story. -- `activity/timeline`: [Oversight and operation/Dashboards and audit trails/Pages/Work Timeline](../../ui/storybook/stories/activity/work-timeline.stories.tsx) (unreviewed) -- `agent-chat/discovery`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) -- `agent-chat/history`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (current), [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical) -- `agent-chat/handoff`: No cataloged story. -- `agent-chat/conference-room`: No cataloged story. -- `agents/hire`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) (historical) -- `agents/instructions`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed) -- `agents/organization`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) -- `agents/lifecycle`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed) -- `agents/built-in`: [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents](../../ui/storybook/stories/agents/built-in-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) (unreviewed) -- `app-permissions/agent-access`: [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) -- `app-permissions/action-policy`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) -- `app-permissions/catalog-change`: No cataloged story. -- `app-permissions/test-action`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed) -- `app-permissions/account-maintenance`: [Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts](../../ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx) (current) -- `budgets-costs/cost-reporting`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) -- `budgets-costs/budgets`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed) -- `budgets-costs/incidents`: No cataloged story. -- `budgets-costs/resolution`: No cataloged story. -- `cases/browse`: No cataloged story. -- `cases/structure`: No cataloged story. -- `cases/history`: No cataloged story. -- `cases/task-links`: No cataloged story. -- `chat-channels/providers`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) -- `chat-channels/identity`: [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) (current), [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) (current) -- `chat-channels/conversation`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) (unreviewed) -- `chat-channels/attachments`: No cataloged story. -- `chat-channels/lifecycle`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed) -- `cli-operations/context`: No cataloged story. -- `cli-operations/domain-commands`: No cataloged story. -- `cli-operations/outputs`: No cataloged story. -- `cli-operations/run-controls`: No cataloged story. -- `cli-operations/worktree-instances`: No cataloged story. -- `companies/selection`: No cataloged story. -- `companies/identity`: [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) -- `companies/archive`: No cataloged story. -- `companies/portability`: No cataloged story. -- `connection-setup/catalog`: [Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry](../../ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) (proposed), [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) (unreviewed) -- `connection-setup/identity-and-access`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) (unreviewed) -- `connection-setup/credentials`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) (current), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) -- `connection-setup/handoff`: [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) (unreviewed) -- `connection-setup/draft-recovery`: No cataloged story. -- `connection-setup/task-request`: [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) (unreviewed) -- `connection-setup/channel-setup`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) -- `connection-setup/failure`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) -- `developer-labs/design`: No cataloged story. -- `developer-labs/interaction-labs`: [Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages](../../ui/storybook/stories/developer-labs/ux-labs.stories.tsx) (unreviewed) -- `developer-labs/performance`: No cataloged story. -- `developer-labs/route-gates`: No cataloged story. -- `documents-artifacts/documents`: No cataloged story. -- `documents-artifacts/annotations`: [Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations](../../ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Product/Document Comments](../../ui/storybook/stories/documents-artifacts/document-comments.stories.tsx) (unreviewed) -- `documents-artifacts/attachments`: [Tasks and conversations/Documents, attachments, and work products/Artifact upload/01 Artifacts pane](../../ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Artifact upload/02 Paused task journey](../../ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Components/CSV File Tab](../../ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet](../../ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs](../../ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx) (unreviewed) -- `documents-artifacts/work-products`: [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit](../../ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Data](../../ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Document](../../ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/File](../../ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Image](../../ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Link preview](../../ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Pull request](../../ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Video](../../ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards](../../ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) (unreviewed) -- `documents-artifacts/library`: [Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) (unreviewed) -- `execution-environments/configuration`: No cataloged story. -- `execution-environments/capabilities`: No cataloged story. -- `execution-environments/probe`: No cataloged story. -- `execution-environments/assignment`: No cataloged story. -- `execution-environments/images`: No cataloged story. -- `gateways-profiles/gateway`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx) (unreviewed) -- `gateways-profiles/profiles`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles](../../ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx) (unreviewed) -- `gateways-profiles/client-connect`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections](../../ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) (unreviewed) -- `gateways-profiles/tokens`: No cataloged story. -- `gateways-profiles/activity`: [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) (unreviewed), [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) (unreviewed) -- `goals/hierarchy`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) -- `goals/alignment`: No cataloged story. -- `goals/navigation`: No cataloged story. -- `inbox-search/views`: No cataloged story. -- `inbox-search/triage`: [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed) -- `inbox-search/decisions`: [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) (unreviewed) -- `inbox-search/search`: [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) (unreviewed) -- `instance-operations/install-update`: No cataloged story. -- `instance-operations/server-service`: [Oversight and operation/Instance operations/Product/Dev Ops Surfaces](../../ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx) (unreviewed) -- `instance-operations/configuration`: No cataloged story. -- `instance-operations/diagnostics`: No cataloged story. -- `instance-operations/backup`: No cataloged story. -- `navigation-preferences/navigation`: [Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx) (proposed), [Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout](../../ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx) (unreviewed) -- `navigation-preferences/preferences`: No cataloged story. -- `navigation-preferences/profile`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed) -- `navigation-preferences/announcements`: [Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard](../../ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx) (unreviewed) -- `navigation-preferences/fallbacks`: No cataloged story. - `onboarding/instance`: No cataloged story. - `onboarding/company`: [Getting started and access/Onboarding and first work/Onboarding/Character](../../ui/storybook/stories/onboarding/onboarding-character.stories.tsx) (unreviewed) - `onboarding/adapter`: [Getting started and access/Onboarding and first work/Onboarding/Agent arc](../../ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx) (unreviewed), [Getting started and access/Onboarding and first work/Onboarding/Connect a model](../../ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx) (historical) - `onboarding/first-task`: [Getting started and access/Onboarding and first work/Onboarding/Agent arc](../../ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx) (unreviewed), [Getting started and access/Onboarding and first work/Onboarding/Connect a model](../../ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx) (historical) - `onboarding/resume`: No cataloged story. -- `pipelines/stages`: No cataloged story. -- `pipelines/items`: No cataloged story. -- `pipelines/review`: No cataloged story. -- `pipelines/learnings`: No cataloged story. -- `plugins/installation`: No cataloged story. -- `plugins/configuration`: No cataloged story. -- `plugins/contributions`: No cataloged story. -- `plugins/lifecycle`: No cataloged story. -- `projects/lifecycle`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) -- `projects/task-context`: [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) (unreviewed) -- `projects/repositories`: [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) (proposed), [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) (proposed) -- `projects/defaults`: [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) (unreviewed) +- `access/login`: No cataloged story. +- `access/invitations`: [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) (unreviewed) +- `access/membership`: No cataloged story. +- `access/resources`: [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) (unreviewed) +- `access/cli-auth`: No cataloged story. +- `companies/selection`: No cataloged story. +- `companies/identity`: [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) +- `companies/archive`: No cataloged story. +- `companies/portability`: No cataloged story. +- `tasks/create`: [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) (current), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `tasks/organize`: [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) (unreviewed) +- `tasks/properties`: [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) (unreviewed) +- `tasks/discussion`: [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) (unreviewed) +- `tasks/completion`: [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) (unreviewed) +- `task-coordination/delegation`: [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) (unreviewed) +- `task-coordination/dependencies`: [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) (unreviewed) +- `task-coordination/signoff`: [Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions](../../ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx) (unreviewed) +- `task-coordination/tree-control`: [Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces](../../ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces](../../ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx) (unreviewed) +- `inbox-search/views`: No cataloged story. +- `inbox-search/triage`: [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed) +- `inbox-search/decisions`: [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) (unreviewed) +- `inbox-search/search`: [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) (unreviewed) +- `agent-chat/discovery`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `agent-chat/history`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical) +- `agent-chat/handoff`: No cataloged story. +- `agent-chat/conference-room`: No cataloged story. - `questions-and-approvals/questions`: [Tasks and conversations/Questions and approvals/Composer/Question flow](../../ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) (current) - `questions-and-approvals/dismiss-and-reopen`: [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) (unreviewed) - `questions-and-approvals/confirmations`: [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) (current) @@ -551,6 +614,53 @@ References are navigation links, not proof of parity. An absent link means no ca - `questions-and-approvals/governance`: [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed) - `questions-and-approvals/tool-review`: [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) (unreviewed) - `questions-and-approvals/continuation`: No cataloged story. +- `steering/queue`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) +- `steering/edit-order-discard`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) +- `steering/steer`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) +- `steering/interrupt`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current), [Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff](../../ui/storybook/stories/steering/interrupt-handoff.stories.tsx) (unreviewed) +- `steering/stop-and-pause`: [Tasks and conversations/Steering and queued messages/Composer/Paused task takeover](../../ui/storybook/stories/steering/paused-composer.stories.tsx) (historical), [Tasks and conversations/Steering and queued messages/Composer/Execution controls](../../ui/storybook/stories/steering/task-execution-controls.stories.tsx) (current) +- `steering/drafts`: No cataloged story. +- `steering/approval-queue`: No cataloged story. +- `documents-artifacts/documents`: No cataloged story. +- `documents-artifacts/annotations`: [Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations](../../ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Product/Document Comments](../../ui/storybook/stories/documents-artifacts/document-comments.stories.tsx) (unreviewed) +- `documents-artifacts/attachments`: [Tasks and conversations/Documents, attachments, and work products/Artifact upload/01 Artifacts pane](../../ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Artifact upload/02 Paused task journey](../../ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Components/CSV File Tab](../../ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet](../../ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs](../../ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx) (unreviewed) +- `documents-artifacts/work-products`: [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit](../../ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Data](../../ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Document](../../ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/File](../../ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Image](../../ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Link preview](../../ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Pull request](../../ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Video](../../ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards](../../ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) (unreviewed) +- `documents-artifacts/library`: [Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) (unreviewed) +- `agents/hire`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) (historical) +- `agents/instructions`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed) +- `agents/organization`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `agents/lifecycle`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed) +- `agents/built-in`: [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents](../../ui/storybook/stories/agents/built-in-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) (unreviewed) +- `runs-adapters/availability`: [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed) +- `runs-adapters/validation`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) (current), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits](../../ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review](../../ui/storybook/stories/runs-adapters/ai-connections.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools](../../ui/storybook/stories/runs-adapters/connection-pools.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in](../../ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview](../../ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding](../../ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect](../../ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent](../../ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage](../../ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery](../../ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components](../../ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx) (unreviewed) +- `runs-adapters/runs`: [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) (unreviewed) +- `runs-adapters/session`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals](../../ui/storybook/stories/runs-adapters/session-goals.stories.tsx) (unreviewed) +- `skills/discovery`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid](../../ui/storybook/stories/skills/skills-store-discovery.stories.tsx) (unreviewed) +- `skills/authoring`: [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy](../../ui/storybook/stories/skills/fork-skill-flow.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/skills/frontmatter-panel.stories.tsx) (unreviewed) +- `skills/versions`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed) +- `skills/policy`: [Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow](../../ui/storybook/stories/skills/agent-skill-row.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states](../../ui/storybook/stories/skills/agent-skills-edge.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/skills/agents-using-skill.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) (unreviewed) +- `skills/testing`: No cataloged story. +- `teams/discovery`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) +- `teams/preview`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) +- `teams/install`: No cataloged story. +- `teams/configuration`: No cataloged story. +- `projects/lifecycle`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) +- `projects/task-context`: [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) (unreviewed) +- `projects/repositories`: [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) (proposed), [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) (proposed) +- `projects/defaults`: [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) (unreviewed) +- `goals/hierarchy`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) +- `goals/alignment`: No cataloged story. +- `goals/navigation`: No cataloged story. +- `workspaces/binding`: [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) (unreviewed) +- `workspaces/provisioning`: [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) (unreviewed) +- `workspaces/services`: [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) (unreviewed) +- `workspaces/files-git`: [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) (unreviewed) +- `workspaces/closure`: No cataloged story. +- `execution-environments/configuration`: No cataloged story. +- `execution-environments/capabilities`: No cataloged story. +- `execution-environments/probe`: No cataloged story. +- `execution-environments/assignment`: No cataloged story. +- `execution-environments/images`: No cataloged story. - `recovery/diagnosis`: [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) (unreviewed), [Projects and execution/Recovery/Task chat/Recovery notice](../../ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Tasks/Execution recovery](../../ui/storybook/stories/recovery/execution-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) (unreviewed) - `recovery/bounded-continuation`: [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) (unreviewed) - `recovery/source-action`: [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed) @@ -559,51 +669,80 @@ References are navigation links, not proof of parity. An absent link means no ca - `recovery/unknown-outcome`: No cataloged story. - `recovery/user-continuation`: No cataloged story. - `recovery/reconnect`: [Projects and execution/Recovery/App/Connection recovery](../../ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx) (unreviewed) -- `routines/definition`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) -- `routines/triggers`: [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) (unreviewed) -- `routines/run-now`: No cataloged story. -- `routines/history`: No cataloged story. -- `routines/management`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) -- `runs-adapters/availability`: [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed) -- `runs-adapters/validation`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) (current), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits](../../ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review](../../ui/storybook/stories/runs-adapters/ai-connections.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools](../../ui/storybook/stories/runs-adapters/connection-pools.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in](../../ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview](../../ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding](../../ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect](../../ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent](../../ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage](../../ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery](../../ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx) (mixed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components](../../ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx) (unreviewed) -- `runs-adapters/runs`: [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) (unreviewed) -- `runs-adapters/session`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals](../../ui/storybook/stories/runs-adapters/session-goals.stories.tsx) (unreviewed) +- `connection-setup/catalog`: [Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry](../../ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) (proposed), [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) (unreviewed) +- `connection-setup/identity-and-access`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) (unreviewed) +- `connection-setup/credentials`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) (current), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) +- `connection-setup/handoff`: [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) (unreviewed) +- `connection-setup/draft-recovery`: No cataloged story. +- `connection-setup/task-request`: [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) (unreviewed) +- `connection-setup/channel-setup`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) +- `connection-setup/failure`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) +- `app-permissions/agent-access`: [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) +- `app-permissions/action-policy`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) +- `app-permissions/catalog-change`: No cataloged story. +- `app-permissions/test-action`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed) +- `app-permissions/account-maintenance`: [Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts](../../ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx) (current) +- `chat-channels/providers`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) +- `chat-channels/identity`: [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) (current), [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) (current) +- `chat-channels/conversation`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) (unreviewed) +- `chat-channels/attachments`: No cataloged story. +- `chat-channels/lifecycle`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed) +- `gateways-profiles/gateway`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx) (unreviewed) +- `gateways-profiles/profiles`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles](../../ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx) (unreviewed) +- `gateways-profiles/client-connect`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections](../../ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) (unreviewed) +- `gateways-profiles/tokens`: No cataloged story. +- `gateways-profiles/activity`: [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) (unreviewed), [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) (unreviewed) - `secrets/company-secrets`: [Apps, channels, and extensions/Secrets and proposals/Product/Secrets](../../ui/storybook/stories/secrets/secrets.stories.tsx) (unreviewed) - `secrets/user-secrets`: [Apps, channels, and extensions/Secrets and proposals/Product/User secrets](../../ui/storybook/stories/secrets/user-secrets.stories.tsx) (unreviewed) - `secrets/proposals`: No cataloged story. - `secrets/vault-import`: [Apps, channels, and extensions/Secrets and proposals/Product/Secrets](../../ui/storybook/stories/secrets/secrets.stories.tsx) (unreviewed) - `secrets/runtime-delivery`: [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor](../../ui/storybook/stories/secrets/environment-variables-editor.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) (unreviewed) -- `skills/discovery`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid](../../ui/storybook/stories/skills/skills-store-discovery.stories.tsx) (unreviewed) -- `skills/authoring`: [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy](../../ui/storybook/stories/skills/fork-skill-flow.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/skills/frontmatter-panel.stories.tsx) (unreviewed) -- `skills/versions`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed) -- `skills/policy`: [Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow](../../ui/storybook/stories/skills/agent-skill-row.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states](../../ui/storybook/stories/skills/agent-skills-edge.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/skills/agents-using-skill.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) (unreviewed) -- `skills/testing`: No cataloged story. +- `plugins/installation`: No cataloged story. +- `plugins/configuration`: No cataloged story. +- `plugins/contributions`: No cataloged story. +- `plugins/lifecycle`: No cataloged story. +- `routines/definition`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) +- `routines/triggers`: [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) (unreviewed) +- `routines/run-now`: No cataloged story. +- `routines/history`: No cataloged story. +- `routines/management`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) +- `pipelines/stages`: No cataloged story. +- `pipelines/items`: No cataloged story. +- `pipelines/review`: No cataloged story. +- `pipelines/learnings`: No cataloged story. +- `cases/browse`: No cataloged story. +- `cases/structure`: No cataloged story. +- `cases/history`: No cataloged story. +- `cases/task-links`: No cataloged story. - `status-cards/authoring`: No cataloged story. - `status-cards/generation`: No cataloged story. - `status-cards/inspection`: No cataloged story. - `status-cards/archive`: No cataloged story. -- `steering/queue`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) -- `steering/edit-order-discard`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) -- `steering/steer`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) -- `steering/interrupt`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current), [Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff](../../ui/storybook/stories/steering/interrupt-handoff.stories.tsx) (unreviewed) -- `steering/stop-and-pause`: [Tasks and conversations/Steering and queued messages/Composer/Paused task takeover](../../ui/storybook/stories/steering/paused-composer.stories.tsx) (historical), [Tasks and conversations/Steering and queued messages/Composer/Execution controls](../../ui/storybook/stories/steering/task-execution-controls.stories.tsx) (current) -- `steering/drafts`: No cataloged story. -- `steering/approval-queue`: No cataloged story. -- `task-coordination/delegation`: [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) (unreviewed) -- `task-coordination/dependencies`: [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) (unreviewed) -- `task-coordination/signoff`: [Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions](../../ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx) (unreviewed) -- `task-coordination/tree-control`: [Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces](../../ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx) (unreviewed), [Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces](../../ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx) (unreviewed) -- `tasks/create`: [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) (current), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) -- `tasks/organize`: [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) (unreviewed) -- `tasks/properties`: [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) (unreviewed) -- `tasks/discussion`: [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) (unreviewed) -- `tasks/completion`: [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) (unreviewed) -- `teams/discovery`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) -- `teams/preview`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) -- `teams/install`: No cataloged story. -- `teams/configuration`: No cataloged story. -- `workspaces/binding`: [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) (unreviewed) -- `workspaces/provisioning`: [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) (unreviewed) -- `workspaces/services`: [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) (unreviewed) -- `workspaces/files-git`: [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) (unreviewed) -- `workspaces/closure`: No cataloged story. +- `budgets-costs/cost-reporting`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) +- `budgets-costs/budgets`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed) +- `budgets-costs/incidents`: No cataloged story. +- `budgets-costs/resolution`: No cataloged story. +- `activity/overview`: [Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc](../../ui/storybook/stories/activity/data-viz-misc.stories.tsx) (unreviewed) +- `activity/activity`: [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) (unreviewed), [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed) +- `activity/runs`: No cataloged story. +- `activity/routine-audit`: No cataloged story. +- `activity/timeline`: [Oversight and operation/Dashboards and audit trails/Pages/Work Timeline](../../ui/storybook/stories/activity/work-timeline.stories.tsx) (unreviewed) +- `navigation-preferences/navigation`: [Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx) (proposed), [Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout](../../ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx) (unreviewed) +- `navigation-preferences/preferences`: No cataloged story. +- `navigation-preferences/profile`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed) +- `navigation-preferences/announcements`: [Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard](../../ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx) (unreviewed) +- `navigation-preferences/fallbacks`: No cataloged story. +- `instance-operations/install-update`: No cataloged story. +- `instance-operations/server-service`: [Oversight and operation/Instance operations/Product/Dev Ops Surfaces](../../ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx) (unreviewed) +- `instance-operations/configuration`: No cataloged story. +- `instance-operations/diagnostics`: No cataloged story. +- `instance-operations/backup`: No cataloged story. +- `cli-operations/context`: No cataloged story. +- `cli-operations/domain-commands`: No cataloged story. +- `cli-operations/outputs`: No cataloged story. +- `cli-operations/run-controls`: No cataloged story. +- `cli-operations/worktree-instances`: No cataloged story. +- `developer-labs/design`: No cataloged story. +- `developer-labs/interaction-labs`: [Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages](../../ui/storybook/stories/developer-labs/ux-labs.stories.tsx) (unreviewed) +- `developer-labs/performance`: No cataloged story. +- `developer-labs/route-gates`: No cataloged story. diff --git a/maps/storybook-map/README.md b/maps/storybook-map/README.md index 1351a22b78f..86ad034b217 100644 --- a/maps/storybook-map/README.md +++ b/maps/storybook-map/README.md @@ -17,8 +17,8 @@ maintainer; this catalog does not redefine product behavior or permissions. ## Catalog contract -`catalog.json` is the maintained inventory, with one entry per story file. -`INDEX.md` is generated from it and the installed Storybook CSF parser. Titles, +`catalog.json` is the maintained inventory, with one entry per authored story file. +`INDEX.md` is generated from it, the upstream feature map, and the installed Storybook CSF parser. Titles, export filtering, story counts and IDs come from source, not filename guesses. All examples are retained, including historical suites. A file's first capability is its primary home; remaining capabilities are lookup references, not copies. @@ -35,7 +35,21 @@ fixture modules and assets live in `ui/storybook/support/`; production wrappers The sidebar follows the section and feature names in the feature-map README, then retains each suite's useful scenario grouping. Foundations contains shared component examples with no primary product capability. Overview contains the catalog guide. -Areas with no stories do not get empty sidebar folders. +Every indexed feature has an Overview page, even when it has no cataloged examples. +The main guide and these pages use the same section, feature, and capability order +as the upstream map. Related suite links cross primary homes without copying stories. +Sidebar labels replace `/` with `, ` because Storybook treats slashes as folders; +the index and page heading retain the original feature name. +Missing examples are explicit; historical examples and guides do not imply current +behavioral coverage. + +`ui/storybook/generated/` contains the derived index and one CSF Overview per +feature. The inline `storySort.order` in the preview config is also generated: +Storybook's indexer requires a literal there. Regenerate these outputs with the catalog command; do not edit +them or add them to the authored inventory. Their stable IDs use +`feature-map---overview`. Generated pages are explanatory guides and do not +increase example-suite counts. Storybook start/build checks this output for drift. +The generator fails on unexpected files rather than silently deleting them. Each suite declares an explicit CSF `id`, recorded as `stableId` in the catalog. The `sidebarTitle` can change without changing that identity. Existing story and @@ -76,14 +90,17 @@ After installing workspace dependencies: ```sh node scripts/storybook-catalog.mjs node scripts/storybook-catalog.mjs --write -node --test scripts/storybook-catalog.test.mjs +node --test scripts/storybook-catalog.test.mjs scripts/storybook-feature-index.test.mjs ``` The checker rejects unaccounted or deleted stories, invalid capability references, duplicate entries/IDs, missing review sources, changed fingerprints and a stale -generated index. `--write` only regenerates the index; it never refreshes review +generated indexes and feature pages. `--write` only regenerates these outputs; it never refreshes review fingerprints or blesses drift. Preserve IDs when changing titles or moving files; compare the existing built Storybook index before and after such changes. +Feature outputs are regenerated before fingerprints are checked. If those writes +change a review source, the command fails with the affected review entries. Inspect +the generated change before updating those fingerprints and rerunning the command. Use the existing [Storybook workflow](../../doc/DEVELOPING.md#storybook) for builds, browser review, and visual tests. Report source inspection, rendering, `play` diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index 7697eb6c384..68802f3ab20 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -136,6 +136,39 @@ sample of interactions and rendering, not execution of all 2,210 stories. The fixture-server story and visual-baseline comparisons remain untested for the reasons below. The owned static preview was left running for the requested test-drive. +## Complete feature index follow-up — 2026-10-12 + +Source baseline: `88bf4c6c1d222f818faa56257efe79207c892a6c` (merged PR #16049). +Storybook now derives all eight sections, 35 feature families, and 171 capabilities +from the feature map. Each feature has a generated Overview, including the seven +without primary suites. Related example links cross primary homes without copying +stories. Generated guides are separate from the authored catalog and example counts. +The upstream feature map remains unchanged. Deeper authored scenario groups remain +intact; this pass does not establish new production parity. + +The built index preserved all 2,210 existing story IDs and the docs ID, adding only +35 feature overviews in map order. Catalog/generator tests (16), token gates, map +checks, both skill validators, workspace typecheck, workspace build, and Storybook +build passed. The UI project passed 8,170 tests in 728 files. The broad local test +run encountered a failure in the unchanged GitHub chat case `authorizes description +edits as the editor, never inheriting the original author's access`; that exact +case passed alone without changing code or expectations. A complete local repository +suite pass is not established. PR #16052 records the final CI result separately. + +The local built Storybook passed browser checks for the complete index/sidebar, +capability-ID search, empty search, Goals cross-feature suite links, all five +Execution environments capability gaps, and the existing Questions suite links. +The original `composer-question-flow--editable-initial-text` bookmark still renders. +The CLI feature uses a comma in its sidebar label because Storybook treats `/` as +a folder separator; its guide heading retains the upstream name. + +Review fixes retain matching shared foundations during search and validate review +fingerprints after generated files are written. Searching `Primitive Matrix` shows +and opens the existing foundation story. A regression test changes a capability +description and proves regeneration reports the resulting review drift without +updating fingerprints. These checks establish navigation and generation behavior, +not production acceptance or visual-baseline comparison. + ## Remaining review work 1. Review the 188 unreviewed suites by capability and production subject. Compare @@ -278,3 +311,23 @@ Source inspection only: no browser rendering, play, visual baseline, provider run or live production acceptance was performed for this reconciliation. The existing catalog checker must run on the actual merged tree after synchronization; its result verifies structure and declared hashes, not these live journeys. + +## 2026-10-13 master merge reconciliation + +Reviewed the source delta from `4808adc34e` to merge `63b2e7b8ad` +(master parent `d67e3507b2`). The text conflict in INDEX retained the feature-map +ordering and upstream workspace review notes. Regenerated INDEX after reviewing +61 stale catalog entries against the changed declared sources. + +The inspected changes add assignment-history recipient suggestions and preserve +manual selections, expand backend verification recipes, and add channel task +defaults and access avatars with matching fixture changes. Entries record those +limits. The sidebar suite that also mounts IssueDetail is now unreviewed because +its route fixture has not been exercised against the new activity query. Existing +composer examples omit the new optional suggested-assignee prop and retain their +previous scenarios; their classifications do not certify the new behavior. + +All 223 authored suites and 2,210 authored story IDs remain. Generated feature +pages still cover 35 areas and 171 capabilities. Catalog/generator tests (16), +catalog validation, map checks and token gates pass. This is source reconciliation, +not a new visual or live production acceptance claim. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 1395cb94ed4..a609ad0a97c 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -5,9 +5,12 @@ "ui/storybook/.storybook/main.ts", "ui/storybook/.storybook/preview.tsx", "ui/storybook/fixtures/paperclipData.ts", - "maps/feature-map/README.md" + "maps/feature-map/README.md", + "scripts/storybook-feature-index.mjs", + "ui/storybook/generated/feature-index.json", + "ui/storybook/support/feature-map/FeatureOverview.tsx" ], - "fingerprint": "eb37e5c7110df7804ffabbd4784bd78cf1f5d31a858bdcb8db7080cfa02b7f0b" + "fingerprint": "8454a7897e390c824f7bd2120009a8454ae60eeae5f4d562f556f8839b716daf" }, "entries": [ { @@ -257,11 +260,11 @@ "agent-chat/history" ], "purpose": "Design explorations/Agent chat sidebar/Pages", - "status": "current", + "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Wrapper mounts production Layout and AgentChat; conversation selection and sends use in-memory routes. Directory name is not prototype evidence.", - "reviewedAt": "2026-10-12", + "notes": "Wrapper mounts production Layout and AgentChat; conversation selection and sends use in-memory routes. Directory name is not prototype evidence. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "ui/src/components/AgentChatPicker.tsx", @@ -281,7 +284,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx" ], - "fingerprint": "aeea37e51418ab1d78eca93cfd4b51636941ac22c8dbadf7fe1a70de74b7ae8b", + "fingerprint": "6994574cfda7901b4cec355add8dadb58146bd4cf0d3488796fe27e19e34cdc3", "stableId": "design-explorations-agent-chat-sidebar-pages", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages" }, @@ -295,8 +298,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/questions-and-approvals.md", @@ -305,7 +308,7 @@ "ui/storybook/fixtures/unansweredQuestionChat.tsx", "ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx" ], - "fingerprint": "a55976ae6cf404c1febe64fb674e1096e8e58dc0360001a5a12af202e0b40908", + "fingerprint": "20c4a640715abcfeadc2763e5d7485aefd32d3e8d596e6392646f8e3264c3b78", "stableId": "chat-comments-agent-chat-unanswered-questions", "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions" }, @@ -319,8 +322,8 @@ "status": "mixed", "implementation": "mixed", "boundary": "fixtures", - "notes": "Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity.", - "reviewedAt": "2026-10-12", + "notes": "Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "ui/src/components/AgentChatPicker.tsx", @@ -340,7 +343,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/agent-chat.stories.tsx" ], - "fingerprint": "b04974700adf2b2fc95565066e4385c68979e509da1f97443ae31a653947255e", + "fingerprint": "c3faad0b551a84f6c7ddf5cf9566b96e0d435d28a4ac3035d147b069723b7152", "stableId": "design-explorations-agent-chat", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat" }, @@ -472,8 +475,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agents.md", "maps/feature-map/navigation-preferences.md", @@ -487,7 +490,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/agents/agent-persona-pages.stories.tsx" ], - "fingerprint": "4c76c6a1c6f5ac036140b649bcf8a0a02978273550a8f784d772a8756358b34a", + "fingerprint": "885a9c5e6afc2f064ef699ec80153b62e4e4bdaa62da4c0724055e910ab3284d", "stableId": "agents-personas-full-pages", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages" }, @@ -676,8 +679,8 @@ "status": "unreviewed", "implementation": "prototype", "boundary": "fixtures", - "notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/components/AgentIconPicker.tsx", @@ -687,7 +690,7 @@ "ui/src/components/task-chat/presentation-mode.tsx", "ui/storybook/stories/chat-channels/agentmail.stories.tsx" ], - "fingerprint": "4433454864b43dce30061f1b7e8f65e7bc5fa2f386bde115e276c30de4e6898f", + "fingerprint": "55ba1a896505c14b5f7531c5dcc2d870f981344a23fe806d25a90350a61a7f33", "stableId": "connections-agentmail-tasks", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks" }, @@ -1159,8 +1162,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "maps/feature-map/tasks.md", @@ -1170,7 +1173,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx" ], - "fingerprint": "f6d94acdd29ba5ca746a6fcba87a44f4384288c02d736aa496a4d30bd94a922a", + "fingerprint": "c605c2eac926e4d0765a6289c22ebba5901de3d536b347f20a8cd293fc9142bc", "stableId": "paperclip-assigned-backlog-safeguards", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards" }, @@ -1208,7 +1211,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1219,7 +1222,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-activity.stories.tsx" ], - "fingerprint": "101840c76686fbf674f97ab469410466a57ff6ed733950544f7fe18b48b98376", + "fingerprint": "80aa360434184867ea46f1e508d3e97c29a6958f991c40c1866e10e5c16af1b1", "stableId": "tasks-browser-use-task-activity", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity" }, @@ -1233,7 +1236,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1243,7 +1246,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-footer.stories.tsx" ], - "fingerprint": "8cf7c9c98d9626150ab7ee48bf81ab5c98e1832b5b1c38ed4a47d5e15e0be9dc", + "fingerprint": "ba83c5b16100c735177b19029c587dcd79de2ab537bcaee130326058963bb2dd", "stableId": "tasks-browser-use-footer", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer" }, @@ -1279,7 +1282,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1290,7 +1293,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/workspaces/browser-use.stories.tsx" ], - "fingerprint": "3a9d780a83fe5a79fd8209125debc6292b2dbcffdf40cf0b0935e442fc8cb7a8", + "fingerprint": "42889268f5e8c23c9ac252a8eddb6b839f05a47594484122a41941c6da7636d0", "stableId": "tasks-browser-use-panel", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel" }, @@ -1360,8 +1363,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -1374,7 +1377,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/chat-comments.stories.tsx" ], - "fingerprint": "78ef704288c2c9ab63641e16b0e5fbe9d70bd81ec6dadacb1a31658388249c6d", + "fingerprint": "9375c8226472f5e94b44c7e5dbe6046d1a01f70e17ead05811d08e6636639d17", "stableId": "product-chat-comments", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments" }, @@ -1408,8 +1411,8 @@ "status": "historical", "implementation": "mixed", "boundary": "fixtures", - "notes": "Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful.", - "reviewedAt": "2026-10-12", + "notes": "Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "ui/src/components/AgentChatPicker.tsx", @@ -1429,7 +1432,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/chat-entry.stories.tsx" ], - "fingerprint": "8e43da9f1bb33d31007ceb6adb20ed6ece6a0cbc99f87ab0cea2172d11d4d2e4", + "fingerprint": "444465b306686cc36d85d4d2af43f9cd31f3c7c34a5fd05f8413aa639becf535", "replacement": "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "stableId": "design-explorations-chat-entry", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry" @@ -1443,8 +1446,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/api/chatEndpoints.ts", @@ -1456,7 +1459,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx" ], - "fingerprint": "593f980a74e21b1e8bce9f48ee8ef7f1247546e4b4519a0916b64881ba5224c8", + "fingerprint": "2fab3391785dd764f6c17819a6ac9f2a2388ffc4af0cbc2e4590580e3de21a09", "stableId": "connections-chat-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management" }, @@ -1490,8 +1493,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -1500,7 +1503,7 @@ "ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx", "ui/storybook/stories/tasks/completed-activity.stories.tsx" ], - "fingerprint": "7c91ff66b892ee3b50680e65cab8c83ec56851a75504ff5e3e6db4270f7b730e", + "fingerprint": "5a7cb4c48683bde333499fce04c3a0b983f444596ed68ab771b15fc910f05f25", "stableId": "tasks-completed-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview" }, @@ -1513,8 +1516,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/MobileBottomNav.tsx", @@ -1523,7 +1526,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-add-menu.stories.tsx" ], - "fingerprint": "cad28d51256da8f82100cec8c051bb8c40af4477b6582341c9a18e2ad18ba612", + "fingerprint": "e7a0a6c477048bb9410db5fa29552b6045a4a02520c4437ef0062141a677a134", "stableId": "composer-add-menu", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Add menu" }, @@ -1537,8 +1540,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -1550,7 +1553,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx" ], - "fingerprint": "b4d5689a5fd6a891e06d98d698f2556832969ac67c56f2e2a8d9da26b0510616", + "fingerprint": "5d8ab0bf43155faaf7a3791409cff9d2afc0a382feaac92c50916c9b9c8e9201", "stableId": "composer-mobile-assignee-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker" }, @@ -1564,8 +1567,8 @@ "status": "mixed", "implementation": "mixed", "boundary": "fixtures", - "notes": "Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity.", - "reviewedAt": "2026-10-12", + "notes": "Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -1578,7 +1581,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-model-picker.stories.tsx" ], - "fingerprint": "f1ddb769d225b27ea4275da372027f7bc6f11ed079a8e7b4a3b7409744707b1f", + "fingerprint": "28f590da6e27168049fb39edec3b0d3a8344d5afc6921d622f22a3a02677eccd", "stableId": "composer-model-and-effort-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker" }, @@ -1591,15 +1594,15 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", "ui/src/components/task-chat/QuestionForm.tsx", "ui/src/components/task-chat/TaskChatComposer.tsx", "ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx" ], - "fingerprint": "72abe627bd9ac15ef9c85219136bd36780be29d4cf042b5c26651b1df7b3a25c", + "fingerprint": "c1b27485d6807fe060e4420340b6dee67a2630642f4a7b5bbcee330f4c12efa3", "stableId": "composer-question-flow", "sidebarTitle": "Tasks and conversations/Questions and approvals/Composer/Question flow" }, @@ -1615,8 +1618,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/steering.md", "ui/src/components/task-chat/TaskChatComposer.tsx", @@ -1624,7 +1627,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/steering/composer-queued-messages.stories.tsx" ], - "fingerprint": "a411b06048a06cacf97c10f29c761c0b13f526766078955f98420d4a1530d7a2", + "fingerprint": "a7a8c31427592e9e35a4da6c90e08c334835e9f84544f2e887aa102c3fc35071", "stableId": "composer-steering-and-queued-messages", "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages" }, @@ -1719,8 +1722,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "maps/feature-map/questions-and-approvals.md", @@ -1730,7 +1733,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx" ], - "fingerprint": "749a735937db08e6c6798578b75db4e81a744f6762d2218d8e392fe9c36afad8", + "fingerprint": "1fd693af15b9cc5669e91fb5eaa67e21ad60866aac16f7f8c40ad6f8d8ec6bb9", "stableId": "chat-comments-connection-reviews", "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews" }, @@ -1746,8 +1749,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/activity.md", "maps/feature-map/budgets-costs.md", @@ -1763,7 +1766,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx" ], - "fingerprint": "55b7a763e93f099aa6085dc7de65474f619aebd1ec9d114ee9c70fd0d2cf42c6", + "fingerprint": "76878f4f9b53c94eac1e74fbb50b943b1f5359c3bddd119cc277bc8073bfe52a", "stableId": "product-control-plane-surfaces", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces" }, @@ -1821,8 +1824,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "maps/feature-map/secrets.md", @@ -1833,7 +1836,7 @@ "ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx", "ui/storybook/support/credential-access/CredentialTaskPage.tsx" ], - "fingerprint": "aadd6c8f82db53b2e910829deb409344199353cb4956313424ce8d1415bead85", + "fingerprint": "70a07a45f3633984db4bbadd599c3745e098f17100f8421b0c3cd1fec46e2186", "stableId": "task-credential-access-02-task-page", "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page" }, @@ -2035,8 +2038,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agents.md", "maps/feature-map/goals.md", @@ -2053,7 +2056,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/dialogs-modals.stories.tsx" ], - "fingerprint": "d1b6c016e119b19affd5d0af49c55b244d280cb4808d683fd583ac15d2fd1b73", + "fingerprint": "0f7eecae93e4360074aeb626deabe29add4a7b3cb96467830d62802094f144a2", "stableId": "product-dialogs-modals", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals" }, @@ -2271,8 +2274,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/ExternalObjectPill.tsx", @@ -2286,7 +2289,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/external-objects.stories.tsx" ], - "fingerprint": "5654b1d1a51fe8d00c3546452e6e2a02bda0be49b014a2bad2a4227c3f1ce685", + "fingerprint": "5484a9480224ce2e2fdc7376e582d26e6d049e6308a1705e45615594cc7db56a", "stableId": "foundations-external-objects", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/External Objects" }, @@ -2525,8 +2528,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/api/chatEndpoints.ts", @@ -2548,7 +2551,7 @@ "ui/storybook/support/github-bot-management/fixture-api.tsx", "ui/storybook/support/github-bot-management/fixtures.ts" ], - "fingerprint": "8688291f425ae517d5a655e9ab237bb035ef2cc8853dfc24129bd7258e36acab", + "fingerprint": "8dd50a9e507f68818ee119efaa9c90ea01ecf0ec82721abcf0e6f7d489de6cbb", "stableId": "connectors-github-bot-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management" }, @@ -2737,8 +2740,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/api/chatEndpoints.ts", @@ -2753,7 +2756,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" ], - "fingerprint": "90d113f72e3f6db48428bf498fba3d14d713eaab194bfb66eb1d27fe17843063", + "fingerprint": "002c59e80ccd20170b8ed432e8343395414df94b724c858006447735b0afc680", "stableId": "connections-imessage-photon", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" }, @@ -2766,8 +2769,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "ui/src/components/task-chat/TaskChatComposer.tsx", @@ -2777,7 +2780,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx" ], - "fingerprint": "23c4fa7c1255dd2110e51fcd9cc78942c716ea1adc3bd59ffd8cabf7830930a3", + "fingerprint": "ee52243b469f3e9581f41c2d1fb88c769d85b80fd6e667591445c261d9b8fcf9", "stableId": "connections-in-task-connections", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Connections/In-task connections" }, @@ -2834,8 +2837,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/Identity.tsx", @@ -2855,7 +2858,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/issue-management.stories.tsx" ], - "fingerprint": "0692ea26dbbe757bb070183a445b72c08d0431cd3f008d77b57682ee099a7cc2", + "fingerprint": "1c8ff1f940a366fabec4f2cb3f5bbf84e8d21be64bb301da65dcc39ca24c9faa", "stableId": "product-issue-management", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Issue Management" }, @@ -2913,8 +2916,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", "ui/src/components/IssueChatThread.tsx", @@ -2922,7 +2925,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx" ], - "fingerprint": "b50484bfb74560001734590d7e7519fd6138f8f24c19afd4a26fdc99593cdb05", + "fingerprint": "c36c3843d115a3dda4aded27d0ed2098105b18568b66e054f77088beca9a6e3c", "stableId": "chat-comments-issue-thread-interactions", "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions" }, @@ -2999,15 +3002,15 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/InlineEntitySelector.tsx", "ui/src/components/SearchableSelect.tsx", "ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx" ], - "fingerprint": "bf52ad146d2393f6acf3854ba155f6df367fc279410c1419dfc353b69e0a7507", + "fingerprint": "3707254c9f397fa663d1d51b0af5dbf1a811f34d9d4faf2dea5041116b92f1c8", "stableId": "components-entity-pickers-mobile", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile" }, @@ -3174,8 +3177,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/MarkdownBody.tsx", @@ -3183,7 +3186,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/new-task-composer.stories.tsx" ], - "fingerprint": "5fd37958b2f864e0f96e881540736c48f9884a0d4bf48200dea0fd0468c43a6d", + "fingerprint": "35fbacd5d5fc225d537fc537e34ad8601a75a7b3aa21b6016537096337b71261", "stableId": "composer-new-task", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/New task" }, @@ -3291,8 +3294,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/projects.md", "maps/feature-map/task-coordination.md", @@ -3304,7 +3307,7 @@ "ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx", "ui/storybook/stories/task-coordination/originating-tasks.stories.tsx" ], - "fingerprint": "e16a7c6351086b45005478d57101377fe51d5c2e8f8ae59ee0f3a32b1811d09d", + "fingerprint": "2c1e0da025ec60281b4c8179180114d7eee259cf30a1d7216a21bbf476eb7228", "stableId": "ux-labs-tasks-created-from-a-task", "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task" }, @@ -3315,15 +3318,17 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Generated inventory counts replace stale hand-maintained Planned/Covered claims; no execution coverage is implied.", + "notes": "Matches the upstream feature index and order, including features with no cataloged examples. Related suite links and classifications do not imply execution coverage.", "reviewedAt": "2026-10-12", "sources": [ "ui/src/components/ui/badge.tsx", "ui/src/components/ui/input.tsx", - "ui/storybook/stories/overview/overview.stories.tsx" + "ui/storybook/stories/overview/overview.stories.tsx", + "ui/storybook/support/feature-map/FeatureOverview.tsx", + "ui/storybook/generated/feature-index.json" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "cc12edb19c1e75db6869f343a6d07b6f1d11faff9ecfbf18a38a7d710a01e6d3", + "fingerprint": "e8dad5fdfca60cad92af994a3d9defbe847894610c368a0edbd1900837c03743", "stableId": "overview-storybook-guide", "sidebarTitle": "Overview/Storybook Guide" }, @@ -3336,8 +3341,8 @@ "status": "historical", "implementation": "prototype", "boundary": "fixtures", - "notes": "Retained local paused takeover; task-execution-controls exercises the shipped pause/stop controls.", - "reviewedAt": "2026-10-12", + "notes": "Retained local paused takeover; task-execution-controls exercises the shipped pause/stop controls. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/steering.md", "ui/src/components/task-chat/TaskChatComposer.tsx", @@ -3345,7 +3350,7 @@ "ui/storybook/stories/steering/paused-composer.stories.tsx" ], "replacement": "ui/storybook/stories/steering/task-execution-controls.stories.tsx", - "fingerprint": "cd90d40ff87a2ce1da5f1a815607055fbed0f9ea42cb74ffe170c769316d570b", + "fingerprint": "7e7436ddeb1e004e1b11297e93f7e299600a2fa68d36993275ed2591f6b4a320", "stableId": "composer-paused-task-takeover", "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Paused task takeover" }, @@ -3405,15 +3410,15 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage.", - "reviewedAt": "2026-10-12", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", "maps/feature-map/tasks.md", "ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx" ], - "fingerprint": "851f4f865947d60c6b17d8eef65b78035caf27dcbd0ec2ac46afaad7ed38fa78", + "fingerprint": "277165110d00845305892000266531905d0a18f017c6c9bf2d8e2e2069af5cb2", "stableId": "primary-agent-00-review-guide", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide" }, @@ -3428,8 +3433,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3448,7 +3453,7 @@ "ui/storybook/stories/agents/primary-agent/01-components.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "158129bcc28622cc3de88f13b51620bcc3e7b634b1f69938c7f4ff9892b7d356", + "fingerprint": "910d12dfac4a601dad1d80217dadf4db6855d8580157c8d9bb18db13b5bf7ddc", "stableId": "primary-agent-01-components", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components" }, @@ -3463,8 +3468,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3482,7 +3487,7 @@ "ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "3ad1e1e519d3a0e02ec2fd09cb64cd1dbf2e592712a6418e03d075c3a462fa75", + "fingerprint": "05f87fcb1dc24bd7ca56ca885ef1c79343f0330f9e93fb0c017aa7cb21c3a104", "stableId": "primary-agent-02-sidebar", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar" }, @@ -3497,8 +3502,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3516,7 +3521,7 @@ "ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "a9a767d5708e58e8d5ab4a893a6723c96a9cb17f90459dab19104701dcd2850b", + "fingerprint": "d9878058d6c2d4d144bcb8aeb97e9e0cb8186407b1b3fefb07078af7f147d74f", "stableId": "primary-agent-03-agents", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents" }, @@ -3531,8 +3536,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3550,7 +3555,7 @@ "ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "b8e2d721f79dcc64210f4c7b35f09536262a6a23f72241174f3a832fbed2bba3", + "fingerprint": "8ab5193560196f684c82e8d8fab6728a208395c3b8628f04dac0ac8c0185d4aa", "stableId": "primary-agent-04-new-task", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task" }, @@ -3565,8 +3570,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3584,7 +3589,7 @@ "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "e05752da7c5a429dbb4922bb995fb9579ac7163e6c4af65cf22c8e8d82008f33", + "fingerprint": "7defc95cd9c952f5f5f4ad1c7954478f5a33921fc6c6b1adf0a8fa3e16621dff", "stableId": "primary-agent-05-chat", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat" }, @@ -3599,8 +3604,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3618,7 +3623,7 @@ "ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "3e6310ce06cb3d7528ea93c1717696fcd4ccff72f36171c2b608ac2e1b18d86e", + "fingerprint": "6c8a08ae15112a54e69fee220160a1550ab9098445486e316c4fb34e2d6e085f", "stableId": "primary-agent-06-journeys", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys" }, @@ -3633,8 +3638,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", "maps/feature-map/agents.md", @@ -3652,7 +3657,7 @@ "ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "eebd68443ba087a528b29589b085f89c6f968f94ce289e92e75278878da194db", + "fingerprint": "a9ecc61cc3e70460b23e3fdd5ede33abd8e4e8e62d5dbce5fa5fcdbf0df2d234", "stableId": "primary-agent-07-confirmation", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation" }, @@ -3694,14 +3699,14 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage.", - "reviewedAt": "2026-10-12", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", "ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx" ], - "fingerprint": "9c6ac52cc6d027359890f69a49ed413a092f36c7eb305b36d8bab54070633b9d", + "fingerprint": "ac5d14d14aa3eec4795ff687b25d1c4025abaa29bb890d45086f1e088b62232d", "stableId": "private-tasks-00-review-guide", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/00 Review guide" }, @@ -3715,8 +3720,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", @@ -3733,7 +3738,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "03b3b019f9495280cc89648b901f4f49396c97e6cb8f25354d170f47ec968ffb", + "fingerprint": "8010910a61b5f20de41ead806ef1da41c09903b61d47db038fe31c2142960a1e", "stableId": "private-tasks-01-creation", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/01 Creation" }, @@ -3747,8 +3752,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", @@ -3759,7 +3764,7 @@ "ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "14eef8227c4193de41f7a75c7d0b8857e129a8d45c8eea619189f9a70f984b6f", + "fingerprint": "29ec80e59e9275be25933e1b87b9a49196af7bd946fb0bd9b21edea52ec58663", "stableId": "private-tasks-02-sharing", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/02 Sharing" }, @@ -3773,8 +3778,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", @@ -3785,7 +3790,7 @@ "ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "767e097ad435242b2bbf6453b58636458f2f0edcec0a9838443ada2bcced1ada", + "fingerprint": "249e1e9b5b9f4ff6625734ab176fca3fdfe237238303f843403a506e529517a6", "stableId": "private-tasks-03-task-actions", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/03 Task actions" }, @@ -3799,8 +3804,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", @@ -3816,7 +3821,7 @@ "ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "a0dc537873e6362097e5cdd7a7cfe141f42303b3e975547d03f428a937a6c037", + "fingerprint": "cb879fe3240a4d1e17e3198c07091d53af898f23c176bba2fc381e4b9bb41dea", "stableId": "private-tasks-04-references-and-blockers", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers" }, @@ -3831,8 +3836,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/projects.md", @@ -3846,7 +3851,7 @@ "ui/storybook/stories/access/private-tasks/05-projects.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "c3ae29a4a0e550629a17eaf817dc3d385e9ed811d3bcd6bf06859e0822378681", + "fingerprint": "5bbc18f1689862e62cf534729dda878cc5ca9e9d8e19f23286fb19536206d77b", "stableId": "private-tasks-05-projects", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/05 Projects" }, @@ -3861,8 +3866,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/projects.md", @@ -3879,7 +3884,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "bbdceb12f6ba702663f243dd3986c5295677c7e9e007ffbed7cbe3efb385ab52", + "fingerprint": "2972bb8615897132cd38835f2a744a5348a53ab746db4749c9ea91877e9e2485", "stableId": "private-tasks-06-full-product-pages", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages" }, @@ -3893,8 +3898,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", @@ -3926,7 +3931,7 @@ "ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "361fb8ed1b95d3668529e2fa2c2d3951524909468134613f6e0c00d062a3ac82", + "fingerprint": "024bd786a1800aed8756e66b876637280c00a51ce3372363aa74e79893a2913d", "stableId": "private-tasks-07-user-stories", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/07 User stories" }, @@ -4601,8 +4606,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -4616,7 +4621,7 @@ "ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx", "ui/storybook/stories/tasks/runner-activity.stories.tsx" ], - "fingerprint": "f39f36b1276c741530f6da95724d0e29fbc43c3bdd87b75640047bbc9f753810", + "fingerprint": "2f7782ae1961a4a8f02c7955edf2a44add21eb2fe241d717cd988ff48d0ca485", "stableId": "tasks-runner-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview" }, @@ -5037,8 +5042,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "maps/feature-map/tasks.md", @@ -5049,7 +5054,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/successful-run-handoff.stories.tsx" ], - "fingerprint": "dd47ab0240dd12f6bdeddeaf155be7afc472e728f69e77579adaf7aeec0eb588", + "fingerprint": "8cf0d6e52decc045151c65575984520ac882499baddbeb86ca10b42385863398", "stableId": "paperclip-successful-run-handoff", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff" }, @@ -5108,8 +5113,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", "maps/feature-map/tasks.md", @@ -5120,7 +5125,7 @@ "ui/src/components/task-chat/task-chat-model.ts", "ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx" ], - "fingerprint": "2c926951a7911a3ca79ceae50578e581f389e7cffe68bce15e862ae95a429644", + "fingerprint": "cd4351774fa50e58fda2f249f2870b7aa376b1eb23a25a4e69280501ef0021d7", "stableId": "tasks-task-chat-review-fixes", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes" }, @@ -5135,8 +5140,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures.", - "reviewedAt": "2026-10-12", + "notes": "Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", "ui/src/components/MobileBottomNav.tsx", @@ -5149,7 +5154,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx" ], - "fingerprint": "65414677c895af91f8381eb20082f1d9cacd84631324060e170589c7e48a6488", + "fingerprint": "a50e649ffce57fafe3ca37b31260463156238af8e58244b3cf33fdeac08353e2", "stableId": "composer-interaction-above-composer", "sidebarTitle": "Tasks and conversations/Questions and approvals/Composer/Interaction above composer" }, @@ -5162,8 +5167,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/MobileBottomNav.tsx", @@ -5173,7 +5178,7 @@ "ui/src/components/task-chat/useWindowAutoFollow.ts", "ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx" ], - "fingerprint": "5050a4cd07125dc0a5375950e14fb8d73c87c5832271973cf593b2d619e156d4", + "fingerprint": "988ca771ff638613760d91ef8151689f3cb4405135906472541915ebff39d165", "stableId": "task-chat-mobile-streaming-follow", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow" }, @@ -5186,15 +5191,15 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/task-chat/TaskChatSystemNotice.tsx", "ui/src/components/task-chat/task-chat-model.ts", "ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx" ], - "fingerprint": "017a102634d3eb0172abd399b115caba602466469efe5fe6292d0d69c49a4a92", + "fingerprint": "07910730b7ef3be833745377575d4abc41a61dd862428b6711031f1ca2f50eeb", "stableId": "product-task-chat-system-notice", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice" }, @@ -5208,8 +5213,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", "maps/feature-map/tasks.md", @@ -5218,7 +5223,7 @@ "ui/storybook/fixtures/unansweredQuestionChat.tsx", "ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx" ], - "fingerprint": "57a0668631cdab3b46f20a1144b367117307fa788114ee3a231e41c9db745dbe", + "fingerprint": "4fbe4667a042a8fcd8f96d7041d207bc115c7d79a8988196bc6bf18ecda456da", "stableId": "chat-comments-task-chat-unanswered-questions", "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions" }, @@ -5231,8 +5236,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/EmailMessageCard.tsx", @@ -5240,7 +5245,7 @@ "ui/src/components/chat/ExternallyConnectedTaskBanner.tsx", "ui/storybook/stories/tasks/task-context-notices.stories.tsx" ], - "fingerprint": "87bfd426fdddcc330f0850b036102dd3f1f023e4c0df26f0519dce43cadd1901", + "fingerprint": "737d40d84d6a42dda3366e41f9d6a829219e424c6e770d7e6bb59f2b530b2bfe", "stableId": "tasks-context-notices", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Context notices" }, @@ -5253,8 +5258,8 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/steering.md", "ui/src/components/TaskTreeControls.tsx", @@ -5263,7 +5268,7 @@ "ui/src/components/task-chat/TaskChatMarker.tsx", "ui/storybook/stories/steering/task-execution-controls.stories.tsx" ], - "fingerprint": "c0d5b4c251fbdb83e15c7b4dcbffe3da402672c88145053bade66facf45d5fbe", + "fingerprint": "8ac1afb1d07856297cf68417d309a9361a719eacceb9162ed54c86304e3c4634", "stableId": "composer-execution-controls", "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Execution controls" }, @@ -5340,15 +5345,15 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage.", - "reviewedAt": "2026-10-12", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "4ff06d0f3bec2c8f318f58463e671fd91e279d8677c118b124b49273c63c72df", + "fingerprint": "4e6ed831f9d6049b77a6bfa29da2bf60623f33d70a591f64a4f5c15b3fc38e11", "stableId": "chat-comments-human-identity-00-review-guide", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide" }, @@ -5361,8 +5366,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/task-chat/TaskChatBubble.tsx", @@ -5372,7 +5377,7 @@ "ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "15e8035278a39e51ececfae5601e57dae8046e24aee5ed04949bdc219c9250c0", + "fingerprint": "ba528b7605db60c2b739862d5f80cbdc1b4379c77587af41dc040fd1015d30f8", "stableId": "chat-comments-human-identity-01-components", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components" }, @@ -5385,8 +5390,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/Layout.tsx", @@ -5395,7 +5400,7 @@ "ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "167c4f2d1cfb9c7f0487f8197908342b889d89bb6a0e752b1aac3a3c9ca6d132", + "fingerprint": "462c55f151c3b297851e351615f0f37afb87b93dd32caa6dfbb128289354f130", "stableId": "chat-comments-human-identity-02-mixed-author-journey", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey" }, @@ -5520,8 +5525,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/Layout.tsx", @@ -5532,7 +5537,7 @@ "ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx", "ui/storybook/support/workspace-branch-recovery/fixtures.ts" ], - "fingerprint": "d34d8092f10377b2280f9511205b5eae76b5dbfd9cde002669eefc51aa5d16d6", + "fingerprint": "5eedfcb5738c125d3fa8ed7dc4b3d20a300b216de51f1ae5e58ede98d38fd75c", "stableId": "workspace-branch-recovery-02-task-journey", "sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/02 Task journey" }, diff --git a/scripts/storybook-catalog.mjs b/scripts/storybook-catalog.mjs index 41d777b4dd9..bba57300f71 100644 --- a/scripts/storybook-catalog.mjs +++ b/scripts/storybook-catalog.mjs @@ -4,6 +4,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { featureIndex, reconcileFeatureArtifacts, sidebarLabel } from "./storybook-feature-index.mjs"; const scriptPath = fileURLToPath(import.meta.url); const defaultRoot = path.resolve(path.dirname(scriptPath), ".."); @@ -41,14 +42,7 @@ export function capabilities(root) { } export function featureNavigation(root) { - const navigation = new Map(); - let section; - for (const line of readFileSync(path.join(root, "maps/feature-map/README.md"), "utf8").split("\n")) { - if (line.startsWith("### ")) section = line.slice(4); - const match = line.match(/^\| \[([^\]]+)\]\(\.\/([^.)]+)\.md\)/); - if (match && section) navigation.set(match[2], `${section}/${match[1]}`); - } - return navigation; + return new Map(featureIndex(root).sections.flatMap(section => section.features.map(feature => [feature.id, `${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}`]))); } export function scanStories(root = defaultRoot) { @@ -99,14 +93,14 @@ export function validateCatalog(root, catalog, stories) { review(label, entry.sources, entry.fingerprint); } for (const story of stories) if (!seen.has(story.file)) errors.push(`${story.file}: story missing from catalog`); - const ids = stories.flatMap(story => story.ids); + const ids = [...stories.flatMap(story => story.ids), ...featureIndex(root).sections.flatMap(section => section.features.map(feature => `feature-map-${feature.id}--overview`))]; if (ids.length !== new Set(ids).size) errors.push("Duplicate story IDs"); return errors; } function cell(value) { return String(value).replaceAll("|", "\\|").replaceAll("\n", " "); } -export function renderIndex(catalog, stories, refs) { +export function renderIndex(catalog, stories, index) { const byFile = new Map(stories.map(story => [story.file, story])); const groups = new Map(); for (const entry of catalog.entries) { @@ -118,8 +112,19 @@ export function renderIndex(catalog, stories, refs) { "[Contract and maintenance](README.md) · [Review findings](REVIEW.md)", "", `${stories.length} story files; ${stories.reduce((n, story) => n + story.ids.length, 0)} indexed stories. Counts describe inventory, not passed tests or complete feature coverage.`, "", "Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file.", ""]; - for (const [area, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`## ${area}`, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []), + const homes = index.sections.flatMap(section => section.features.map(feature => ({ ...feature, section: section.title }))); + homes.push({ id: "foundations-and-guides", title: "Shared foundations and guides", section: "Foundations", description: "Shared components and contributor guidance outside the product feature index." }); + let currentSection; + for (const feature of homes) { + const area = feature.id; + const entries = groups.get(area) ?? []; + if (feature.section !== currentSection) { + lines.push(`## ${feature.section}`, ""); + currentSection = feature.section; + } + lines.push(`### ${feature.title}`, "", feature.description, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : [])); + if (!entries.length) lines.push("No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature.", ""); + else lines.push( "| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |", "| --- | --- | --- | --- | --- |"); for (const entry of entries.sort((a, b) => a.file.localeCompare(b.file))) { @@ -129,24 +134,31 @@ export function renderIndex(catalog, stories, refs) { lines.push(""); } lines.push("## Capability lookup", "", "References are navigation links, not proof of parity. An absent link means no cataloged story, not a missing product feature. CLI/operator capabilities may need no Storybook example.", ""); - for (const ref of refs.keys()) { + for (const ref of index.sections.flatMap(section => section.features.flatMap(feature => feature.capabilities.map(capability => capability.id)))) { const entries = catalog.entries.filter(entry => entry.capabilities.includes(ref)); lines.push(`- \`${ref}\`: ${entries.length ? entries.map(entry => `[${cell(byFile.get(entry.file).title)}](../../${entry.file}) (${entry.status})`).join(", ") : "No cataloged story."}`); } return lines.join("\n") + "\n"; } +export function reconcileCatalog(root, catalog, stories, write = false) { + const index = featureIndex(root); + const errors = reconcileFeatureArtifacts(root, index, write); + errors.push(...validateCatalog(root, catalog, stories)); + if (!errors.length) { + const generated = renderIndex(catalog, stories, index); + if (write) writeFileSync(path.join(root, indexPath), generated); + else if (!existsSync(path.join(root, indexPath)) || readFileSync(path.join(root, indexPath), "utf8") !== generated) errors.push("Generated INDEX.md is stale; run with --write after reviewing catalog changes"); + } + return errors; +} + if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { const args = process.argv.slice(2); if (args.some(arg => arg !== "--write")) throw new Error("Usage: node scripts/storybook-catalog.mjs [--write]"); const catalog = JSON.parse(readFileSync(path.join(defaultRoot, catalogPath), "utf8")); const stories = scanStories(); - const errors = validateCatalog(defaultRoot, catalog, stories); - if (!errors.length) { - const generated = renderIndex(catalog, stories, capabilities(defaultRoot)); - if (args.includes("--write")) writeFileSync(path.join(defaultRoot, indexPath), generated); - else if (!existsSync(path.join(defaultRoot, indexPath)) || readFileSync(path.join(defaultRoot, indexPath), "utf8") !== generated) errors.push("Generated INDEX.md is stale; run with --write after reviewing catalog changes"); - } + const errors = reconcileCatalog(defaultRoot, catalog, stories, args.includes("--write")); if (errors.length) { errors.forEach(error => console.error(error)); process.exitCode = 1; } else console.log(`Storybook catalog aligned: ${stories.length} files, ${stories.reduce((n, story) => n + story.ids.length, 0)} stories. Structure and review fingerprints only; no runtime proof.`); } diff --git a/scripts/storybook-catalog.test.mjs b/scripts/storybook-catalog.test.mjs index 8a2d5f2a086..2dddd2d07e0 100644 --- a/scripts/storybook-catalog.test.mjs +++ b/scripts/storybook-catalog.test.mjs @@ -4,7 +4,8 @@ import os from "node:os"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { capabilities, fingerprint, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs"; +import { fingerprint, reconcileCatalog, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs"; +import { featureIndex, reconcileFeatureArtifacts } from "./storybook-feature-index.mjs"; function fixture(t) { const root = mkdtempSync(path.join(os.tmpdir(), "storybook-catalog-")); @@ -99,7 +100,7 @@ test("missing and escaping source references cannot certify a review", t => { test("cross-capability lookup retains one canonical file and honest status", t => { const f = fixture(t); f.catalog.entries[0].status = "historical"; - const index = renderIndex(f.catalog, f.stories, capabilities(f.root)); + const index = renderIndex(f.catalog, f.stories, featureIndex(f.root)); assert.match(index, /tasks\/create.*\(historical\)/); assert.match(index, /tasks\/discussion.*\(historical\)/); assert.match(index, /1 story files; 1 indexed stories/); @@ -112,6 +113,46 @@ test("duplicate story IDs cannot silently collapse distinct examples", t => { assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/); }); +test("feature index keeps empty homes and cross-feature references in map order", t => { + const f = fixture(t); + f.write("maps/feature-map/README.md", "### Work\n| [Goals](./goals.md) | Align work. |\n| [Tasks](./tasks.md) | Create work. |\n"); + f.write("maps/feature-map/goals.md", "## Sub-features\n- `hierarchy`: Link goals.\n## Gotchas\n"); + f.catalog.entries[0].capabilities.push("goals/hierarchy"); + const index = renderIndex(f.catalog, f.stories, featureIndex(f.root)); + assert.ok(index.indexOf("### Goals") < index.indexOf("### Tasks")); + assert.match(index, /### Goals[\s\S]*?No primary story suite/); + assert.match(index, /`goals\/hierarchy`: \[Tasks and conversations\/Tasks\/Create\]/); +}); + +test("authored stories cannot collide with generated overview IDs", t => { + const f = fixture(t); + f.stories[0].ids = ["feature-map-tasks--overview"]; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/); +}); + +test("regeneration reports review drift from its own writes without refreshing fingerprints", t => { + const f = fixture(t); + f.write("ui/storybook/.storybook/preview.tsx", "export default { parameters: { options: { storySort: { order: [],\n}, } } };\n"); + reconcileFeatureArtifacts(f.root, featureIndex(f.root), true); + f.write("maps/storybook-map/INDEX.md", ""); + const generatedSource = "ui/storybook/generated/feature-index.json"; + f.catalog.sharedReview.sources.push(generatedSource); + f.catalog.entries[0].sources.push(generatedSource); + f.catalog.sharedReview.fingerprint = fingerprint(f.root, f.catalog.sharedReview.sources); + f.catalog.entries[0].fingerprint = fingerprint(f.root, f.catalog.entries[0].sources); + assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories, true), []); + const reviewed = f.catalog.sharedReview.fingerprint; + f.write("maps/feature-map/tasks.md", "## Sub-features\n- `create`: Updated description.\n- `discussion`: discuss\n## Gotchas\n"); + const errors = reconcileCatalog(f.root, f.catalog, f.stories, true); + assert.equal(errors.filter(error => error.includes("source changed; review")).length, 2); + assert.equal(f.catalog.sharedReview.fingerprint, reviewed); + assert.notEqual(fingerprint(f.root, f.catalog.sharedReview.sources), reviewed); + f.catalog.sharedReview.fingerprint = fingerprint(f.root, f.catalog.sharedReview.sources); + f.catalog.entries[0].fingerprint = fingerprint(f.root, f.catalog.entries[0].sources); + assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories, true), []); + assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories), []); +}); + test("Storybook parser discovers nested files, filters helpers, preserves explicit IDs and counts play functions", t => { const f = fixture(t); f.write("ui/package.json", '{"type":"module"}'); diff --git a/scripts/storybook-feature-index.mjs b/scripts/storybook-feature-index.mjs new file mode 100644 index 00000000000..5dd7cf0afa9 --- /dev/null +++ b/scripts/storybook-feature-index.mjs @@ -0,0 +1,126 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const generatedRoot = "ui/storybook/generated"; +export const sidebarLabel = title => title.replaceAll("/", ", "); + +export function featureIndex(root) { + const sections = []; + let section; + const seen = new Set(); + const source = readFileSync(path.join(root, "maps/feature-map/README.md"), "utf8"); + for (const line of source.split("\n")) { + if (line.startsWith("### ")) { + section = { title: line.slice(4), features: [] }; + sections.push(section); + } + const match = line.match(/^\| \[([^\]]+)\]\(\.\/([a-z0-9-]+)\.md\) \| (.*) \|$/); + if (!match) { + if (line.startsWith("| [")) throw new Error(`Unrecognized feature-map row: ${line}`); + continue; + } + if (!section || seen.has(match[2])) throw new Error(`Invalid feature-map entry: ${line}`); + const [, title, id, description] = match; + seen.add(id); + const recipe = readFileSync(path.join(root, `maps/feature-map/${id}.md`), "utf8"); + const body = recipe.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0]; + if (!body) throw new Error(`Missing sub-features: ${id}`); + const capabilities = []; + for (const row of body.trim().split("\n")) { + const capability = row.match(/^- `([^`]+)`:\s*(.*)$/); + if (capability) { + const [, key, text] = capability; + capabilities.push({ id: `${id}/${key}`, label: key.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase()), description: text }); + } else if (row.trim() && capabilities.length) { + capabilities.at(-1).description += ` ${row.trim()}`; + } + } + if (!capabilities.length || new Set(capabilities.map(item => item.id)).size !== capabilities.length) throw new Error(`Invalid sub-features: ${id}`); + section.features.push({ id, title, description, capabilities }); + } + const populated = sections.filter(item => item.features.length); + if (!populated.length) throw new Error("Feature-map index has no feature families"); + for (const file of readdirSync(path.join(root, "maps/feature-map"))) { + if (file.endsWith(".md") && file !== "README.md" && !seen.has(file.slice(0, -3))) throw new Error(`Feature recipe missing from index: ${file}`); + } + return { sections: populated }; +} + +export function featureOrder(index) { + return ["Overview", ...index.sections.flatMap(section => [sidebarLabel(section.title), + section.features.flatMap(feature => [sidebarLabel(feature.title), ["Overview", "*"]]), + ]), "Foundations"]; +} + +export function featureArtifacts(index) { + const outputs = new Map([ + [`${generatedRoot}/feature-index.json`, JSON.stringify(index, null, 2) + "\n"], + ]); + for (const section of index.sections) { + for (const feature of section.features) { + outputs.set(`${generatedRoot}/features/${feature.id}.stories.tsx`, [ + 'import type { Meta, StoryObj } from "@storybook/react-vite";', + 'import { FeatureOverview } from "../../support/feature-map/FeatureOverview";', + "", + "const meta = {", + ` id: ${JSON.stringify(`feature-map-${feature.id}`)},`, + ` title: ${JSON.stringify(`${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}/Overview`)},`, + " component: FeatureOverview,", + ` args: { area: ${JSON.stringify(feature.id)} },`, + ' tags: ["!autodocs"],', + ' parameters: { layout: "fullscreen" },', + "} satisfies Meta;", + "export default meta;", + "export const Overview: StoryObj = {};", + "", + ].join("\n")); + } + } + return outputs; +} + +export function reconcileFeatureArtifacts(root, index, write = false) { + const outputs = featureArtifacts(index); + const errors = []; + function inspect(directory) { + if (!existsSync(path.join(root, directory))) return; + for (const entry of readdirSync(path.join(root, directory), { withFileTypes: true })) { + const file = `${directory}/${entry.name}`; + if (entry.isDirectory()) inspect(file); + else if (!outputs.has(file)) errors.push(`Unexpected generated feature file: ${file}`); + } + } + inspect(generatedRoot); + if (errors.length) return errors; + for (const [file, content] of outputs) { + const destination = path.join(root, file); + if (write) { + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, content); + } else if (!existsSync(destination) || readFileSync(destination, "utf8") !== content) { + errors.push(`${file}: generated feature index is stale; run storybook-catalog.mjs --write`); + } + } + const previewFile = "ui/storybook/.storybook/preview.tsx"; + const previewPath = path.join(root, previewFile); + const preview = readFileSync(previewPath, "utf8"); + const pattern = /(\bstorySort:\s*\{\s*order:\s*)([\s\S]*?)(,\s*\n\s*\},)/g; + if ([...preview.matchAll(pattern)].length !== 1) throw new Error("Expected one inline storySort.order in preview.tsx"); + const order = JSON.stringify(featureOrder(index), null, 2) + .replace(/("[^"]+",)\n\s+\[\n\s+"Overview",\n\s+"\*"\n\s+\]/g, '$1 ["Overview", "*"]') + .replaceAll("\n", "\n "); + const updated = preview.replace(pattern, (_, before, value, after) => before + order + after); + if (write) writeFileSync(previewPath, updated); + else if (updated !== preview) errors.push(`${previewFile}: generated sidebar order is stale; run storybook-catalog.mjs --write`); + return errors; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = fileURLToPath(new URL("..", import.meta.url)); + const errors = reconcileFeatureArtifacts(root, featureIndex(root)); + if (errors.length) { + errors.forEach(error => console.error(error)); + process.exitCode = 1; + } +} diff --git a/scripts/storybook-feature-index.test.mjs b/scripts/storybook-feature-index.test.mjs new file mode 100644 index 00000000000..dd345a0d694 --- /dev/null +++ b/scripts/storybook-feature-index.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { featureArtifacts, featureIndex, featureOrder, reconcileFeatureArtifacts } from "./storybook-feature-index.mjs"; + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "storybook-feature-index-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const write = (file, text) => { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + }; + write("maps/feature-map/README.md", "# Map\n### Work\n| [Tasks](./tasks.md) | Create and follow work. |\n| [Goals](./goals.md) | Align work. |\n### Connections\n| [Apps](./apps.md) | Connect tools. |\n"); + write("maps/feature-map/tasks.md", "# Tasks\n## Sub-features\n- `create`: Create work;\n retain the initial description.\n- `review`: Review it.\n## How to get to it\nNot a capability.\n"); + write("maps/feature-map/goals.md", "# Goals\n## Sub-features\n- `hierarchy`: Link goals.\n## Gotchas\n"); + write("maps/feature-map/apps.md", "# Apps\n## Sub-features\n- `connect`: Connect an app.\n## Gotchas\n"); + write("ui/storybook/.storybook/preview.tsx", "export default { parameters: { options: { storySort: { order: [],\n}, unrelated: true } } };\n"); + return { root, write }; +} + +test("feature index preserves recipe names, map order, and multiline capability descriptions", t => { + const { root } = fixture(t); + const index = featureIndex(root); + assert.deepEqual(index.sections.map(section => section.title), ["Work", "Connections"]); + assert.deepEqual(index.sections.flatMap(section => section.features.map(feature => feature.id)), ["tasks", "goals", "apps"]); + assert.deepEqual(index.sections[0].features[0].capabilities, [ + { id: "tasks/create", label: "Create", description: "Create work; retain the initial description." }, + { id: "tasks/review", label: "Review", description: "Review it." }, + ]); + assert.deepEqual(featureOrder(index), ["Overview", "Work", ["Tasks", ["Overview", "*"], "Goals", ["Overview", "*"]], "Connections", ["Apps", ["Overview", "*"]], "Foundations"]); +}); + +test("every feature gets a stable overview, including features with no authored stories", t => { + const { root } = fixture(t); + const index = featureIndex(root); + const outputs = featureArtifacts(index); + const file = "ui/storybook/generated/features/goals.stories.tsx"; + assert.match(outputs.get(file), /id: "feature-map-goals"/); + assert.match(outputs.get(file), /title: "Work\/Goals\/Overview"/); + index.sections[0].features[1].title = "Work alignment"; + assert.match(featureArtifacts(index).get(file), /id: "feature-map-goals"/); + assert.match(featureArtifacts(index).get(file), /title: "Work\/Work alignment\/Overview"/); + index.sections[0].features[1].title = "CLI/API and local worktrees"; + assert.match(featureArtifacts(index).get(file), /title: "Work\/CLI, API and local worktrees\/Overview"/); + assert.ok(featureOrder(index)[2].includes("CLI, API and local worktrees")); +}); + +test("generated guides detect upstream drift, local edits, missing files, and unknown files", t => { + const { root, write } = fixture(t); + const index = featureIndex(root); + assert.equal(reconcileFeatureArtifacts(root, index).length, 5); + assert.deepEqual(reconcileFeatureArtifacts(root, index, true), []); + assert.deepEqual(reconcileFeatureArtifacts(root, index), []); + write("ui/storybook/generated/features/goals.stories.tsx", "export default {};"); + assert.match(reconcileFeatureArtifacts(root, index).join("\n"), /goals.stories.tsx: generated feature index is stale/); + reconcileFeatureArtifacts(root, index, true); + index.sections.reverse(); + assert.match(reconcileFeatureArtifacts(root, index).join("\n"), /preview.tsx: generated sidebar order is stale/); + assert.match(readFileSync(path.join(root, "ui/storybook/.storybook/preview.tsx"), "utf8"), /unrelated: true/); + write("ui/storybook/generated/features/orphan.stories.tsx", "keep this visible"); + assert.match(reconcileFeatureArtifacts(root, index, true).join("\n"), /Unexpected generated feature file/); + assert.equal(readFileSync(path.join(root, "ui/storybook/generated/features/orphan.stories.tsx"), "utf8"), "keep this visible"); +}); + +test("malformed or omitted map entries fail instead of silently hiding a feature", t => { + const { root, write } = fixture(t); + write("maps/feature-map/README.md", "### Work\n| [Tasks](./tasks.md) | Tasks |\n"); + assert.throws(() => featureIndex(root), /missing from index/); + write("maps/feature-map/README.md", "### Work\n| [Tasks](tasks.md) | Tasks |\n"); + assert.throws(() => featureIndex(root), /Unrecognized feature-map row/); +}); diff --git a/ui/package.json b/ui/package.json index 8ded900de4a..f59ad09df61 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,8 +17,8 @@ "dev": "vite", "build": "vite build", "build:preview": "vite build --config vite.preview.config.mjs", - "storybook": "storybook dev -p 6006 -c storybook/.storybook", - "build-storybook": "storybook build -c storybook/.storybook -o storybook-static", + "storybook": "node ../scripts/storybook-feature-index.mjs && storybook dev -p 6006 -c storybook/.storybook", + "build-storybook": "node ../scripts/storybook-feature-index.mjs && storybook build -c storybook/.storybook -o storybook-static", "preview": "vite preview", "typecheck": "tsc -b", "clean": "rm -rf dist dist-preview storybook-static tsconfig.tsbuildinfo", diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index 7f8da855382..833fdb586e2 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -16,7 +16,7 @@ const paperclipInstanceOrigin = (() => { })(); const config: StorybookConfig = { - stories: ["../stories/**/*.stories.@(ts|tsx|mdx)"], + stories: ["../stories/**/*.stories.@(ts|tsx|mdx)", "../generated/features/*.stories.tsx"], staticDirs: ["../../public", "../public"], addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], framework: { diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index bfd3349cf97..ecdfd5fdda9 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -856,62 +856,62 @@ const preview: Preview = { "Overview", "Getting started and access", [ - "Onboarding and first work", - "Login, invitations, and access", - "Companies and portability" + "Onboarding and first work", ["Overview", "*"], + "Login, invitations, and access", ["Overview", "*"], + "Companies and portability", ["Overview", "*"] ], "Tasks and conversations", [ - "Task creation and lifecycle", - "Delegation, dependencies, and signoff", - "Inbox, decisions, and search", - "Agent conversations and project handoff", - "Questions and approvals", - "Steering and queued messages", - "Documents, attachments, and work products" + "Task creation and lifecycle", ["Overview", "*"], + "Delegation, dependencies, and signoff", ["Overview", "*"], + "Inbox, decisions, and search", ["Overview", "*"], + "Agent conversations and project handoff", ["Overview", "*"], + "Questions and approvals", ["Overview", "*"], + "Steering and queued messages", ["Overview", "*"], + "Documents, attachments, and work products", ["Overview", "*"] ], "Agents and reusable capabilities", [ - "Hiring, configuration, and organization", - "Runs, harnesses, and model accounts", - "Skills and Skill Studio", - "Team packages and installation" + "Hiring, configuration, and organization", ["Overview", "*"], + "Runs, harnesses, and model accounts", ["Overview", "*"], + "Skills and Skill Studio", ["Overview", "*"], + "Team packages and installation", ["Overview", "*"] ], "Projects and execution", [ - "Projects and repositories", - "Goals and work alignment", - "Workspaces, services, and files", - "Execution environments", - "Recovery" + "Projects and repositories", ["Overview", "*"], + "Goals and work alignment", ["Overview", "*"], + "Workspaces, services, and files", ["Overview", "*"], + "Execution environments", ["Overview", "*"], + "Recovery", ["Overview", "*"] ], "Apps, channels, and extensions", [ - "Connection setup", - "App access and action permissions", - "External chat and email", - "Tool gateways and access profiles", - "Secrets and proposals", - "Plugins" + "Connection setup", ["Overview", "*"], + "App access and action permissions", ["Overview", "*"], + "External chat and email", ["Overview", "*"], + "Tool gateways and access profiles", ["Overview", "*"], + "Secrets and proposals", ["Overview", "*"], + "Plugins", ["Overview", "*"] ], "Automation and structured work", [ - "Routines, schedules, and triggers", - "Pipelines, review queues, and learnings", - "Cases", - "Status cards" + "Routines, schedules, and triggers", ["Overview", "*"], + "Pipelines, review queues, and learnings", ["Overview", "*"], + "Cases", ["Overview", "*"], + "Status cards", ["Overview", "*"] ], "Oversight and operation", [ - "Costs and budgets", - "Dashboards and audit trails", - "Navigation, profile, and announcements", - "Instance operations", - "CLI/API and local worktrees" + "Costs and budgets", ["Overview", "*"], + "Dashboards and audit trails", ["Overview", "*"], + "Navigation, profile, and announcements", ["Overview", "*"], + "Instance operations", ["Overview", "*"], + "CLI, API and local worktrees", ["Overview", "*"] ], "Contributor surfaces", [ - "Developer previews and diagnostic labs" + "Developer previews and diagnostic labs", ["Overview", "*"] ], "Foundations" ], diff --git a/ui/storybook/generated/feature-index.json b/ui/storybook/generated/feature-index.json new file mode 100644 index 00000000000..2ba715c3dff --- /dev/null +++ b/ui/storybook/generated/feature-index.json @@ -0,0 +1,1144 @@ +{ + "sections": [ + { + "title": "Getting started and access", + "features": [ + { + "id": "onboarding", + "title": "Onboarding and first work", + "description": "Instance setup, company wizard, first agent and task.", + "capabilities": [ + { + "id": "onboarding/instance", + "label": "Instance", + "description": "choose local instance configuration and start the server." + }, + { + "id": "onboarding/company", + "label": "Company", + "description": "create a company with its identity and initial agent." + }, + { + "id": "onboarding/adapter", + "label": "Adapter", + "description": "choose an available harness, model, account, and execution environment." + }, + { + "id": "onboarding/first-task", + "label": "First task", + "description": "leave setup with a task whose execution or missing prerequisite is visible." + }, + { + "id": "onboarding/resume", + "label": "Resume", + "description": "return to an incomplete wizard without losing saved state." + } + ] + }, + { + "id": "access", + "title": "Login, invitations, and access", + "description": "Sessions, bootstrap, membership, roles, and CLI authorization.", + "capabilities": [ + { + "id": "access/login", + "label": "Login", + "description": "sign in or claim the board in the configured deployment mode." + }, + { + "id": "access/invitations", + "label": "Invitations", + "description": "join through a valid invite and handle expired or already-used links." + }, + { + "id": "access/membership", + "label": "Membership", + "description": "review join requests and manage company/instance membership and permissions." + }, + { + "id": "access/resources", + "label": "Resources", + "description": "respect company and resource boundaries on deep links and mutations." + }, + { + "id": "access/cli-auth", + "label": "Cli auth", + "description": "authorize a CLI session and revoke or replace credentials." + } + ] + }, + { + "id": "companies", + "title": "Companies and portability", + "description": "Company switching/settings, archival, package import/export.", + "capabilities": [ + { + "id": "companies/selection", + "label": "Selection", + "description": "create/select a company and keep navigation in its scope." + }, + { + "id": "companies/identity", + "label": "Identity", + "description": "update company identity and configured settings." + }, + { + "id": "companies/archive", + "label": "Archive", + "description": "archive and restore through the available controls without confusing archival with deletion." + }, + { + "id": "companies/portability", + "label": "Portability", + "description": "preview a company package and inspect imported agents, projects, skills, and conflicts." + } + ] + } + ] + }, + { + "title": "Tasks and conversations", + "features": [ + { + "id": "tasks", + "title": "Task creation and lifecycle", + "description": "Creation, assignment, lists, properties, comments, and completion.", + "capabilities": [ + { + "id": "tasks/create", + "label": "Create", + "description": "capture a title/request with the intended agent, project, and attachments." + }, + { + "id": "tasks/organize", + "label": "Organize", + "description": "filter, sort, group, and navigate tasks without losing the selected context." + }, + { + "id": "tasks/properties", + "label": "Properties", + "description": "change status, priority, assignee, project, and relationships where authorized." + }, + { + "id": "tasks/discussion", + "label": "Discussion", + "description": "persist comments, references, and files on the task." + }, + { + "id": "tasks/completion", + "label": "Completion", + "description": "distinguish backlog, active work, review, blocked, done, and cancelled outcomes." + } + ] + }, + { + "id": "task-coordination", + "title": "Delegation, dependencies, and signoff", + "description": "Child work, prerequisites, reviewers, and task-tree controls.", + "capabilities": [ + { + "id": "task-coordination/delegation", + "label": "Delegation", + "description": "inspect parent/child tasks and the original request behind delegated work." + }, + { + "id": "task-coordination/dependencies", + "label": "Dependencies", + "description": "show blocking tasks and wake eligible work when prerequisites resolve." + }, + { + "id": "task-coordination/signoff", + "label": "Signoff", + "description": "apply the configured implementation/review completion policy." + }, + { + "id": "task-coordination/tree-control", + "label": "Tree control", + "description": "pause or resume a task tree without bypassing its gates." + } + ] + }, + { + "id": "inbox-search", + "title": "Inbox, decisions, and search", + "description": "Personal triage, decision queues, unread/blocked views, and search.", + "capabilities": [ + { + "id": "inbox-search/views", + "label": "Views", + "description": "use Mine, Recent, Unread, Blocked, and broader task views." + }, + { + "id": "inbox-search/triage", + "label": "Triage", + "description": "read or archive attention items without pretending the underlying task is complete." + }, + { + "id": "inbox-search/decisions", + "label": "Decisions", + "description": "open the source of a decision and act in the right context." + }, + { + "id": "inbox-search/search", + "label": "Search", + "description": "query company work and follow a result to its original record." + } + ] + }, + { + "id": "agent-chat", + "title": "Agent conversations and project handoff", + "description": "Persistent conversations, discovery, handoff, and gated board chat.", + "capabilities": [ + { + "id": "agent-chat/discovery", + "label": "Discovery", + "description": "find an agent and reopen an accessible recent conversation." + }, + { + "id": "agent-chat/history", + "label": "History", + "description": "send messages, inspect replies/files, and return to the same conversation." + }, + { + "id": "agent-chat/handoff", + "label": "Handoff", + "description": "create or identify a project/task from chat and follow the resulting work." + }, + { + "id": "agent-chat/conference-room", + "label": "Conference room", + "description": "use the separately gated board chat without assuming parity with agent chat." + } + ] + }, + { + "id": "questions-and-approvals", + "title": "Questions and approvals", + "description": "Question/plan responses, formal approvals, and app-tool review.", + "capabilities": [ + { + "id": "questions-and-approvals/questions", + "label": "Questions", + "description": "single-choice, multiple-choice, custom, and free-text answers; paged forms retain answers until submission and show validation failures." + }, + { + "id": "questions-and-approvals/dismiss-and-reopen", + "label": "Dismiss and reopen", + "description": "unanswered questions remain in the feed after dismissal or a newer message; reopening restores the form without inventing an answer." + }, + { + "id": "questions-and-approvals/confirmations", + "label": "Confirmations", + "description": "accept, decline, or request changes; stale, expired, resolved, and withdrawn requests cannot be acted on again." + }, + { + "id": "questions-and-approvals/plan-review", + "label": "Plan review", + "description": "inspect the requested plan revision before accepting or asking for changes. An ordinary planning task can proceed to execution; Agent Chat hands work to linked tasks and remains a conversation." + }, + { + "id": "questions-and-approvals/audience", + "label": "Audience", + "description": "the current actor, company, named audience, and review policy govern resolution. A visible request is not necessarily actionable by that viewer." + }, + { + "id": "questions-and-approvals/governance", + "label": "Governance", + "description": "pending/history views, approve/reject, revision request, and resubmission for formal board approvals." + }, + { + "id": "questions-and-approvals/tool-review", + "label": "Tool review", + "description": "approve once, always allow the displayed scope, or decline; decision status and provider execution outcome remain distinct." + }, + { + "id": "questions-and-approvals/continuation", + "label": "Continuation", + "description": "one recorded response leads to the eligible waiting run or continuation; repeated clicks, reloads, and competing tabs do not repeat work." + } + ] + }, + { + "id": "steering", + "title": "Steering and queued messages", + "description": "Follow-ups, queue edits, interruption, and pause/resume.", + "capabilities": [ + { + "id": "steering/queue", + "label": "Queue", + "description": "a busy task saves follow-ups and shows their order and wait reason." + }, + { + "id": "steering/edit-order-discard", + "label": "Edit order discard", + "description": "edit full Markdown, reorder, and discard eligible saved messages; ownership, revision conflicts, and already-dispatched states matter." + }, + { + "id": "steering/steer", + "label": "Steer", + "description": "explicit user action sends a queued message into a compatible active run; unavailable capabilities and provider rejection remain visible." + }, + { + "id": "steering/interrupt", + "label": "Interrupt", + "description": "stop the current execution and carry accepted follow-ups into the eligible continuation without delivering them twice." + }, + { + "id": "steering/stop-and-pause", + "label": "Stop and pause", + "description": "distinguish stop-in-flight, confirmed stop, task/ancestor pause, agent pause, and budget hold. Resume must respect the actual gate." + }, + { + "id": "steering/drafts", + "label": "Drafts", + "description": "switching tasks, reloading, and uncertain submission retain the correct task's draft and attachment receipts." + }, + { + "id": "steering/approval-queue", + "label": "Approval queue", + "description": "answered questions and accepted approvals have durable delivery distinct from editable ordinary comments." + } + ] + }, + { + "id": "documents-artifacts", + "title": "Documents, attachments, and work products", + "description": "Versioned documents, annotations, files, outputs, and artifact library.", + "capabilities": [ + { + "id": "documents-artifacts/documents", + "label": "Documents", + "description": "edit versioned task documents and inspect or restore revisions." + }, + { + "id": "documents-artifacts/annotations", + "label": "Annotations", + "description": "attach feedback to document content and inspect resolution state." + }, + { + "id": "documents-artifacts/attachments", + "label": "Attachments", + "description": "upload, preview, and download allowed files with company access checks." + }, + { + "id": "documents-artifacts/work-products", + "label": "Work products", + "description": "link deliverables to their source task, resource, and review context." + }, + { + "id": "documents-artifacts/library", + "label": "Library", + "description": "search, filter, group, and open artifact stacks." + } + ] + } + ] + }, + { + "title": "Agents and reusable capabilities", + "features": [ + { + "id": "agents", + "title": "Hiring, configuration, and organization", + "description": "Agent identity, instructions, reporting lines, lifecycle, and built-ins.", + "capabilities": [ + { + "id": "agents/hire", + "label": "Hire", + "description": "choose identity, adapter, model, and required account settings; edit reporting relationships through the agent configuration afterward." + }, + { + "id": "agents/instructions", + "label": "Instructions", + "description": "edit agent configuration and instruction files with durable revisions." + }, + { + "id": "agents/organization", + "label": "Organization", + "description": "inspect and change reporting relationships through supported controls." + }, + { + "id": "agents/lifecycle", + "label": "Lifecycle", + "description": "pause/resume or retire an agent while preserving history." + }, + { + "id": "agents/built-in", + "label": "Built in", + "description": "configure required agents without treating them as ordinary removable hires." + } + ] + }, + { + "id": "runs-adapters", + "title": "Runs, harnesses, and model accounts", + "description": "Adapter/model setup, account validation, transcripts, and run history.", + "capabilities": [ + { + "id": "runs-adapters/availability", + "label": "Availability", + "description": "discover installed adapters and available models/accounts." + }, + { + "id": "runs-adapters/validation", + "label": "Validation", + "description": "test an adapter in the selected execution environment and surface authentication failures." + }, + { + "id": "runs-adapters/runs", + "label": "Runs", + "description": "inspect run status, events, output, costs, and the source task." + }, + { + "id": "runs-adapters/session", + "label": "Session", + "description": "preserve or reset execution context through supported lifecycle actions." + } + ] + }, + { + "id": "skills", + "title": "Skills and Skill Studio", + "description": "Discovery, sources, authoring, revisions, tests, and agent policies.", + "capabilities": [ + { + "id": "skills/discovery", + "label": "Discovery", + "description": "browse installed/discover views and import a skill or source." + }, + { + "id": "skills/authoring", + "label": "Authoring", + "description": "create/edit a writable skill or fork a read-only source." + }, + { + "id": "skills/versions", + "label": "Versions", + "description": "inspect revisions, source updates, diffs, and selected releases." + }, + { + "id": "skills/policy", + "label": "Policy", + "description": "apply agent enablement and skill usage policy independently from installation." + }, + { + "id": "skills/testing", + "label": "Testing", + "description": "run a bounded Studio test and inspect its result and interactions." + } + ] + }, + { + "id": "teams", + "title": "Team packages and installation", + "description": "Catalog preview/install via CLI/API; catalog UI availability called out.", + "capabilities": [ + { + "id": "teams/discovery", + "label": "Discovery", + "description": "list available team catalog entries and inspect package contents." + }, + { + "id": "teams/preview", + "label": "Preview", + "description": "inspect proposed resources and installation options before mutation." + }, + { + "id": "teams/install", + "label": "Install", + "description": "install into the intended company and inspect resulting agents and resources." + }, + { + "id": "teams/configuration", + "label": "Configuration", + "description": "supply required adapters/accounts and verify installed agents can work." + } + ] + } + ] + }, + { + "title": "Projects and execution", + "features": [ + { + "id": "projects", + "title": "Projects and repositories", + "description": "Project lifecycle, task context, repository configuration, and defaults.", + "capabilities": [ + { + "id": "projects/lifecycle", + "label": "Lifecycle", + "description": "create, name, organize, and archive projects." + }, + { + "id": "projects/task-context", + "label": "Task context", + "description": "inspect project tasks and create work in the selected project." + }, + { + "id": "projects/repositories", + "label": "Repositories", + "description": "configure repository/workspace sources and verify usable checkouts." + }, + { + "id": "projects/defaults", + "label": "Defaults", + "description": "maintain project instructions, environment, goal, and budget context where offered." + } + ] + }, + { + "id": "goals", + "title": "Goals and work alignment", + "description": "Goal hierarchy, ownership, status, and project/task context.", + "capabilities": [ + { + "id": "goals/hierarchy", + "label": "Hierarchy", + "description": "create and inspect goal level, parent, owner, and status." + }, + { + "id": "goals/alignment", + "label": "Alignment", + "description": "associate work with a valid goal in the same company." + }, + { + "id": "goals/navigation", + "label": "Navigation", + "description": "follow goal/project/task context to understand why work exists." + } + ] + }, + { + "id": "workspaces", + "title": "Workspaces, services, and files", + "description": "Provisioning, task bindings, services/logs, Git/files, and closure.", + "capabilities": [ + { + "id": "workspaces/binding", + "label": "Binding", + "description": "identify the workspace actually bound to a task/run." + }, + { + "id": "workspaces/provisioning", + "label": "Provisioning", + "description": "observe readiness and failures instead of inferring readiness from a created record." + }, + { + "id": "workspaces/services", + "label": "Services", + "description": "start/stop supported services and inspect logs and exposed endpoints." + }, + { + "id": "workspaces/files-git", + "label": "Files git", + "description": "inspect workspace files and repository state used for the deliverable." + }, + { + "id": "workspaces/closure", + "label": "Closure", + "description": "close/reopen or reconcile through permitted controls without losing work." + } + ] + }, + { + "id": "execution-environments", + "title": "Execution environments", + "description": "Local, SSH, sandbox providers, target probes, and custom images.", + "capabilities": [ + { + "id": "execution-environments/configuration", + "label": "Configuration", + "description": "inspect local environments and configure available SSH/sandbox targets." + }, + { + "id": "execution-environments/capabilities", + "label": "Capabilities", + "description": "select only drivers/providers that advertise the required execution capability." + }, + { + "id": "execution-environments/probe", + "label": "Probe", + "description": "test reachability and runtime prerequisites in the actual target." + }, + { + "id": "execution-environments/assignment", + "label": "Assignment", + "description": "bind the intended agent/project/workspace to its execution target." + }, + { + "id": "execution-environments/images", + "label": "Images", + "description": "inspect supported sandbox custom-image preparation and terminal workflows." + } + ] + }, + { + "id": "recovery", + "title": "Recovery", + "description": "Stopped work, workspace repair, bounded continuation, and reconnect.", + "capabilities": [ + { + "id": "recovery/diagnosis", + "label": "Diagnosis", + "description": "a task/run exposes the recorded cause, evidence, owner, attempt progress, and next action without requiring a raw-log interpretation." + }, + { + "id": "recovery/bounded-continuation", + "label": "Bounded continuation", + "description": "supported interrupted work continues with preserved conversation context and bounded attempts, without blindly replaying tools." + }, + { + "id": "recovery/source-action", + "label": "Source action", + "description": "source-scoped recovery actions remain attached to the task; independent repair work may have its own issue and return owner." + }, + { + "id": "recovery/exhaustion", + "label": "Exhaustion", + "description": "exhausted or overdue recovery exposes the current gate and permitted operator action; it does not claim an automatic path is still live." + }, + { + "id": "recovery/workspace", + "label": "Workspace", + "description": "workspace divergence/export failures show the applicable repair, reconcile, or isolated reissue action with evidence and authority checks." + }, + { + "id": "recovery/unknown-outcome", + "label": "Unknown outcome", + "description": "uncertain external side effects require inspection and an explicit decision, not automatic replay." + }, + { + "id": "recovery/user-continuation", + "label": "User continuation", + "description": "saved user messages after a stop are reconsidered once cleanup proves the old execution is stopped; duplicate wakes are avoided." + }, + { + "id": "recovery/reconnect", + "label": "Reconnect", + "description": "browser stream recovery refreshes persisted state without claiming the agent itself restarted or that a failed run succeeded." + } + ] + } + ] + }, + { + "title": "Apps, channels, and extensions", + "features": [ + { + "id": "connection-setup", + "title": "Connection setup", + "description": "Catalog, MCP links, task requests, authentication, and setup resumption.", + "capabilities": [ + { + "id": "connection-setup/catalog", + "label": "Catalog", + "description": "find a provider, inspect its available methods, and open setup; unavailable methods explain their prerequisite rather than silently switching." + }, + { + "id": "connection-setup/identity-and-access", + "label": "Identity and access", + "description": "select personal/shared identity and eligible agents; an empty explicit selection and company-wide access must not be confused." + }, + { + "id": "connection-setup/credentials", + "label": "Credentials", + "description": "provider sign-in, key, or generic MCP URL follows its supported setup method; credentials are not retained in browser draft storage." + }, + { + "id": "connection-setup/handoff", + "label": "Handoff", + "description": "provider popup/full-page callback returns to the correct setup host with the original task intent and identity choice intact." + }, + { + "id": "connection-setup/draft-recovery", + "label": "Draft recovery", + "description": "cancel, Back, Save & exit, reload, Finish setup, and reconnect preserve the exact draft rather than creating duplicate connections." + }, + { + "id": "connection-setup/task-request", + "label": "Task request", + "description": "only the addressed person resolves the requested connection; usable access releases the pending task request through normal continuation." + }, + { + "id": "connection-setup/channel-setup", + "label": "Channel setup", + "description": "provider endpoint, represented agent, linked sender, and communication instructions are configured before an external message is used." + }, + { + "id": "connection-setup/failure", + "label": "Failure", + "description": "denied login, unreachable/private URL, expired credentials, and permission errors leave an actionable state rather than a false success." + } + ] + }, + { + "id": "app-permissions", + "title": "App access and action permissions", + "description": "Agent grants, off/ask/allowed actions, discovery, tests, and revocation.", + "capabilities": [ + { + "id": "app-permissions/agent-access", + "label": "Agent access", + "description": "grant the connection to all or selected eligible agents." + }, + { + "id": "app-permissions/action-policy", + "label": "Action policy", + "description": "set off/ask/allowed independently for available actions." + }, + { + "id": "app-permissions/catalog-change", + "label": "Catalog change", + "description": "review newly discovered/quarantined actions before enabling them." + }, + { + "id": "app-permissions/test-action", + "label": "Test action", + "description": "choose an actor and inputs and inspect a real action result." + }, + { + "id": "app-permissions/account-maintenance", + "label": "Account maintenance", + "description": "inspect accounts, reconnect/revoke, and review connection activity." + } + ] + }, + { + "id": "chat-channels", + "title": "External chat and email", + "description": "Provider-specific identity, threads, files, delivery, and endpoint upkeep.", + "capabilities": [ + { + "id": "chat-channels/providers", + "label": "Providers", + "description": "configure the available Slack, Discord, Microsoft Teams, Telegram, GitHub, AgentMail, and experimental iMessage Photon paths; availability still depends on gates and provider support." + }, + { + "id": "chat-channels/identity", + "label": "Identity", + "description": "link the external sender to the intended authorized Paperclip identity." + }, + { + "id": "chat-channels/conversation", + "label": "Conversation", + "description": "route inbound messages to the selected agent and send replies to the correct thread." + }, + { + "id": "chat-channels/attachments", + "label": "Attachments", + "description": "transfer supported files with inspectable delivery and failure states." + }, + { + "id": "chat-channels/lifecycle", + "label": "Lifecycle", + "description": "inspect endpoint activity, repair auth, and disable delivery when required." + } + ] + }, + { + "id": "gateways-profiles", + "title": "Tool gateways and access profiles", + "description": "Tool exposure, client configuration, tokens, profiles, and activity.", + "capabilities": [ + { + "id": "gateways-profiles/gateway", + "label": "Gateway", + "description": "create/edit an endpoint backed by a tool access profile." + }, + { + "id": "gateways-profiles/profiles", + "label": "Profiles", + "description": "define reusable tool access configuration and inspect draft/detail state." + }, + { + "id": "gateways-profiles/client-connect", + "label": "Client connect", + "description": "copy the intended endpoint/configuration to an authorized test client." + }, + { + "id": "gateways-profiles/tokens", + "label": "Tokens", + "description": "create/revoke client credentials and verify their scope." + }, + { + "id": "gateways-profiles/activity", + "label": "Activity", + "description": "inspect tool discovery/calls and explicit errors." + } + ] + }, + { + "id": "secrets", + "title": "Secrets and proposals", + "description": "Company/user credentials, grants, proposals, and vault import.", + "capabilities": [ + { + "id": "secrets/company-secrets", + "label": "Company secrets", + "description": "create/update secret references and control their bindings." + }, + { + "id": "secrets/user-secrets", + "label": "User secrets", + "description": "define required user credentials and set a value for the current user." + }, + { + "id": "secrets/proposals", + "label": "Proposals", + "description": "review proposed changes with the appropriate authority." + }, + { + "id": "secrets/vault-import", + "label": "Vault import", + "description": "import eligible external-vault references through the offered flow." + }, + { + "id": "secrets/runtime-delivery", + "label": "Runtime delivery", + "description": "deliver only the secret references authorized for the run/actor." + } + ] + }, + { + "id": "plugins", + "title": "Plugins", + "description": "Installation/configuration, contributed pages/tools, and lifecycle.", + "capabilities": [ + { + "id": "plugins/installation", + "label": "Installation", + "description": "install a trusted test package or local development plugin through supported controls." + }, + { + "id": "plugins/configuration", + "label": "Configuration", + "description": "save validated configuration and inspect activation/failure state." + }, + { + "id": "plugins/contributions", + "label": "Contributions", + "description": "open plugin routes, settings, tools, and contributed resource tabs." + }, + { + "id": "plugins/lifecycle", + "label": "Lifecycle", + "description": "disable/remove or reload a disposable plugin and observe cleanup." + } + ] + } + ] + }, + { + "title": "Automation and structured work", + "features": [ + { + "id": "routines", + "title": "Routines, schedules, and triggers", + "description": "Definitions, variables, scheduled/webhook/manual runs, and history.", + "capabilities": [ + { + "id": "routines/definition", + "label": "Definition", + "description": "save the request, agent, project, variables, and execution context." + }, + { + "id": "routines/triggers", + "label": "Triggers", + "description": "configure supported schedules/webhooks or other offered triggers." + }, + { + "id": "routines/run-now", + "label": "Run now", + "description": "supply inputs and create an observable routine execution." + }, + { + "id": "routines/history", + "label": "History", + "description": "inspect resulting tasks and past outcomes." + }, + { + "id": "routines/management", + "label": "Management", + "description": "organize, pause/disable, archive, and distinguish plugin-managed routines." + } + ] + }, + { + "id": "pipelines", + "title": "Pipelines, review queues, and learnings", + "description": "Experimental stages, automation, items, review, and learning records.", + "capabilities": [ + { + "id": "pipelines/stages", + "label": "Stages", + "description": "configure stage order, behavior, and automation assignees." + }, + { + "id": "pipelines/items", + "label": "Items", + "description": "create or inspect items, grouping, stage state, and work references." + }, + { + "id": "pipelines/review", + "label": "Review", + "description": "act on the pipeline review queue and inspect the resulting transition." + }, + { + "id": "pipelines/learnings", + "label": "Learnings", + "description": "inspect the separate learning view and its source context." + } + ] + }, + { + "id": "cases", + "title": "Cases", + "description": "Experimental structured fields, relationships, revisions, and task links.", + "capabilities": [ + { + "id": "cases/browse", + "label": "Browse", + "description": "search, filter, sort, group, and navigate case records." + }, + { + "id": "cases/structure", + "label": "Structure", + "description": "inspect fields and parent/child relationships without losing matching descendants." + }, + { + "id": "cases/history", + "label": "History", + "description": "inspect revisions and activity for a record." + }, + { + "id": "cases/task-links", + "label": "Task links", + "description": "follow work associated with a case in both directions." + } + ] + }, + { + "id": "status-cards", + "title": "Status cards", + "description": "Experimental summaries, watched work, refresh history, and settings.", + "capabilities": [ + { + "id": "status-cards/authoring", + "label": "Authoring", + "description": "create a card and configure its prompt, refresh behavior, and summarizer." + }, + { + "id": "status-cards/generation", + "label": "Generation", + "description": "observe compilation/refresh tasks and their successful or failed output." + }, + { + "id": "status-cards/inspection", + "label": "Inspection", + "description": "read the summary, watched work, history, and available query debugging." + }, + { + "id": "status-cards/archive", + "label": "Archive", + "description": "archive and restore without confusing a stale summary with a current result." + } + ] + } + ] + }, + { + "title": "Oversight and operation", + "features": [ + { + "id": "budgets-costs", + "title": "Costs and budgets", + "description": "Spend reports, scoped limits, incidents, hard stops, and resumption.", + "capabilities": [ + { + "id": "budgets-costs/cost-reporting", + "label": "Cost reporting", + "description": "inspect spend by the offered time range and resource dimensions." + }, + { + "id": "budgets-costs/budgets", + "label": "Budgets", + "description": "configure supported company/project/agent limits and inspect usage." + }, + { + "id": "budgets-costs/incidents", + "label": "Incidents", + "description": "distinguish warnings, hard stops, and manual pauses." + }, + { + "id": "budgets-costs/resolution", + "label": "Resolution", + "description": "raise or resolve a budget incident through authorized controls and verify resumption." + } + ] + }, + { + "id": "activity", + "title": "Dashboards and audit trails", + "description": "Company health, live work, activity, runs, routines, and timeline.", + "capabilities": [ + { + "id": "activity/overview", + "label": "Overview", + "description": "inspect the company dashboard and live-work summary." + }, + { + "id": "activity/activity", + "label": "Activity", + "description": "filter and page through recorded actor/resource events." + }, + { + "id": "activity/runs", + "label": "Runs", + "description": "find executions and follow their task/agent context." + }, + { + "id": "activity/routine-audit", + "label": "Routine audit", + "description": "inspect routine execution activity separately from its definition." + }, + { + "id": "activity/timeline", + "label": "Timeline", + "description": "use the available timeline view to understand work over time." + } + ] + }, + { + "id": "navigation-preferences", + "title": "Navigation, profile, and announcements", + "description": "Sidebar state, favorites/recents, personal identity, and dismissals.", + "capabilities": [ + { + "id": "navigation-preferences/navigation", + "label": "Navigation", + "description": "use current sidebar links, favorites, recents, and available layout controls." + }, + { + "id": "navigation-preferences/preferences", + "label": "Preferences", + "description": "persist supported ordering/collapse choices for the intended user/company." + }, + { + "id": "navigation-preferences/profile", + "label": "Profile", + "description": "edit personal identity and inspect public profile links." + }, + { + "id": "navigation-preferences/announcements", + "label": "Announcements", + "description": "inspect and dismiss an announcement with its intended instance-wide user scope." + }, + { + "id": "navigation-preferences/fallbacks", + "label": "Fallbacks", + "description": "handle missing resources and old deep links without presenting another resource as the target." + } + ] + }, + { + "id": "instance-operations", + "title": "Instance operations", + "description": "Installation, updates, service health, configuration, and backups.", + "capabilities": [ + { + "id": "instance-operations/install-update", + "label": "Install update", + "description": "inspect install channel/version and apply supported update or rollback operations." + }, + { + "id": "instance-operations/server-service", + "label": "Server service", + "description": "start/stop the intended instance and inspect its service health." + }, + { + "id": "instance-operations/configuration", + "label": "Configuration", + "description": "configure database, server binding, storage, secrets, and available experimental settings." + }, + { + "id": "instance-operations/diagnostics", + "label": "Diagnostics", + "description": "use doctor/health output to distinguish readiness from a listening port." + }, + { + "id": "instance-operations/backup", + "label": "Backup", + "description": "create and inspect backups and verify restore only into a separate disposable target." + } + ] + }, + { + "id": "cli-operations", + "title": "CLI/API and local worktrees", + "description": "Explicit context, resource commands, outputs, runs, and isolated instances.", + "capabilities": [ + { + "id": "cli-operations/context", + "label": "Context", + "description": "choose instance/API base, profile, identity, and company explicitly." + }, + { + "id": "cli-operations/domain-commands", + "label": "Domain commands", + "description": "read/write tasks, agents, projects, goals, approvals, routines, and other registered resources." + }, + { + "id": "cli-operations/outputs", + "label": "Outputs", + "description": "work with documents/assets/skills and inspect machine-readable results." + }, + { + "id": "cli-operations/run-controls", + "label": "Run controls", + "description": "invoke/inspect supported runs and observe actual outcomes." + }, + { + "id": "cli-operations/worktree-instances", + "label": "Worktree instances", + "description": "initialize, seed, diagnose, and clean up only an owned development instance." + } + ] + } + ] + }, + { + "title": "Contributor surfaces", + "features": [ + { + "id": "developer-labs", + "title": "Developer previews and diagnostic labs", + "description": "Design examples, interaction fixtures, performance checks; not production acceptance.", + "capabilities": [ + { + "id": "developer-labs/design", + "label": "Design", + "description": "inspect shared components and layout examples in the design guide or Storybook." + }, + { + "id": "developer-labs/interaction-labs", + "label": "Interaction labs", + "description": "exercise synthetic bootstrap, task-chat, denial, and collaboration states." + }, + { + "id": "developer-labs/performance", + "label": "Performance", + "description": "measure the provided long-thread fixture with known data and environment." + }, + { + "id": "developer-labs/route-gates", + "label": "Route gates", + "description": "distinguish dev-only, experimental, historical, and fallback surfaces." + } + ] + } + ] + } + ] +} diff --git a/ui/storybook/generated/features/access.stories.tsx b/ui/storybook/generated/features/access.stories.tsx new file mode 100644 index 00000000000..7dacca8e8ea --- /dev/null +++ b/ui/storybook/generated/features/access.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-access", + title: "Getting started and access/Login, invitations, and access/Overview", + component: FeatureOverview, + args: { area: "access" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/activity.stories.tsx b/ui/storybook/generated/features/activity.stories.tsx new file mode 100644 index 00000000000..a967df0407a --- /dev/null +++ b/ui/storybook/generated/features/activity.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-activity", + title: "Oversight and operation/Dashboards and audit trails/Overview", + component: FeatureOverview, + args: { area: "activity" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/agent-chat.stories.tsx b/ui/storybook/generated/features/agent-chat.stories.tsx new file mode 100644 index 00000000000..f9706d2e91f --- /dev/null +++ b/ui/storybook/generated/features/agent-chat.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-agent-chat", + title: "Tasks and conversations/Agent conversations and project handoff/Overview", + component: FeatureOverview, + args: { area: "agent-chat" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/agents.stories.tsx b/ui/storybook/generated/features/agents.stories.tsx new file mode 100644 index 00000000000..60ed8ea3d66 --- /dev/null +++ b/ui/storybook/generated/features/agents.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-agents", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Overview", + component: FeatureOverview, + args: { area: "agents" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/app-permissions.stories.tsx b/ui/storybook/generated/features/app-permissions.stories.tsx new file mode 100644 index 00000000000..890719f34f0 --- /dev/null +++ b/ui/storybook/generated/features/app-permissions.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-app-permissions", + title: "Apps, channels, and extensions/App access and action permissions/Overview", + component: FeatureOverview, + args: { area: "app-permissions" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/budgets-costs.stories.tsx b/ui/storybook/generated/features/budgets-costs.stories.tsx new file mode 100644 index 00000000000..6eaa14c23cf --- /dev/null +++ b/ui/storybook/generated/features/budgets-costs.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-budgets-costs", + title: "Oversight and operation/Costs and budgets/Overview", + component: FeatureOverview, + args: { area: "budgets-costs" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/cases.stories.tsx b/ui/storybook/generated/features/cases.stories.tsx new file mode 100644 index 00000000000..a460b6f7194 --- /dev/null +++ b/ui/storybook/generated/features/cases.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-cases", + title: "Automation and structured work/Cases/Overview", + component: FeatureOverview, + args: { area: "cases" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/chat-channels.stories.tsx b/ui/storybook/generated/features/chat-channels.stories.tsx new file mode 100644 index 00000000000..59518d8f1e6 --- /dev/null +++ b/ui/storybook/generated/features/chat-channels.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-chat-channels", + title: "Apps, channels, and extensions/External chat and email/Overview", + component: FeatureOverview, + args: { area: "chat-channels" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/cli-operations.stories.tsx b/ui/storybook/generated/features/cli-operations.stories.tsx new file mode 100644 index 00000000000..71de7f942b6 --- /dev/null +++ b/ui/storybook/generated/features/cli-operations.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-cli-operations", + title: "Oversight and operation/CLI, API and local worktrees/Overview", + component: FeatureOverview, + args: { area: "cli-operations" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/companies.stories.tsx b/ui/storybook/generated/features/companies.stories.tsx new file mode 100644 index 00000000000..c75fbc8f580 --- /dev/null +++ b/ui/storybook/generated/features/companies.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-companies", + title: "Getting started and access/Companies and portability/Overview", + component: FeatureOverview, + args: { area: "companies" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/connection-setup.stories.tsx b/ui/storybook/generated/features/connection-setup.stories.tsx new file mode 100644 index 00000000000..dd82f534d6c --- /dev/null +++ b/ui/storybook/generated/features/connection-setup.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-connection-setup", + title: "Apps, channels, and extensions/Connection setup/Overview", + component: FeatureOverview, + args: { area: "connection-setup" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/developer-labs.stories.tsx b/ui/storybook/generated/features/developer-labs.stories.tsx new file mode 100644 index 00000000000..f4de9cc18fa --- /dev/null +++ b/ui/storybook/generated/features/developer-labs.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-developer-labs", + title: "Contributor surfaces/Developer previews and diagnostic labs/Overview", + component: FeatureOverview, + args: { area: "developer-labs" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/documents-artifacts.stories.tsx b/ui/storybook/generated/features/documents-artifacts.stories.tsx new file mode 100644 index 00000000000..203f38e439b --- /dev/null +++ b/ui/storybook/generated/features/documents-artifacts.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-documents-artifacts", + title: "Tasks and conversations/Documents, attachments, and work products/Overview", + component: FeatureOverview, + args: { area: "documents-artifacts" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/execution-environments.stories.tsx b/ui/storybook/generated/features/execution-environments.stories.tsx new file mode 100644 index 00000000000..e3a1764173c --- /dev/null +++ b/ui/storybook/generated/features/execution-environments.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-execution-environments", + title: "Projects and execution/Execution environments/Overview", + component: FeatureOverview, + args: { area: "execution-environments" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/gateways-profiles.stories.tsx b/ui/storybook/generated/features/gateways-profiles.stories.tsx new file mode 100644 index 00000000000..17d8d379248 --- /dev/null +++ b/ui/storybook/generated/features/gateways-profiles.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-gateways-profiles", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Overview", + component: FeatureOverview, + args: { area: "gateways-profiles" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/goals.stories.tsx b/ui/storybook/generated/features/goals.stories.tsx new file mode 100644 index 00000000000..e50c247d3a0 --- /dev/null +++ b/ui/storybook/generated/features/goals.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-goals", + title: "Projects and execution/Goals and work alignment/Overview", + component: FeatureOverview, + args: { area: "goals" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/inbox-search.stories.tsx b/ui/storybook/generated/features/inbox-search.stories.tsx new file mode 100644 index 00000000000..6993432438b --- /dev/null +++ b/ui/storybook/generated/features/inbox-search.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-inbox-search", + title: "Tasks and conversations/Inbox, decisions, and search/Overview", + component: FeatureOverview, + args: { area: "inbox-search" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/instance-operations.stories.tsx b/ui/storybook/generated/features/instance-operations.stories.tsx new file mode 100644 index 00000000000..dcd63d0fd1b --- /dev/null +++ b/ui/storybook/generated/features/instance-operations.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-instance-operations", + title: "Oversight and operation/Instance operations/Overview", + component: FeatureOverview, + args: { area: "instance-operations" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/navigation-preferences.stories.tsx b/ui/storybook/generated/features/navigation-preferences.stories.tsx new file mode 100644 index 00000000000..f894ec18769 --- /dev/null +++ b/ui/storybook/generated/features/navigation-preferences.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-navigation-preferences", + title: "Oversight and operation/Navigation, profile, and announcements/Overview", + component: FeatureOverview, + args: { area: "navigation-preferences" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/onboarding.stories.tsx b/ui/storybook/generated/features/onboarding.stories.tsx new file mode 100644 index 00000000000..88bfe310c8f --- /dev/null +++ b/ui/storybook/generated/features/onboarding.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-onboarding", + title: "Getting started and access/Onboarding and first work/Overview", + component: FeatureOverview, + args: { area: "onboarding" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/pipelines.stories.tsx b/ui/storybook/generated/features/pipelines.stories.tsx new file mode 100644 index 00000000000..ea339376eb7 --- /dev/null +++ b/ui/storybook/generated/features/pipelines.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-pipelines", + title: "Automation and structured work/Pipelines, review queues, and learnings/Overview", + component: FeatureOverview, + args: { area: "pipelines" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/plugins.stories.tsx b/ui/storybook/generated/features/plugins.stories.tsx new file mode 100644 index 00000000000..808e79900ba --- /dev/null +++ b/ui/storybook/generated/features/plugins.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-plugins", + title: "Apps, channels, and extensions/Plugins/Overview", + component: FeatureOverview, + args: { area: "plugins" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/projects.stories.tsx b/ui/storybook/generated/features/projects.stories.tsx new file mode 100644 index 00000000000..99b5af85c28 --- /dev/null +++ b/ui/storybook/generated/features/projects.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-projects", + title: "Projects and execution/Projects and repositories/Overview", + component: FeatureOverview, + args: { area: "projects" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/questions-and-approvals.stories.tsx b/ui/storybook/generated/features/questions-and-approvals.stories.tsx new file mode 100644 index 00000000000..c79a983be87 --- /dev/null +++ b/ui/storybook/generated/features/questions-and-approvals.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-questions-and-approvals", + title: "Tasks and conversations/Questions and approvals/Overview", + component: FeatureOverview, + args: { area: "questions-and-approvals" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/recovery.stories.tsx b/ui/storybook/generated/features/recovery.stories.tsx new file mode 100644 index 00000000000..a448fa3a752 --- /dev/null +++ b/ui/storybook/generated/features/recovery.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-recovery", + title: "Projects and execution/Recovery/Overview", + component: FeatureOverview, + args: { area: "recovery" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/routines.stories.tsx b/ui/storybook/generated/features/routines.stories.tsx new file mode 100644 index 00000000000..c79537ead7c --- /dev/null +++ b/ui/storybook/generated/features/routines.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-routines", + title: "Automation and structured work/Routines, schedules, and triggers/Overview", + component: FeatureOverview, + args: { area: "routines" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/runs-adapters.stories.tsx b/ui/storybook/generated/features/runs-adapters.stories.tsx new file mode 100644 index 00000000000..393b9755ddd --- /dev/null +++ b/ui/storybook/generated/features/runs-adapters.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-runs-adapters", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Overview", + component: FeatureOverview, + args: { area: "runs-adapters" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/secrets.stories.tsx b/ui/storybook/generated/features/secrets.stories.tsx new file mode 100644 index 00000000000..b2c5e2311f4 --- /dev/null +++ b/ui/storybook/generated/features/secrets.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-secrets", + title: "Apps, channels, and extensions/Secrets and proposals/Overview", + component: FeatureOverview, + args: { area: "secrets" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/skills.stories.tsx b/ui/storybook/generated/features/skills.stories.tsx new file mode 100644 index 00000000000..79f3ccdeb56 --- /dev/null +++ b/ui/storybook/generated/features/skills.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-skills", + title: "Agents and reusable capabilities/Skills and Skill Studio/Overview", + component: FeatureOverview, + args: { area: "skills" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/status-cards.stories.tsx b/ui/storybook/generated/features/status-cards.stories.tsx new file mode 100644 index 00000000000..7d2695581de --- /dev/null +++ b/ui/storybook/generated/features/status-cards.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-status-cards", + title: "Automation and structured work/Status cards/Overview", + component: FeatureOverview, + args: { area: "status-cards" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/steering.stories.tsx b/ui/storybook/generated/features/steering.stories.tsx new file mode 100644 index 00000000000..856161d7f14 --- /dev/null +++ b/ui/storybook/generated/features/steering.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-steering", + title: "Tasks and conversations/Steering and queued messages/Overview", + component: FeatureOverview, + args: { area: "steering" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/task-coordination.stories.tsx b/ui/storybook/generated/features/task-coordination.stories.tsx new file mode 100644 index 00000000000..1d6080ac941 --- /dev/null +++ b/ui/storybook/generated/features/task-coordination.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-task-coordination", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Overview", + component: FeatureOverview, + args: { area: "task-coordination" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/tasks.stories.tsx b/ui/storybook/generated/features/tasks.stories.tsx new file mode 100644 index 00000000000..917ce1d9ff5 --- /dev/null +++ b/ui/storybook/generated/features/tasks.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-tasks", + title: "Tasks and conversations/Task creation and lifecycle/Overview", + component: FeatureOverview, + args: { area: "tasks" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/teams.stories.tsx b/ui/storybook/generated/features/teams.stories.tsx new file mode 100644 index 00000000000..42fa79255bc --- /dev/null +++ b/ui/storybook/generated/features/teams.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-teams", + title: "Agents and reusable capabilities/Team packages and installation/Overview", + component: FeatureOverview, + args: { area: "teams" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/generated/features/workspaces.stories.tsx b/ui/storybook/generated/features/workspaces.stories.tsx new file mode 100644 index 00000000000..0d1790d4cf9 --- /dev/null +++ b/ui/storybook/generated/features/workspaces.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FeatureOverview } from "../../support/feature-map/FeatureOverview"; + +const meta = { + id: "feature-map-workspaces", + title: "Projects and execution/Workspaces, services, and files/Overview", + component: FeatureOverview, + args: { area: "workspaces" }, + tags: ["!autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +export const Overview: StoryObj = {}; diff --git a/ui/storybook/stories/overview/overview.stories.tsx b/ui/storybook/stories/overview/overview.stories.tsx index 4da748cfce2..1c7b9b0a339 100644 --- a/ui/storybook/stories/overview/overview.stories.tsx +++ b/ui/storybook/stories/overview/overview.stories.tsx @@ -1,93 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import catalog from "../../../../maps/storybook-map/catalog.json"; - -type IndexEntry = { id: string; type: string; importPath: string }; - -function StorybookGuide() { - const [query, setQuery] = useState(""); - const [storyLinks, setStoryLinks] = useState>({}); - const [indexError, setIndexError] = useState(false); - useEffect(() => { - const controller = new AbortController(); - void fetch(new URL("index.json", window.location.href), { signal: controller.signal }) - .then(response => { - if (!response.ok) throw new Error("Story index unavailable"); - return response.json() as Promise<{ entries: Record }>; - }) - .then(index => { - const links: Record = {}; - for (const entry of Object.values(index.entries)) { - if (entry.type !== "story") continue; - const file = `ui/${entry.importPath.replace(/^\.\//, "")}`; - links[file] ??= `./?path=/story/${entry.id}`; - } - setStoryLinks(links); - }) - .catch(() => { if (!controller.signal.aborted) setIndexError(true); }); - return () => controller.abort(); - }, []); - const groups = useMemo(() => { - const result = new Map(); - const search = query.trim().toLowerCase(); - for (const entry of catalog.entries) { - if (search && ![entry.purpose, entry.file, entry.status, entry.implementation, ...entry.capabilities].join(" ").toLowerCase().includes(search)) continue; - const area = entry.capabilities[0]?.split("/")[0] ?? "foundations-and-guides"; - const entries = result.get(area) ?? []; - entries.push(entry); - result.set(area, entries); - } - return [...result].sort(([a], [b]) => a.localeCompare(b)); - }, [query]); - return ( -
-
-

Storybook by feature

-

- {catalog.entries.length} story suites organized by the feature map. Current means source-reviewed; - it does not mean every interaction or production journey passed. Production components can use simulated data. -

-

- Historical designs and proposals stay available. Unreviewed entries need deeper comparison with the current product. - The maintained contract and full source references are in maps/storybook-map/. -

- setQuery(event.target.value)} /> - {indexError ?

Story links could not load. Reload this guide to try again.

: null} -
- {groups.length === 0 ?

No matching stories.

: null} - {groups.map(([area, entries]) => ( -
-

{area}

-
    - {entries.map(entry => ( -
  • -
    - {storyLinks[entry.file] - ? {entry.purpose} - : {entry.purpose}} - {entry.status} - {entry.implementation} - {entry.boundary} -
    -

    {entry.notes}

    -

    {entry.capabilities.join(" · ") || "Shared foundations and contributor guidance"}

    -
  • - ))} -
-
- ))} -
- ); -} +import { FeatureMapIndex } from "../../support/feature-map/FeatureOverview"; const meta = { id: "overview-storybook-guide", title: "Overview/Storybook Guide", - component: StorybookGuide, + component: FeatureMapIndex, parameters: { docs: { description: { component: "Feature-based navigation from the downstream catalog, with separate product, component, and fixture classifications. Story links come from this build's index." } } }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; export const CoverageGuide: Story = {}; diff --git a/ui/storybook/support/feature-map/FeatureOverview.tsx b/ui/storybook/support/feature-map/FeatureOverview.tsx new file mode 100644 index 00000000000..b543a545dde --- /dev/null +++ b/ui/storybook/support/feature-map/FeatureOverview.tsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import catalog from "../../../../maps/storybook-map/catalog.json"; +import featureIndex from "../../generated/feature-index.json"; + +type Entry = Omit<(typeof catalog.entries)[number], "capabilities"> & { capabilities: string[] }; +const entries: Entry[] = catalog.entries; +type IndexEntry = { id: string; type: string; importPath: string }; +const recipeRoot = "https://github.com/paperclipai/paperclip/blob/master/maps/feature-map/"; +const allFeatures = featureIndex.sections.flatMap(section => section.features); +const examples = entries.filter(entry => entry.implementation !== "guide"); +const featureHref = (id: string) => `./?path=/story/feature-map-${id}--overview`; + +function useStoryLinks() { + const [links, setLinks] = useState>({}); + const [error, setError] = useState(false); + useEffect(() => { + const controller = new AbortController(); + void fetch(new URL("index.json", window.location.href), { signal: controller.signal }) + .then(response => { + if (!response.ok) throw new Error("Story index unavailable"); + return response.json() as Promise<{ entries: Record }>; + }) + .then(index => { + const next: Record = {}; + for (const entry of Object.values(index.entries)) { + if (entry.type !== "story") continue; + const file = `ui/${entry.importPath.replace(/^\.\//, "")}`; + next[file] ??= `./?path=/story/${entry.id}`; + } + setLinks(next); + }) + .catch(() => { if (!controller.signal.aborted) setError(true); }); + return () => controller.abort(); + }, []); + return { links, error }; +} + +function SuiteLink({ entry, links }: { entry: Entry; links: Record }) { + return ( +
  • +
    + {links[entry.file] + ? {entry.purpose} + : {entry.purpose}} + {entry.status} + {entry.implementation} + {entry.boundary} +
    +

    {entry.notes}

    +
  • + ); +} + +export function FeatureMapIndex() { + const [query, setQuery] = useState(""); + const { links, error } = useStoryLinks(); + const search = query.trim().toLowerCase(); + const sections = useMemo(() => featureIndex.sections.map(section => ({ + ...section, + features: section.features.filter(feature => { + const related = entries.filter(entry => entry.capabilities.some(ref => ref.startsWith(`${feature.id}/`))); + return [section.title, feature.title, feature.id, feature.description, + ...feature.capabilities.flatMap(capability => [capability.id, capability.label, capability.description]), + ...related.flatMap(entry => [entry.file, entry.purpose, entry.status, entry.implementation]), + ].join(" ").toLowerCase().includes(search); + }), + })).filter(section => section.features.length), [search]); + const foundations = examples.filter(entry => !entry.capabilities.length + && ["Shared foundations", entry.file, entry.purpose, entry.status, entry.implementation, entry.boundary].join(" ").toLowerCase().includes(search)); + return ( +
    +
    +

    Paperclip feature map

    +

    The same product index as the feature map, with Storybook examples alongside each capability.

    +

    {allFeatures.length} feature families · {allFeatures.reduce((count, feature) => count + feature.capabilities.length, 0)} capabilities. Examples are fixtures and references, not proof of production behavior.

    + Read the feature-map index + setQuery(event.target.value)} /> +
    + {sections.length === 0 && foundations.length === 0 ?

    No matching features or examples.

    : null} + {sections.map(section => ( +
    +

    {section.title}

    +
      + {section.features.map(feature => { + const suites = examples.filter(entry => entry.capabilities.some(ref => ref.startsWith(`${feature.id}/`))); + return ( +
    • +
      + {feature.title} + {suites.length ? `${suites.length} related example ${suites.length === 1 ? "suite" : "suites"}` : "No cataloged examples yet"} +
      +

      {feature.description}

      +
    • + ); + })} +
    +
    + ))} + {foundations.length ? ( +
    +

    Shared foundations

    +

    Shared UI components sit outside the product feature index.

    + {error ?

    Example links could not load. Reload to try again.

    : null} +
      {foundations.map(entry => )}
    +
    + ) : null} +
    + ); +} + +export function FeatureOverview({ area }: { area: string }) { + const feature = allFeatures.find(item => item.id === area); + const { links, error } = useStoryLinks(); + if (!feature) return

    This feature is not in the feature-map index.

    ; + const guides = entries.filter(entry => entry.implementation === "guide" && entry.capabilities.some(ref => ref.startsWith(`${area}/`))); + return ( +
    +
    + All features +

    {feature.title}

    +

    {feature.description}

    + Feature recipe and verification steps +

    Capabilities follow the feature recipe in the same order. Related suites may live under another feature. A suite link does not establish that every behavior has an example or passed a test.

    + {error ?

    Example links could not load. Reload to try again.

    : null} +
    + + {feature.capabilities.map(capability => { + const suites = examples.filter(entry => entry.capabilities.includes(capability.id)); + return ( +
    +

    {capability.label}

    + {capability.id} +
    {capability.description}
    + {suites.length + ?
      {suites.map(entry => )}
    + :

    No cataloged example yet. Use the feature recipe for entry points, verification steps, and known limits.

    } +
    + ); + })} + {guides.length ?

    Related guides

      {guides.map(entry => )}
    : null} +
    + ); +} From fd61dc1dd538e36b88f01dddf34eaf8df89feeca Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:02:37 -0500 Subject: [PATCH 02/85] docs(skills): distinguish Paperclip verification targets (#16145) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Verification must show which runtime and user journey passed. > - The existing skill centers on an empty instance launched from source. > - Seeded data, reused databases, published packages, and hosted stacks prove different outcomes. > - This pull request makes the target and starting conditions explicit. > - It routes deployment to private Cloud skills and keeps feature checks in Verify Paperclip. ## Linked Issues or Issue Description **Issue type** Missing documentation. **Where is the issue?** `.agents/skills/verify-paperclip/SKILL.md`. **What's wrong?** The skill does not distinguish source development, npm installation, staging, and production. It also needs to distinguish fresh onboarding from seeded or reused data and make platform scope explicit. **Suggested fix** Add target selection and focused references. Preserve the existing empty-instance helper. Name the private Cloud deployment skills as a separate prerequisite when deployment is requested. No duplicate documentation PR was found in the open-PR search. ## What Changed - Record artifact, environment, starting data, platform, and journey before testing. - Document dev servers, fresh instances, seeded/reused test drives, and cloned worktrees. - Separate exact-version canary and stable npm verification from source and Cloud evidence. - Document staging and production checks and route deployment to `deploy-paperclip-stack` or `deploy-cloud-harness` in the private Cloud repo. - Limit local ownership helpers to instances created by their launcher. ## Verification - Current-head GitHub CI: 55 passed, 2 skipped, no failures. Typecheck, tests, build, and the aggregate gate passed. Greptile: 5/5, all threads resolved. - Skill validator: passed. - Relative Markdown file links: passed across the changed skills and references (29 links in final validation). - `python3 -m unittest discover -s .agents/skills/verify-paperclip/tests -v`: 2 passed. - `node scripts/check-maps.mjs`: passed (structural coverage only). - `git diff --check`: passed. - `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`: attempted; blocked by missing workspace dependencies (`node_modules`, Node type definitions, and the checkout tsx entry point). These are not passing full-suite results. - Existing published-launcher test attempt was blocked by sandbox loopback permissions; it does not provide package-install evidence. - Reviewed commands against development docs, release workflow, and current Cloud runbooks. No live deployment, publication, or production mutation was performed for these documentation changes. ## Risks - Low runtime risk: only skill documentation changes. - Private deployment skills require access to the Cloud repo and must be available before that handoff works. The verifier reports the prerequisite when unavailable. - Workflow changes can make command guidance stale. The skills direct readers to maintained workflows and runbooks before use. ## Model Used OpenAI GPT-6 via Codex, with reasoning, file editing, shell execution, and GitHub CLI access. 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 #` 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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (the focused ownership suite; full-suite limits are listed above) - [x] I have added or updated tests where applicable (no runtime change; existing tests retained) - [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 --- .agents/skills/verify-paperclip/SKILL.md | 82 +++++++++++++++-- .../references/npm-install.md | 78 +++++++++++++++++ .../references/verification-targets.md | 87 +++++++++++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/verify-paperclip/references/npm-install.md create mode 100644 .agents/skills/verify-paperclip/references/verification-targets.md diff --git a/.agents/skills/verify-paperclip/SKILL.md b/.agents/skills/verify-paperclip/SKILL.md index b16467140a0..9e10d7c2f90 100644 --- a/.agents/skills/verify-paperclip/SKILL.md +++ b/.agents/skills/verify-paperclip/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-paperclip -description: Launch an isolated Paperclip instance, drive its real web UI or CLI using the existing feature-map recipes, capture evidence, and clean up owned processes. Use to verify user-facing Paperclip behavior. +description: Verify Paperclip from a source checkout, published npm install, or existing staging or production stack. Select the environment, data state, version, and platform; drive real UI or CLI journeys and capture evidence. Route deployment to the private Cloud skills. --- # Verify Paperclip @@ -15,7 +15,66 @@ recipe or access drift with evidence; map updates belong to [maintain-verification-skill](../maintain-verification-skill/SKILL.md). Read [development instructions](../../../doc/DEVELOPING.md) for current commands. -## Launch +## Select the verification target + +State what is being tested before starting. Use the requested target; for ordinary +feature development, default to the current checkout on the current host with +isolated data. Expand into cross-platform or release qualification only when +requested or needed to reproduce the problem. + +Record these independent dimensions together: + +- **Artifact:** source checkout (path, commit, dirty state), published npm package + (requested channel and resolved exact version), or deployed image/release. +- **Environment:** local, staging, or production; target URL and intended company + or workspace; board/user/agent identity and authentication mode. +- **Starting data:** fresh and unonboarded, freshly seeded, cloned, or reused; + embedded versus external PostgreSQL; configuration/data location or a redacted + database identifier. Never record credentials or connection strings. +- **Platform:** server OS/architecture, Node/npm versions, and browser/client OS + when relevant. A Mac browser against a Linux server is Linux server evidence; + WSL is Linux evidence, not native Windows evidence. +- **Journey:** first installation/onboarding, an existing-user feature, upgrade, + or a live agent run; expected outcome and real versus simulated dependencies. + +| Target | Use it to prove | Procedure | +| --- | --- | --- | +| Checkout dev server, existing or cloned data | Source behavior against existing state | [Checkout modes](references/verification-targets.md#checkout-and-data-modes) | +| Checkout with fresh empty data | First-run onboarding and subsequent features | [Launch](#launch-an-empty-checkout-instance) below | +| Checkout test drive, fresh seed or reused data | Features with a configured company/CEO | [Checkout modes](references/verification-targets.md#checkout-and-data-modes) | +| Published npm canary | End-user installation of a specific prerelease | [npm verification](references/npm-install.md) | +| Published npm stable (`latest`) | End-user installation of the released package | [npm verification](references/npm-install.md) | +| Existing staging stack | Deployed build, hosted auth, flags, integrations | [Hosted verification](references/verification-targets.md#staging-and-production) | +| Existing production stack | Current production journey within the authorized scope | [Hosted verification](references/verification-targets.md#staging-and-production) | + +Fresh data does not imply a fresh install. A seeded test drive does not prove +onboarding. A source build does not prove npm packaging. A passing staging check +does not prove production, and a production Cloud stack does not prove the stable +npm install. Report only the combinations actually exercised. + +## Deployment handoff + +When a requested revision must first be built or deployed, use the skills in the +private `paperclip-cloud` repo: + +- `deploy-paperclip-stack` at `.agents/skills/deploy-paperclip-stack/SKILL.md` + for a tenant workspace or an explicitly scoped fleet rollout. +- `deploy-cloud-harness` at `.agents/skills/deploy-cloud-harness/SKILL.md` + for the Cloud control plane itself. + +Locate the authorized Cloud checkout or installed skill by name; do not assume a +sibling directory exists. Those skills own build preparation, deployment, +migrations, and rollout recovery. Return here with environment, target, resolved +source SHA, release/image identity, deployment result, and evidence link. If +private access is unavailable, report that prerequisite as blocked and continue +checks possible on the supplied target without inventing deploy commands. + +A verification request alone does not authorize deployment, npm publication, +fleet-default changes, or a production rollout. Preserve explicit deployment +instructions already given. Publishing npm packages belongs to the existing +[release skill](../release/SKILL.md); installing one does not require publishing it. + +## Launch an empty checkout instance Run from the repository root with Node 24.11+, Python 3, `lsof`, and dependencies installed with `pnpm install --frozen-lockfile`. Browser driving uses the existing @@ -53,7 +112,11 @@ For Paperclip-managed reusable services, follow instead of launching a second server. Its cloned-data/login requirements differ from this empty local instance. -## Doctor +## Doctor (owned checkout helper only) + +The doctor/stop commands require the real `launch.json` created by `launch.py`. +Do not fabricate one for dev servers, npm installs, test drives, or hosted stacks. +Use their own readiness and lifecycle procedures instead. After startup, and again after any surprising behavior, run: @@ -98,14 +161,16 @@ guessing resource URLs. The unprefixed `/onboarding` route starts naming; use th company-prefixed onboarding route to resume its agent setup. Check relevant experimental gates and actor permissions. -For CLI checks, discover exact flags with +For source-checkout CLI checks, discover exact flags with `node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts --help`, then -select the disposable data directory/API/company explicitly. API reads can +select the disposable data directory/API/company explicitly. For published npm +checks, use the installed version’s CLI and help instead. API reads can corroborate UI writes; API-created state does not prove a creation UI. ## Evidence -Record feature file, sub-feature and entry-point ID, commit/dirty state, owned URL, +Include the target dimensions above, then record feature file, sub-feature and +entry-point ID, commit/dirty state, target URL, company/user, flags, runtime/harness, live versus simulated dependencies, command, exit code, expected and observed behavior, and evidence links. Report each entry as passed, failed, blocked with its prerequisite, or not run. @@ -126,7 +191,10 @@ documentation coverage rather than live test health. ## Cleanup -Close the browser context you created (the smoke does this in `finally`). Stop the owned runtime with: +Close the browser context you created (the smoke does this in `finally`). Leave +shared dev, staging, and production services running. The remaining cleanup steps +in this section apply only to `launch.py` instances; use the selected target’s +procedure for other launchers. Stop the helper-owned runtime with: ```sh python3 .agents/skills/verify-paperclip/scripts/stop.py \ diff --git a/.agents/skills/verify-paperclip/references/npm-install.md b/.agents/skills/verify-paperclip/references/npm-install.md new file mode 100644 index 00000000000..fe4491948d7 --- /dev/null +++ b/.agents/skills/verify-paperclip/references/npm-install.md @@ -0,0 +1,78 @@ +# Verify the published npm experience + +Use this route for “can a user install and run this package?” Source startup, +workspace links, `pnpm build`, and local tarballs do not answer that question. + +## Resolve the exact artifact + +Read [.github/workflows/release.yml](../../../../.github/workflows/release.yml) +for publication behavior. Canary publication follows verified pushes to `master`; +an arbitrary feature-branch SHA need not have a published canary. Publication can +fail or still be running, so prove registry availability before claiming coverage. + +Resolve the requested channel once against the public registry: + +```sh +npm view paperclipai@canary version --registry=https://registry.npmjs.org +npm view paperclipai@latest version --registry=https://registry.npmjs.org +``` + +Use the one requested by the task: `canary` for prerelease verification, `latest` +for stable production npm verification, or an explicit version. Record resolved +version, registry, dist integrity, and source provenance from release metadata, +workflow, and tags. Pin that version for all later commands. If testing a SHA, +confirm the package maps to it; never substitute the current canary silently. +Missing publication is a blocked check, not a reason to publish during verification. + +## Install like a new user + +Use a disposable directory outside the checkout and fresh npm cache, +instance/config, and user-config paths. Use an isolated OS user/container when +proving absence of all pre-existing user state. Inspect inherited routing and +registry overrides without exposing secrets. Avoid workspace dependencies, local +`file:` packages, preinstalled Paperclip binaries, and cached `npx` installs. + +Run the documented end-user entry point with the pinned package, for example +`npx --yes --registry=https://registry.npmjs.org paperclipai@`. +Read that version's `--help` before selecting `onboard`, `install`, or service +flags. For a foreground disposable check use `onboard --yes --bind loopback +--no-install-service --run --data-dir ` when supported by that +version; keep ordinary interactive onboarding when that is the journey requested. +A managed CLI/service installation is separate from an ephemeral `npx` launch; +test it explicitly if requested. + +Prove each relevant stage separately: registry download/install, CLI execution, +server readiness, visible onboarding, saved company/setup state, restart/reload, +and a first agent task when authorized. Successful `--version` alone does not +prove a usable install. For upgrades, first create state with the previous exact +version, then install the target against that same disposable data and verify +state survives; report this separately from a fresh install. + +## Existing automation and its limits + +The repo has a published-package onboarding suite: + +```sh +PAPERCLIPAI_VERSION= pnpm test:canary-onboarding-smoke +``` + +Read [its configuration](../../../../tests/canary-onboarding/playwright.config.ts) +and [launcher](../../../../tests/canary-onboarding/start-published-canary.mjs) +before running. It installs the published package into isolated temporary state, +uses a fresh npm cache and loopback server, and retains failure diagnostics. +It uses checkout Playwright tooling to drive a published runtime; record both +revisions. Its name and automated release wiring target canaries. For stable +verification, inspect compatibility with the selected version or use the manual +pinned-package path above; do not relabel a canary result as stable proof. + +The Docker release-smoke suite is separate image evidence. Likewise, +`scripts/clean-install-npm.sh` defaults to source, and its published mode uses a +moving version and source-driven uninstall; it does not by itself prove the +whole lifecycle of a specific published version. + +Capture OS/architecture, Node/npm versions, exact commands and exit codes, +installed version, serving revision when available, initial data state, screenshots, +logs, and observed outcomes. Redact credentials. Stop owned foreground processes +or uninstall an owned test service with the tested version's supported command; +retain evidence outside disposable data. Never run checkout ownership helpers +with invented metadata to stop a package process. diff --git a/.agents/skills/verify-paperclip/references/verification-targets.md b/.agents/skills/verify-paperclip/references/verification-targets.md new file mode 100644 index 00000000000..837f3803a11 --- /dev/null +++ b/.agents/skills/verify-paperclip/references/verification-targets.md @@ -0,0 +1,87 @@ +# Checkout, data, and hosted targets + +## Checkout and data modes + +Read [DEVELOPING.md](../../../../doc/DEVELOPING.md) and +[DATABASE.md](../../../../doc/DATABASE.md) for the current startup contract. + +- **Existing dev server:** use `pnpm dev` (watch) or `pnpm dev:once` from the + intended checkout. Inspect its instance routing and data configuration first; + do not assume the default port belongs to this checkout. Record the actual + served commit and dirty state. Reuse a matching running server when appropriate. +- **Fresh empty instance:** use the skill's `launch.py` helper with a new data + directory. Complete the UI or CLI onboarding journey under test. This is the + credential-free route; a live model run needs separate provider setup. +- **Seeded test drive:** run the documented CLI `test-drive --no-browser` with + an authorized environment-backed provider credential. It creates a temporary + embedded instance, completes setup, and seeds Test Company and a CEO. It does + not execute the first task. Verify that separately when required. +- **Reused test drive:** pass `--data-dir `. If it already + contains a company, bootstrap flags do not replace companies, agents, or + secrets. Record existing state; do not label the result fresh onboarding. + Test drive rejects external PostgreSQL configuration and database URL overrides. +- **Managed or cloned worktree:** use + [dev-workspace run/verify/fix](../../paperclip-dev-workspace-run-verify-fix/SKILL.md). + Verify seed completion, instance routing, and login handoff before driving it. + Cloned data is existing-user evidence even when the checkout is new. + +`--data-dir` alone is not proof of a fresh database: check effective config and +whether `DATABASE_URL` routes to an external database. Do not print its value. +For ordinary dev, inspect both environment overrides and the active database +config: with `DATABASE_URL` unset, `database.mode: postgres` and a saved +`connectionString` still select external PostgreSQL. Embedded PostgreSQL is the +default only when neither an environment override nor the active config selects +an external database; its instance directory may already hold data. Do not reset +shared data to make a test pass. Choose separate disposable data for first-run +checks and verify the effective backend before calling it isolated. + +Record runtime PID, URL, port owner, checkout, and data location for a process you +start. Check `/api/health`, expected served revision, bootstrap/auth readiness, +and the actual page. Capture starting state and verify persistence after reload. +Use that launcher's documented stop procedure for an owned process; leave a +server you borrowed running. + +## Platform scope + +Ordinary development covers the current host unless the request or failure calls +for more. For a platform issue or release matrix, list required combinations: +macOS, Linux, native Windows, architecture, runtime, and install path. Mark each +combination passed, failed, blocked, or not run separately. + +The local helper needs POSIX tools such as `lsof`; do not assume it qualifies +native Windows. Use the supported CLI and native shell for that platform, with +isolated instance/config/cache paths and platform-appropriate process cleanup. +A container proves its guest OS and selected architecture, not the host's native +installer, service manager, file permissions, or path behavior. Keep browser and +server platforms distinct when testing a remote Linux box from a Mac. + +## Staging and production + +Use the supplied tenant URL and intended identity. Determine whether the target +is the Paperclip tenant application or the Cloud control plane. Confirm the +environment from the operator's deployment record and authenticated routing; +a page title or local Git checkout is not evidence of the serving revision. + +If a build/deploy is needed, follow the private skill handoff in `SKILL.md` first. +Record the exact serving SHA and release/image identity after deployment, not +just the requested branch, CI result, or fleet default. Recheck after a long run: +a staging auto-roll or another deployment can change the serving version. Mark +unknown or changing provenance as inconclusive for a claim about a specific SHA. + +For staging, use a designated test workspace and its real hosted sign-in/entry +flow. Record fresh versus existing workspace state, experimental flags, relevant +provider connections, and whether dependencies are live. Check tenant readiness +and then the feature journey, including persistence or continuation. A control +plane `/healthz` response or tenant HTML 200 is not feature acceptance. + +For production, keep verification within the requested stack, identity, and +journey. Prefer observation of existing state unless writes are authorized; use +an agreed test workspace for mutating checks. Do not run the empty-instance +onboarding smoke, reset data, change flags, or trigger paid agent work merely to +make a production check pass. If a necessary write is outside the request, +report that missing authorization and complete the read-only checks. + +Never use local ownership helpers against a remote server. Close only browser +sessions you created and clean up only test records whose removal is authorized. +Keep shared services running. Report production Cloud verification separately +from stable npm verification; the shipped artifacts and configuration differ. From 60bc0d5fee9a6e48db08fc016521fee3d9f57d67 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:12:11 -0500 Subject: [PATCH 03/85] fix(release): run native builds and canary smoke after skipped channels (#16149) ## Thinking Path > - Paperclip lets people manage AI agents for work. > - Published packages need the native runner artifacts from their exact source. > - The release workflow selects one channel and deliberately skips the other channels. > - The native producer used an implicit success condition across those skipped dependencies. > - The merged-source release selected native artifacts but skipped their build and canary publication. > - This pull request makes the existing producer and canary smoke conditions explicit. > - Successful source selection, artifact verification and cancellation checks still apply. ## Linked Issues or Issue Description Refs #16028. **What happened?** [Release 38389963483](https://github.com/paperclipai/paperclip/actions/runs/38389963483) passed source selection, then skipped native production, canary publication and onboarding smoke. The exact source script requires native artifacts; the API does not expose the source job outputs. The registry canary still points to an older source. **Expected behavior** The selected release channel builds its required native artifacts, publishes its verified package, then runs the exact-version onboarding smoke. Skipped inactive channels must not suppress these jobs. **Steps to reproduce** Inspect the linked master-push run at `d67e3507b23041212fa26302ce6de078c5ebcc05`. Compare the successful source job and exact source script with the three skipped jobs. The two existing workflow contract cases also fail against the original conditions. **Paperclip version or commit** `d67e3507b23041212fa26302ce6de078c5ebcc05`. **Deployment mode** GitHub Actions release workflow. ## What Changed - Require `!cancelled()`, successful source selection and `required=true` for native production. - Require `!cancelled()` and successful publication for canary onboarding smoke. - Extend the existing workflow tests for selected channels, skipped ancestors, missing outputs, failed jobs and cancellation. ## Verification - The two affected contract cases fail before treatment and pass after treatment. - Focused source controls cover 28 producer and five smoke condition combinations. - Independent source review found no changes to source, trust, artifact, channel, permission or publication gates. - These tests check source predicates. Actual hosted routing and publication must still pass after merge. - Full existing release suite: 70/70 pass. Existing source-verification neighbors: 20/20 pass. - Workflow structure passes actionlint. Full shell lint reports five hints in unchanged commands. - Full hosted checks and fresh Greptile review are pending. ## Risks The producer serves all release channels. The explicit source-result and required-output checks retain each channel's admission gates. Cancellation still prevents new work. This does not change runtime code, packages, agent selection, or stored configurations. [GitHub documents the implicit status check](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions) and [skipped dependency behavior](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-jobs). ## Model Used OpenAI Codex, based on GPT-6, with reasoning and code tools. This session does not expose a verifiable exact 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 - [ ] 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 Co-authored-by: Paperclip --- .github/workflows/release.yml | 4 +- .../release-verify-workflow.test.mjs | 40 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19d2853b4f5..a02b40609f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -377,7 +377,7 @@ jobs: release_runner_binaries: needs: release_runner_source - if: needs.release_runner_source.outputs.required == 'true' + if: ${{ !cancelled() && needs.release_runner_source.result == 'success' && needs.release_runner_source.outputs.required == 'true' }} uses: ./.github/workflows/release-verify.yml with: ref: ${{ needs.release_runner_source.outputs.sha }} @@ -525,7 +525,7 @@ jobs: # anyone mistakes an installable package for an onboardable one. smoke_canary_onboarding: needs: publish_canary - if: needs.publish_canary.result == 'success' + if: ${{ !cancelled() && needs.publish_canary.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index c6db590248a..5c172222631 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { runInNewContext } from "node:vm"; import { createServer } from "node:http"; import { canaryStartup } from "./canary-startup-fixture.mjs"; import { canRetryPublishedTarball404, TARBALL_METADATA_TIMEOUT_MS } from "../../tests/canary-onboarding/start-published-canary.mjs"; @@ -307,8 +308,20 @@ test("published canaries are gated by the exact-version onboarding browser smoke ); assert.match( releaseWorkflow, - /smoke_canary_onboarding:\n\s+needs: publish_canary\n\s+if: needs\.publish_canary\.result == 'success'/, + /smoke_canary_onboarding:\n\s+needs: publish_canary\n\s+if: \$\{\{ !cancelled\(\) && needs\.publish_canary\.result == 'success' \}\}/, ); + const smoke = releaseWorkflow.split(" smoke_canary_onboarding:\n")[1].split(/\n [a-z_]+:\n/)[0]; + const condition = smoke.match(/^ if: \$\{\{ (.+) \}\}$/m)?.[1]; + assert.match(condition, /^!cancelled\(\) && needs\.publish_canary\.result == 'success'$/); + for (const [result, cancelled, expected] of [ + ["success", false, true], ["failure", false, false], + ["cancelled", false, false], ["skipped", false, false], ["success", true, false], + ]) { + const needs = { publish_canary: { result }, release_runner_source: { result: "success" }, + select_nightly: { result: "skipped" }, select_beta: { result: "skipped" }, preflight_stable: { result: "skipped" } }; + assert.equal(runInNewContext(condition, { cancelled: () => cancelled, needs }), expected, + `Canary smoke: publisher=${result}, cancelled=${cancelled}`); + } assert.match( releaseWorkflow, /PAPERCLIPAI_VERSION: \$\{\{ needs\.publish_canary\.outputs\.canary_version \}\}/, @@ -772,6 +785,31 @@ test("release daemon production uses exact-source native hosts and no publisher assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`)); } const release = readWorkflow("release.yml"); + const source = release.split(" release_runner_source:\n")[1].split(/\n [a-z_]+:\n/)[0]; + const binaries = release.split(" release_runner_binaries:\n")[1].split(/\n [a-z_]+:\n/)[0]; + assert.match(source, /needs: \[verify_canary, select_nightly, select_beta, preflight_stable\]/); + assert.match(source, /!cancelled\(\)/); + assert.match(binaries, /needs: release_runner_source/); + assert.match(binaries, /ref: \$\{\{ needs\.release_runner_source\.outputs\.sha \}\}/); + assert.match(binaries, /runner_binaries_only: true/); + const condition = binaries.match(/^ if: (.+)$/m)?.[1].replace(/^\$\{\{\s*|\s*\}\}$/g, ""); + assert.match(condition, /^!cancelled\(\) && needs\.release_runner_source\.result == 'success' && needs\.release_runner_source\.outputs\.required == 'true'$/, + "Native compilation must bypass deliberately skipped channel ancestors and require successful exact-source selection"); + const laneJobs = ["verify_canary", "select_nightly", "select_beta", "preflight_stable"]; + for (const activeLane of laneJobs) { + for (const [result, required, cancelled, expected] of [ + ["success", "true", false, true], ["failure", "true", false, false], + ["cancelled", "true", false, false], ["skipped", "true", false, false], + ["success", "false", false, false], ["success", undefined, false, false], + ["success", "true", true, false], + ]) { + const needs = Object.fromEntries(laneJobs.map(job => [job, { result: job === activeLane ? "success" : "skipped" }])); + needs.release_runner_source = { result, outputs: { required } }; + assert.equal(Object.values(needs).filter(job => job.result === "skipped").length, result === "skipped" ? 4 : 3); + assert.equal(runInNewContext(condition, { cancelled: () => cancelled, needs }), expected, + `${activeLane}: source=${result}, required=${required}, cancelled=${cancelled}`); + } + } for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) { const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0]; assert.match(block, /needs: .*release_runner_binaries/); From eb06c9fedd5e940939ff789b452c04ee59384341 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:26:32 -0500 Subject: [PATCH 04/85] refactor(server): split chat channel workflows into provider and shared modules (#16080) Move provider operations into channel folders and shared chat work into named interaction, setup, admission, delivery and publication workflows. Consolidate reviewed PRs #16085, #16089, #16096 and #16105. Preserve upstream execution-default and recovery behavior. The main service is 7,917 lines. All 55 current-head CI checks pass, with two optional Storybook skips. Greptile is 5/5 with no findings. All 1,294 current chat integration cases, repository-wide typecheck and build pass locally. Broad local tool-route timing/socket failures are recorded in the PR. Co-Authored-By: Paperclip --- ...26-10-12-chat-channel-workflow-refactor.md | 159 + server/src/services/chat-channels.ts | 11682 +--------------- .../services/chat-channels/configuration.ts | 968 ++ .../src/services/chat-channels/credentials.ts | 507 + .../discord/publication-preparation.ts | 120 + .../chat-channels/discord/question-forms.ts | 189 + .../src/services/chat-channels/endpoints.ts | 476 + .../github/message-provenance.ts | 32 + .../services/chat-channels/github/setup.ts | 69 + .../imessage-photon/destination-admission.ts | 29 + .../imessage-photon/publications.ts | 285 +- .../chat-channels/imessage-photon/setup.ts | 88 + .../chat-channels/inbound/admission.ts | 399 + .../chat-channels/inbound/authorization.ts | 429 + .../inbound/conversation-queue.ts | 767 + .../chat-channels/inbound/delivery-ledger.ts | 241 + .../chat-channels/inbound/envelope.ts | 360 + .../chat-channels/inbound/lifecycle.ts | 1086 ++ .../inbound/resource-admission.ts | 113 + .../chat-channels/inbound/surfaces.ts | 180 + .../chat-channels/interactions/callbacks.ts | 2013 +++ .../interactions/continuations.ts | 432 + .../microsoft-teams/destination-admission.ts | 68 + .../chat-channels/microsoft-teams/routes.ts | 55 + .../chat-channels/provider-catalog.ts | 108 + .../publications/authorization.ts | 344 + .../chat-channels/publications/failures.ts | 328 + .../chat-channels/publications/order.ts | 36 + .../chat-channels/publications/policy.ts | 132 + .../chat-channels/publications/preparation.ts | 150 + .../chat-channels/publications/queue.ts | 389 + .../publications/task-control.ts | 149 + .../chat-channels/publications/transport.ts | 439 + .../chat-channels/publications/types.ts | 18 + .../chat-channels/publications/worker.ts | 1081 ++ .../services/chat-channels/runtime-types.ts | 25 + .../src/services/chat-channels/sdk-state.ts | 88 + .../src/services/chat-channels/slack/setup.ts | 89 + .../src/services/chat-channels/speko/setup.ts | 37 + .../telegram/confirmation-actions.ts | 656 + .../telegram/destination-admission.ts | 62 + .../telegram/publication-preparation.ts | 192 + .../services/chat-channels/telegram/setup.ts | 110 + 43 files changed, 13705 insertions(+), 11475 deletions(-) create mode 100644 doc/plans/2026-10-12-chat-channel-workflow-refactor.md create mode 100644 server/src/services/chat-channels/configuration.ts create mode 100644 server/src/services/chat-channels/credentials.ts create mode 100644 server/src/services/chat-channels/discord/publication-preparation.ts create mode 100644 server/src/services/chat-channels/discord/question-forms.ts create mode 100644 server/src/services/chat-channels/endpoints.ts create mode 100644 server/src/services/chat-channels/github/message-provenance.ts create mode 100644 server/src/services/chat-channels/github/setup.ts create mode 100644 server/src/services/chat-channels/imessage-photon/destination-admission.ts create mode 100644 server/src/services/chat-channels/imessage-photon/setup.ts create mode 100644 server/src/services/chat-channels/inbound/admission.ts create mode 100644 server/src/services/chat-channels/inbound/authorization.ts create mode 100644 server/src/services/chat-channels/inbound/conversation-queue.ts create mode 100644 server/src/services/chat-channels/inbound/delivery-ledger.ts create mode 100644 server/src/services/chat-channels/inbound/envelope.ts create mode 100644 server/src/services/chat-channels/inbound/lifecycle.ts create mode 100644 server/src/services/chat-channels/inbound/resource-admission.ts create mode 100644 server/src/services/chat-channels/inbound/surfaces.ts create mode 100644 server/src/services/chat-channels/interactions/callbacks.ts create mode 100644 server/src/services/chat-channels/interactions/continuations.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/destination-admission.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/routes.ts create mode 100644 server/src/services/chat-channels/provider-catalog.ts create mode 100644 server/src/services/chat-channels/publications/authorization.ts create mode 100644 server/src/services/chat-channels/publications/failures.ts create mode 100644 server/src/services/chat-channels/publications/order.ts create mode 100644 server/src/services/chat-channels/publications/policy.ts create mode 100644 server/src/services/chat-channels/publications/preparation.ts create mode 100644 server/src/services/chat-channels/publications/queue.ts create mode 100644 server/src/services/chat-channels/publications/task-control.ts create mode 100644 server/src/services/chat-channels/publications/transport.ts create mode 100644 server/src/services/chat-channels/publications/types.ts create mode 100644 server/src/services/chat-channels/publications/worker.ts create mode 100644 server/src/services/chat-channels/sdk-state.ts create mode 100644 server/src/services/chat-channels/slack/setup.ts create mode 100644 server/src/services/chat-channels/speko/setup.ts create mode 100644 server/src/services/chat-channels/telegram/confirmation-actions.ts create mode 100644 server/src/services/chat-channels/telegram/destination-admission.ts create mode 100644 server/src/services/chat-channels/telegram/publication-preparation.ts create mode 100644 server/src/services/chat-channels/telegram/setup.ts diff --git a/doc/plans/2026-10-12-chat-channel-workflow-refactor.md b/doc/plans/2026-10-12-chat-channel-workflow-refactor.md new file mode 100644 index 00000000000..0bb245a9afb --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-workflow-refactor.md @@ -0,0 +1,159 @@ +# Chat channel workflow refactor + +The user authorized six hours of sustained work starting at 2026-10-13 02:14 UTC, +with a target of fewer than 8,000 lines in `server/src/services/chat-channels.ts`. +The starting commit is `7eaa9cfa9ce04398a5eb0211a86aacaaa01959dd`: 19,153 lines, +762,442 bytes. At kickoff, the preceding ten PRs were reviewed and green, but not yet merged. +The user authorizes publishing and merging reviewed changes to paperclipai/paperclip. + +The previous plan prioritized too many small helper cuts. This plan prioritizes +complete workflows, provider ownership, and meaningful reductions per PR. Shared +transactions and admission policy belong in shared workflow modules. Provider +transport, envelope checks, setup, and native interactions belong in provider +folders. Do not put shared code in one provider's folder or use a mutable context +bag to move the entire service unchanged. + +| Priority | Responsibility and destination | Estimated net reduction | Contract and state | Risk and verification | +| --- | --- | ---: | --- | --- | +| 1 | Native interaction callbacks and continuation: `interactions/`, `telegram/confirmation-actions.ts`, `discord/question-forms.ts` | 2,900 | Current actor/runtime/destination readers; durable token and interaction transactions; deferred wakeups. | Preserve token replay, audit order, provider acknowledgement, post-commit recovery, and live fault barriers. Existing public service, HTTP and real-database interaction tests before and after. | +| 2 | Endpoint configuration and provider setup: provider `setup.ts`, shared credential persistence/configuration | 1,600–2,200 | Credential lease and current endpoint; provider registration outcome; stable native identity. | Preserve fail-closed persistence, webhook/receiver ownership, partial setup cleanup, and credential rotation races. Existing setup and runtime tests. | +| 3 | Message admission and provider policy: shared intake workflow, GitHub message policy, provider envelope projections | 2,400–3,000 | Delivery claim and conversation/task admission; explicit provider policy results; per-service message provenance. | Preserve row locks, deduplication, author authority, task generations, attachments, and GitHub automatic/explicit admission. Existing real-database ingress and replay tests. | +| 4 | Durable delivery and lifecycle: shared queue, provider lifecycle inputs | 1,700–2,000 | Conversation leases, replay decode, ordered drains, mutation reconciliation; shared live receipt state. | Preserve stale lease exclusion, event order, deletion/edit races, deferred startup and shutdown. Existing concurrency and lifecycle tests. | +| 5 | Outbound publication: provider delivery modules and shared publication worker | 2,700–3,400 | Current-source authorization; exact transport parts; attempts, unknown outcomes, completion receipts and cleanup. | Preserve no duplicate sends, ambiguous acceptance, credential fencing, file recovery, concurrency and current task-control completion. Existing assembled publication and file tests. | +| 6, if needed | Operator recovery and endpoint projections | 800–1,200 | Explicit replay/resolution and company-scoped board calls. | Preserve retry audit, current policy and authorization. Existing operator-resolution integration tests. | + +The estimates are not a PR quota. Inspect every full execution path before moving +it. Increase size only where a cohesive responsibility has a narrow contract. +After every reviewed slice, remap the remaining file and report the measured size. + +For each slice: run a behavioral baseline before editing production; retain +existing bodies and SQL; compare normalized AST and statement order; run server +typecheck, module boundaries and public-path tests. Review all intentional callback +and dispatch substitutions. Preserve live option reads with getters or callbacks. +Keep per-service state per service and shared cache/lease ownership shared. + +Open one PR per cohesive extraction with the full repository template. Verify its +current-head review and all CI gates before opening the next extraction. Build each +on its immediate predecessor. Run repository-wide typecheck, tests and build once +at the final handoff; investigate and disclose any failures or unreached coverage. +The earlier batch recorded local timing/host cleanup failures; green CI and passing +focused retries do not erase that evidence. No timeout or assertion weakening. + +Public application fixtures prove assembled service/HTTP/database behavior. They +do not prove live third-party execution. Use only owned fixture data and processes. + +## First extraction + +The native interaction workflow reduces the service from 19,153 to 16,117 lines +and from 762,442 to 656,146 bytes. Telegram owns confirmation resolution and +settlement. Discord owns correction draft retention and accepted-answer cleanup. +Shared callbacks retain token/source/actor checks and atomic question resolution. +Shared continuation work retains durable wakeup recovery. SDK state persistence +has a direct module import and retains the public compatibility export. + +The same 62 public-path interaction cases pass before and after. Seven supporting +suites pass all 70 cases. Server typecheck and module boundaries pass. AST checks +cover nine moved declarations, three provider bodies, and retained service order. +Fault barriers remain live getters. No assertion or timeout changed. + +## Second extraction + +Endpoint configuration and provider setup reduce the service to 14,241 lines and +580,227 bytes. Shared modules own endpoint records, credential identity/persistence, +and the credential-leased configuration state machine. Channel folders own Telegram +registration, GitHub repair, Speko tool installation, Photon configuration and +inspection, and Slack setup projection. Registration dependencies remain live. + +All 1,278 chat cases passed on the first PR before this move. The final setup wiring +passes 268 selected integration cases, server typecheck, and module boundaries. +AST checks cover 25 declarations and nine provider bodies. One final test attempt +could not start PostgreSQL because this Mac exhausted IPC slots; after reclaiming +one unused zero-attachment segment with a dead creator, the same suite passed. + +## Third extraction + +The publication workflow reduces the service to 11,198 lines and 462,108 bytes. +Authorization, durable preparation, transport, failures, attempt ownership, task +control completion, and bounded endpoint scheduling now have named modules. +Photon owns prompt and reply delivery. Discord and Telegram own durable Markdown +attachment preparation. Shared native splitting remains shared across providers. + +The same 301 public-path publication cases pass before and after. Eight supporting +suites pass all 185 cases. Server typecheck, module boundaries, and AST checks pass. +The AST audit covers 26 declarations, three provider bodies, and retained service +order. Shared endpoint lanes and Teams cooldowns retain their original maps; +shutdown and publication wake listeners stay live. Early callback registration +uses typed forwarding functions instead of reading factories before initialization. + +## Fourth extraction + +Ordered delivery and lifecycle reconciliation reduce the service to 9,287 lines +and 388,734 bytes. The delivery ledger owns ordering and runtime-fence projections. +The conversation queue owns exact leases, reconstruction and ordered replay. +The lifecycle workflow owns edit/delete admission and reconciliation. Teams owns +authenticated route recording. GitHub owns restoration of durable message policy +into the same per-service provenance maps. + +The same 225 lifecycle, ordering, lease, replay and recovery cases pass before and +after. Typecheck and module boundaries pass. AST checks cover 26 declarations, the +GitHub restoration body, and retained service order. Live maps and lease hooks retain +their original lifetime. The publication predecessor's packaging retry passed unchanged. This fourth +slice is published as PR #16096. + + +## Fifth extraction + +Inbound authority, envelope preparation, durable admission and resource admission +reduce the service to 7,899 lines and 335,607 bytes. The shared admission module +keeps endpoint locking, runtime and credential fences, gateway lease renewal, +deduplication and delivery insertion in one transaction. Teams, Telegram and +Photon own their destination rules and use that same transaction. The main +message path now calls preparation, durable admission and resource admission +before the retained task workflow. + +The same 278 selected public-path cases pass before and after the first move. +All 1,278 existing chat integration cases pass on the final provider dispatch +in 309.47 seconds. Server typecheck and module boundaries pass. One full-suite attempt +could not allocate PostgreSQL shared memory before any test ran. Only a verified +unused 56-byte segment with a dead creator and zero attachments was reclaimed +before retrying. No active database was stopped. + +AST checks cover 21 moved declarations, envelope and resource phase bodies, +three provider destination bodies, and the complete admission transaction after +restoring the explicit provider calls. The service still owns the per-call +gateway ownership handoff. The envelope retains its per-call recovery map and +the original authenticated runtime. No assertion or timeout changed. + +## Stack landing + +The preceding ten PRs reached master through #16058. The five workflow PRs are +#16080, #16085, #16089, #16096 and #16105. Each extraction has individual behavioral +evidence and a 5/5 review. The four child PRs merged into their predecessors after +each combined revision passed its own checks and review. Every consolidation +preserved the tested tip tree exactly. + +The final master integration leaves the service at 7,917 lines and 337,195 bytes. +The 18-line increase over the extraction tip preserves upstream execution-default +selection and authorization. Endpoint serialization and credential-leased updates +retain that policy in the extracted endpoint module. Task creation and failed-run +recovery retain their upstream behavior. All 107 extracted declarations match +current master after reversing the documented binding substitutions. The endpoint, +task-creation and recovery functions also pass exact normalized AST comparisons. + +Repository-wide typecheck and build pass on this integration. The first full local +test command passed 18,391 server tests and skipped 105, but one unchanged tool-access +test hit its 15-second timeout. That case passed alone. The UI and CLI groups passed +8,706 tests. Shared and adapter groups passed 4,751 tests and skipped 20. A database +group could not initialize one PostgreSQL fixture. This Mac has a 32-segment shared +memory limit and concurrent fixture databases. The unchanged database cases are +being rerun one file at a time before the remaining chat and serialized route lanes. +Keep the original failed runs in the verification record. No assertion or timeout +was changed. The root PR remains open for fresh review and CI against master. + +The service now has 67 direct functions. The largest retained paths are message +task handling (1,449 lines), webhook dispatch (685), operator action resolution +(488), and publication resolution (343). Provider setup, transport preparation, +native interactions and destination admission live in their channel folders. +Shared admission, delivery, lifecycle and publication transactions have named +workflow modules. The retained message path calls those phases and keeps task +mutation and per-call ownership together. diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index ea9742c0bcf..f4fd375d29e 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,22 +1,52 @@ +import { PROVIDER_LABELS, CAPABILITIES } from "./chat-channels/provider-catalog.js"; +import { createMessageResourceAdmission } from "./chat-channels/inbound/resource-admission.js"; +import type { DiscordGatewayOwnership } from "./chat-channels/runtime-types.js"; +import { createInboundAdmission } from "./chat-channels/inbound/admission.js"; +import { createInboundEnvelope } from "./chat-channels/inbound/envelope.js"; +import { sameTeamsThreadIdentity, durableExternalThreadIdentity, teamsThreadRootMessageId, canonicalProviderResourceId, nonDirectDestinationAllowed, linearControlCommand, actionThreadMatchesConversation } from "./chat-channels/inbound/surfaces.js"; +import { createChatInboundAuthority } from "./chat-channels/inbound/authorization.js"; +import { createTeamsRoutes } from "./chat-channels/microsoft-teams/routes.js"; +import type { LiveInboundMessage } from "./chat-channels/runtime-types.js"; +import { DELIVERY_PROCESSING_STALE_MS, normalizedDeliveryThreadId, normalizedLifecycleEffect, deliveryReady, pendingInboundWakeupCondition, lifecycleRuntimeFence, normalizedLifecycleWithRuntimeFence } from "./chat-channels/inbound/delivery-ledger.js"; +import { createChatMessageLifecycle } from "./chat-channels/inbound/lifecycle.js"; +import { createChatConversationDeliveryQueue } from "./chat-channels/inbound/conversation-queue.js"; +import { publicationTransportOrderKey } from "./chat-channels/publications/order.js"; +import { createChatPublicationTaskControl } from "./chat-channels/publications/task-control.js"; +import { createChatPublicationTransport } from "./chat-channels/publications/transport.js"; +import { createChatPublicationAuthorization } from "./chat-channels/publications/authorization.js"; +import { createChatPublicationFailures } from "./chat-channels/publications/failures.js"; +import { createChatPublicationPreparation } from "./chat-channels/publications/preparation.js"; +import { createChatPublicationWorker } from "./chat-channels/publications/worker.js"; +import { createChatPublicationQueue } from "./chat-channels/publications/queue.js"; +import { isExplicitOperatorPublication } from "./chat-channels/publications/policy.js"; + +import { createChatCredentials } from "./chat-channels/credentials.js"; +import { createChatEndpoints } from "./chat-channels/endpoints.js"; +import { createChatConfiguration } from "./chat-channels/configuration.js"; +import { createPhotonSetupInspection } from "./chat-channels/imessage-photon/setup.js"; +import { createChatSdkStatePersistence } from "./chat-channels/sdk-state.js"; +export { createChatSdkStatePersistence } from "./chat-channels/sdk-state.js"; +import { createChatInteractionCallbacks } from "./chat-channels/interactions/callbacks.js"; +import { createChatInteractionContinuations } from "./chat-channels/interactions/continuations.js"; import { createGitHubInstallationLifecycle } from "./chat-channels/github/installation-lifecycle.js"; import { createGitHubAppSetup } from "./chat-channels/github/app-setup.js"; import { createSlackTaskAuthorityRegistration } from "./chat-channels/slack/task-authority.js"; import { createGitHubPublicationPolicy } from "./chat-channels/github/publication-policy.js"; import { createTelegramAttachmentFailure } from "./chat-channels/telegram/attachment-failure.js"; -import { createSlackCommands, slackCommandForAgent } from "./chat-channels/slack/commands.js"; +import { createSlackCommands } from "./chat-channels/slack/commands.js"; import { createTelegramCommands, telegramGuidanceCommand } from "./chat-channels/telegram/commands.js"; import { createTeamsFileDelivery } from "./chat-channels/microsoft-teams/file-delivery.js"; import { createGitHubSetupSecret } from "./chat-channels/github/setup-secret.js"; -import { prepareSlackInventory, createSlackInventory } from "./chat-channels/slack/inventory.js"; -import { prepareGitHubInventory } from "./chat-channels/github/inventory.js"; -import { prepareDiscordInventory } from "./chat-channels/discord/inventory.js"; -import type { VerifiedProviderIdentity } from "./chat-channels/runtime-types.js"; -import { verifySlackCredentials } from "./chat-channels/slack/credentials.js"; -import { verifyDiscordCredentials } from "./chat-channels/discord/credentials.js"; -import { verifyTelegramCredentials } from "./chat-channels/telegram/credentials.js"; -import { verifyGitHubCredentials, REQUIRED_GITHUB_EVENTS, UNAVOIDABLE_GITHUB_EVENTS } from "./chat-channels/github/credentials.js"; -import { verifySpekoCredentials } from "./chat-channels/speko/credentials.js"; -import { verifyTeamsCredentials } from "./chat-channels/microsoft-teams/credentials.js"; +import { createSlackInventory } from "./chat-channels/slack/inventory.js"; + + + + + + +import { REQUIRED_GITHUB_EVENTS, UNAVOIDABLE_GITHUB_EVENTS } from "./chat-channels/github/credentials.js"; + + import { createPhotonCredentialChecks } from "./chat-channels/imessage-photon/credentials.js"; import { slackTestMessageService } from "./connectors/slack/test-message.js"; import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; @@ -24,19 +54,19 @@ import { isProvisionedSlackSetup } from "@paperclipai/shared"; import { createSetupProbes } from "./chat-channels/setup-probes.js"; import { createChatActivity, publicationSummary } from "./chat-channels/activity.js"; import { fileTransferProjectionColumns } from "./chat-channels/microsoft-teams/file-transfer-projection.js"; -import { createProviderEffects, providerEffectPayload, type ProviderEffectTarget, type ProviderEffectPayload } from "./chat-channels/provider-effects.js"; +import { createProviderEffects, providerEffectPayload, type ProviderEffectTarget } from "./chat-channels/provider-effects.js"; import { createTelegramCallbackNoticeAuthority } from "./chat-channels/telegram/callback-notices.js"; -import { createOutboundAttachments, isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError } from "./chat-channels/outbound-attachments.js"; +import { createOutboundAttachments } from "./chat-channels/outbound-attachments.js"; export { hydrateOutboundAttachment } from "./chat-channels/outbound-attachments.js"; -import { discordMarkdownAttachment } from "./chat-channels/discord/messages.js"; -import { telegramMarkdownAttachment } from "./chat-channels/telegram/messages.js"; + + import { createInboundAttachments } from "./chat-channels/inbound-attachments.js"; import { createInboundReactions } from "./chat-channels/reactions.js"; -import { slackThreadChannelId } from "./chat-channels/slack/messages.js"; + import { createReceiptReactions } from "./chat-channels/receipt-reactions.js"; import { createDiscordRootThreads } from "./chat-channels/discord/root-threads.js"; import { createDiscordGatewayEvents } from "./chat-channels/discord/gateway-events.js"; -import { createSlackCallbackVerification, slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback } from "./chat-channels/slack/callback-verification.js"; +import { createSlackCallbackVerification, slackRequestSignatureIsValid, slackRequestWorkspaceId, slackCallbackMatchesPublicUrl, inspectSlackCallback } from "./chat-channels/slack/callback-verification.js"; import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; @@ -47,40 +77,30 @@ import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterTh import { createGitHubWebhookIngress, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; import { createSlackSessionStop } from "./chat-channels/slack/session-stop.js"; import { createTelegramMaintenance } from "./chat-channels/telegram/maintenance.js"; -import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext, SlackCallbackSurface, InternalSetupState } from "./chat-channels/runtime-types.js"; +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext, InternalSetupState } from "./chat-channels/runtime-types.js"; import { createChatFailedRunRetry } from "./chat-channels/failed-run-retry.js"; import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js"; import { createChatIdentityLinks } from "./chat-channels/identity-links.js"; import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js"; -import { baseTeamsConversationId } from "./chat-channels/microsoft-teams/resources.js"; + import { slackResourceLabelIsFallback } from "./chat-channels/slack/resources.js"; -import { telegramResourceLabelIsFallback } from "./chat-channels/telegram/resources.js"; + import { MAX_INBOUND_TEXT, stableExternalPrincipalId, - type LifecycleActor, - lifecycleActorFromAuthor, } from "./chat-channels/message-lifecycle.js"; import { telegramLifecycleEventFromPayload, isTelegramEditedMessageRaw, isTelegramMigrationRaw, - telegramMessageSequence, telegramZeroMessageId, telegramDeliveryHasZeroMessageId, - telegramMessageSentAt, } from "./chat-channels/telegram/messages.js"; -import { - slackLifecycleFilesDigest, - slackMessageSentAt, -} from "./chat-channels/slack/messages.js"; -import { - discordLifecycleFilesDigest, -} from "./chat-channels/discord/messages.js"; + + import { microsoftTeamsLifecycleEventFromPayload, - teamsActivitySentAt, } from "./chat-channels/microsoft-teams/messages.js"; import { type GitHubLifecycleEvent, @@ -89,8 +109,8 @@ import { import { dueChatAction, nextChatActionAt, SLACK_COMMAND_POST_STALE_MS, SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS } from "./chat-action-work.js"; import type { ScheduledChatAction } from "./chat-work-notifications.js"; import { notifyChatDeliveryWork, notifyChatPublicationWork, notifyChatEndpointWork, notifyChatActionWork, notifyChatVerificationWork } from "./chat-work-notifications.js"; -import { syncSpekoVoiceTools } from "./voice/speko-agent-tools.js"; -import { configureSpekoSessionTools } from "./voice/speko-tool-setup.js"; + + import { voiceSessionService } from "./voice/voice-session-service.js"; import { createSpekoProvider } from "./voice/speko-provider.js"; import { currentVoiceCredentialFingerprint, voiceSessionStore } from "./voice/voice-session-store.js"; @@ -98,14 +118,12 @@ import { createChatRuntime, type ChatRuntime } from "./chat-runtime.js"; import type { ChatEndpointRuntime } from "./voice/voice-runtime.js"; import { parseMarkdown } from "chat"; import { githubPublicAttachmentsFromMessage } from "./chat-github-attachments.js"; -import { - stageReceiptReactionRemovals, -} from "./chat-receipt-reactions.js"; + import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js"; import { fastResponseRequests } from "@paperclipai/db"; -import { chatCredentialMutationLease, CREDENTIAL_MUTATION_LEASE_TTL_MS, type CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js"; +import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js"; import type { AgentAvatarRequest } from "./agent-avatars.js"; -import { slackChatRegistrationService, slackRegistrationProjection } from "./chat-slack-registration.js"; +import { slackChatRegistrationService } from "./chat-slack-registration.js"; import { chatSlackRegistrations } from "@paperclipai/db"; import { canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js"; @@ -113,15 +131,15 @@ import { withSlackBoardLease } from "./slack-board-lease.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { authorizeSlackBoardPublication } from "./slack-board-authority.js"; import { assertSlackBoardWorkAllowed } from "./slack-board-resume.js"; -import { slackExplicitPublicationDuplicate, unresolvedSlackPublicationCondition } from "./connectors/slack-publication.js"; + import { rememberVerifiedSlackSearchEvent } from "./connectors/slack-search-context.js"; -import { slackPublicationAllowed } from "./connectors/slack-access.js"; + import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; -import { assertChatExecutionDefaultsAccess, assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; +import { assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } import { githubReviewCheckService } from "./chat-github-checks.js"; @@ -131,20 +149,19 @@ import { githubAutomaticAdmission, githubPreviousAssessment, githubExplicitMentionEvent, - githubMessageAddressesAnotherBot, } from "./chat-github-events.js"; import { githubReviewPrompt, githubManualMessagePrompt, GITHUB_CONFIGURED_SKILL_GUIDANCE } from "./chat-github-review-policy.js"; -import { chatGitHubConfigurations, chatGitHubReviews } from "@paperclipai/db"; +import { chatGitHubReviews } from "@paperclipai/db"; import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubAutomaticEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; import { githubChatReviewService } from "./chat-github-reviews.js"; import { githubResponseCommentService } from "./chat-github-response-comments.js"; -import { githubChatPrincipalAccess } from "./chat-github-access.js"; -import { githubAppJwt } from "./chat-github-client.js"; + + import { resumeExternalConversation } from "./slack-conversation-state.js"; -import { settleExternalConversation } from "./slack-conversation-lifecycle.js"; + import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; @@ -152,18 +169,17 @@ import { PhotonState } from "./photon/state.js"; -import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; -import { PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; -import { PhotonChatAdapter, photonReplyReference } from "./photon/adapter.js"; -import { photonChannelConfigurationSchema } from "@paperclipai/shared"; -import { defaultSlackAppConfiguration } from "@paperclipai/shared"; + +import { photonReplyReference } from "./photon/adapter.js"; + + + import { createHash, createHmac, - randomBytes, randomUUID, timingSafeEqual, } from "node:crypto"; @@ -172,30 +188,22 @@ import { asc, desc, eq, - gt, inArray, - isNotNull, isNull, - like, - lt, lte, ne, notExists, notInArray, or, sql, - type SQL, } from "drizzle-orm"; -import { alias, type AnyPgColumn } from "drizzle-orm/pg-core"; +import { alias } from "drizzle-orm/pg-core"; import { recordChatWebhookReceipt, recordChatWebhookStage, } from "./chat-webhook-diagnostics.js"; import { - canonicalTeamsThreadId, - externalThreadIdentityCondition, readChatControlChronology, - teamsConversationId, } from "./chat-control-chronology.js"; import { retryChatControlAdmission } from "./chat-control-admission-retry.js"; import type { Db } from "@paperclipai/db"; @@ -217,48 +225,33 @@ import { chatIdentityLinks, chatMessageLinks, chatPublications, - chatSdkState, chatTeamsFileTransfers, companyMemberships, - companySecretBindings, heartbeatRuns, issueComments, issueAttachments, issueThreadInteractions, issues, - toolApplications, toolConnections, } from "@paperclipai/db"; import type { - ChatAdapterCapabilities, - ChatEndpointCallbackSurfaces, ChatEventKind, - ChatEndpoint, ChatEndpointSetupState, ChatProvider, ChatPublicationBatchStatus, - ConfigureChatEndpointInput, - CreateChatEndpointInput, ExternalChannelBindingSummary, SafeChatPublicationPayload, ToolCredentialSecretRef, - UpdateChatEndpointInput, } from "@paperclipai/shared"; import { - isAgentStatusInvokable, isUuidLike, LOW_TRUST_REVIEW_PRESET, LOW_TRUST_REVIEW_PRESET_VERSION, LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION, } from "@paperclipai/shared"; -import { - MAX_ATTACHMENT_BYTES, - normalizeContentType, -} from "../attachment-types.js"; -import { isUniqueViolation } from "../db-errors.js"; -import { - deriveTeamsPersonalRecipient, -} from "./chat-teams-personal-recipient.js"; + + + import { type TeamsFileTransferOptions, } from "./chat-teams-file-transfers.js"; @@ -269,18 +262,11 @@ import { projectChatPublicationBatch, } from "./chat-publication-batches.js"; -import { telegramAttachmentForUpload } from "./chat-telegram-photo.js"; -import { - readTelegramCallbackProvenance, - telegramCallbackThreadId, - TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, -} from "./chat-telegram-ephemeral.js"; -import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; -import { - isTelegramDraftStopped, - type TelegramDraftControl, -} from "./chat-telegram-draft-stop.js"; + + + + import { parseChatWebhookPublicBaseUrl } from "../chat-webhook-public-url.js"; @@ -299,20 +285,12 @@ import type { StorageService } from "../storage/types.js"; import { type ChatSdkCallbackEvent, type ChatSdkMessageCallbackEvent, - type ChatSdkMessageUpdatedCallbackEvent, type ChatSdkProvider, type ChatSdkRuntime, type DiscordGatewayCallbackEvent, - type ResolvedChatSdkProviderConfig, - type SlackFileUploadAcceptedReceipt, } from "./chat-sdk-runtime.js"; -import type { - ChatSdkStateCompareAndSetInput, - ChatSdkStateDeleteInput, - ChatSdkStatePersistence, - ChatSdkStateScope, -} from "./chat-sdk-state.js"; + import { logActivity, publishActivity, @@ -324,59 +302,34 @@ import { } from "./issue-assignment-wakeup.js"; import { issueService } from "./issues.js"; import { - authorizeNativeChatReviewPresentation, NativeChatReviewPresentationContentionError, } from "./native-runtime/native-chat-review-presentation.js"; import { isExternalChatWaitAuthorizationContention } from "./native-runtime/chat-attachment-reuse.js"; import { projectSafeChatPublication } from "./chat-publication-projection.js"; -import { safeChatTaskUrl } from "./chat-task-url.js"; -import { - resyncGitHubAppWebhook, -} from "./chat-github-webhook-config.js"; + + import { githubAttachmentLimitOmissions, - restoreGitHubAttachmentLimitOmissions, } from "./chat-github-attachments.js"; import { getExternalChannelBindingSummary } from "./chat-channel-binding.js"; -import { type ChatProviderInventoryResult } from "./chat-provider-inventory.js"; -import { - discordMarkdownRequiresAttachment, -} from "./chat-discord.js"; -import { - renderPublicationTransportText, - splitNativePublicationText, -} from "./chat-publication-text-parts.js"; + + + import { readRegisteredDiscordCommandRegistration, } from "./chat-discord-command-registration-store.js"; -import { - deleteDiscordQuestionFormCorrection, - discordQuestionFormCorrectionModal, - discordQuestionFormDenialResponse, - discordQuestionFormThreadId, - isDiscordQuestionFormCorrectionId, - loadDiscordQuestionFormCorrection, - retainDiscordQuestionFormCorrection, -} from "./chat-discord-question-forms.js"; + import { parseSlackSessionStop, } from "./chat-slack-sessions.js"; -import { - stageSlackSessionSync, -} from "./chat-slack-session-outbox.js"; + import { parseChatProviderLifecycle, type ChatProviderLifecycleEffect, } from "./chat-provider-lifecycle.js"; -import { - enqueueTerminalIssueInteractionChatPublications, - nativeChatQuestion, - nativeTelegramConfirmation, - TELEGRAM_CALLBACK_DATA_LIMIT_BYTES, - telegramChatSdkCallbackData, -} from "./chat-interaction-publications.js"; -import { chatProviderConversationUrl } from "./chat-provider-links.js"; -import { classifyChatPublicationError } from "./chat-publication-errors.js"; + + + import { inboundWakePublicationKey, @@ -384,177 +337,21 @@ import { parseInboundWakePublicationKey, resolveInboundWakeReceipt, } from "./chat-inbound-wakeup-publications.js"; -import { - normalizeMicrosoftTeamsCredentialIds, -} from "./chat-teams-credentials.js"; -import { - prepareTeamsInlineImage, -} from "./chat-teams-inline-images.js"; -import { - shouldStreamSafePublicationText, - splitTelegramPublicationText, - streamSafePublicationText, - telegramMarkdownRequiresAttachment, -} from "./chat-publication-stream.js"; -import { issueThreadInteractionService } from "./issue-thread-interactions.js"; + + + + import { questionResponseDeliveryService, type QuestionResponseDeliveryServiceOptions, } from "./question-response-delivery.js"; -import { - chatQuestionFormDenialResponse, - chatQuestionFormValidationResponse, - claimChatQuestionFormSubmission, - completeChatQuestionFormSubmission, - isChatQuestionFormOpenActionId, - loadChatQuestionFormSubmissionToken, - resolveChatQuestionFormOpen, - validateChatQuestionFormSubmission, -} from "./chat-question-forms.js"; + import { secretService } from "./secrets.js"; import type { - ActionEvent, - AdapterPostableMessage, - Attachment, - Author, - FileUpload, Message, - MessageDeletedEvent, - ModalResponse, - ModalSubmitEvent, SlashCommandEvent, Thread, } from "chat"; -import { Actions, Button, Card, CardText, LinkButton } from "chat"; - -const PROVIDER_LABELS: Record = { - speko: "Speko", - "imessage-photon": "iMessage Photon", - agentmail: "AgentMail", - slack: "Slack", - github: "GitHub", - discord: "Discord", - "microsoft-teams": "Microsoft Teams", - telegram: "Telegram", -}; - -type PublicationOrderTable = { - createdAt: AnyPgColumn; - id: AnyPgColumn; - payload: AnyPgColumn; -}; - -function publicationTransportOrderKey(table: PublicationOrderTable) { - return sql`coalesce(${table.payload}->'transportPart'->>'orderKey', ${table.id}::text)`; -} - -function publicationPrecedes( - earlier: PublicationOrderTable, - later: PublicationOrderTable, -) { - return or( - lt(earlier.createdAt, later.createdAt), - and( - eq(earlier.createdAt, later.createdAt), - lt( - publicationTransportOrderKey(earlier), - publicationTransportOrderKey(later), - ), - ), - ); -} - -const CAPABILITIES: Record = { - speko: { threads: false, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: false, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, - "imessage-photon": { threads: false, directMessages: true, nativeStreaming: false, messageEdits: true, messageDeletes: false, reactions: false, files: true, cards: false, actions: true, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, - agentmail: { threads: true, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: true, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: true }, - slack: { - threads: true, - directMessages: true, - nativeStreaming: true, - messageEdits: true, - messageDeletes: true, - reactions: true, - files: true, - cards: true, - actions: true, - modals: true, - slashCommands: true, - ephemeralMessages: true, - proactiveDirectMessages: false, - }, - github: { - threads: true, - directMessages: false, - nativeStreaming: false, - messageEdits: true, - messageDeletes: true, - reactions: true, - files: false, - cards: true, - actions: false, - modals: false, - slashCommands: false, - ephemeralMessages: false, - proactiveDirectMessages: false, - }, - discord: { - threads: true, - directMessages: true, - nativeStreaming: false, - messageEdits: true, - messageDeletes: true, - reactions: true, - files: true, - cards: true, - actions: true, - modals: true, - // Automatic registration upgrades these only after a durable provider - // receipt. A draft or an uncertain/conflicting registration stays false. - slashCommands: false, - ephemeralMessages: false, - proactiveDirectMessages: false, - }, - "microsoft-teams": { - threads: true, - directMessages: true, - // Production webhook processing is deferred into Paperclip's durable - // ingress queue. The Teams adapter's request-scoped DM streamer is gone by - // the time agent output is published, so advertise the durable behavior we - // can actually provide. editMessage still lets one run coalesce its - // queued, working, and final states in place. - nativeStreaming: false, - // Paperclip supplements the pinned adapter's missing inbound Bot Framework - // messageUpdate/messageDelete dispatch after provider authentication. - messageEdits: true, - messageDeletes: true, - reactions: true, - files: true, - cards: true, - actions: true, - modals: true, - slashCommands: false, - ephemeralMessages: true, - proactiveDirectMessages: false, - }, - telegram: { - threads: true, - directMessages: true, - nativeStreaming: true, - messageEdits: true, - // Telegram's Bot API does not emit an update when a user deletes a - // message, so this capability cannot be offered truthfully. - messageDeletes: false, - reactions: true, - files: true, - cards: true, - actions: true, - modals: false, - slashCommands: false, - ephemeralMessages: false, - proactiveDirectMessages: false, - }, -}; const REQUIRED_CREDENTIALS: Record< Exclude, @@ -590,7 +387,6 @@ const SUPPLIED_CREDENTIAL_KEYS: Record = { }; const MAX_ERROR_TEXT = 2_000; -const DELIVERY_PROCESSING_STALE_MS = 60_000; // A Discord root mention is durably staged before the adapter creates its // provider thread. Give the bounded provider retry loop ample time to finish; // if the process disappears, the delivery worker verifies that thread over @@ -650,133 +446,9 @@ function credentialFingerprint(refs: ToolCredentialSecretRef[]): string { ); return createHash("sha256").update(JSON.stringify(stable)).digest("hex"); } - -const ATTACHMENT_FAILURE_NOTICE_PREFIX = "attachment-failure-notice:"; - -function attachmentFailureNoticeFence( - idempotencyKey: string, -): LifecycleRuntimeFence | null { - const match = - /^attachment-failure-notice:[0-9a-f-]{36}:(\d+):([0-9a-f]{64})$/i.exec( - idempotencyKey, - ); - if (!match) return null; - const generation = Number(match[1]); - if (!Number.isSafeInteger(generation) || generation < 0) return null; - return { generation, credentialFingerprint: match[2]!.toLowerCase() }; -} - -function definiteAttachmentProviderRejection(error: unknown): boolean { - const pending: Array<{ depth: number; value: unknown }> = [ - { depth: 0, value: error }, - ]; - const seen = new Set(); - const statuses: number[] = []; - const names: string[] = []; - const codes: string[] = []; - while (pending.length > 0) { - const current = pending.shift(); - if ( - !current || - current.depth > 4 || - !current.value || - typeof current.value !== "object" || - seen.has(current.value) - ) { - continue; - } - seen.add(current.value); - const record = current.value as { - cause?: unknown; - code?: unknown; - details?: { code?: unknown; providerStatus?: unknown }; - innerHttpError?: { statusCode?: unknown }; - name?: unknown; - original?: unknown; - originalError?: unknown; - response?: { status?: unknown }; - status?: unknown; - statusCode?: unknown; - }; - for (const status of [ - record.status, - record.statusCode, - record.response?.status, - record.details?.providerStatus, - record.innerHttpError?.statusCode, - ]) { - if (typeof status === "number" && Number.isInteger(status)) { - statuses.push(status); - } - } - if (typeof record.name === "string") names.push(record.name); - for (const code of [record.code, record.details?.code]) { - if (typeof code === "string") codes.push(code); - } - for (const nested of [ - record.cause, - record.original, - record.originalError, - ]) { - pending.push({ depth: current.depth + 1, value: nested }); - } - } - if ( - names.some((name) => - ["ValidationError", "NotImplementedError"].includes(name), - ) || - codes.some((code) => - [ - "CHAT_ADAPTER_COMPATIBILITY_ERROR", - "CHAT_PROVIDER_PRETRANSPORT_REJECTED", - "NOT_IMPLEMENTED", - "VALIDATION_ERROR", - ].includes(code), - ) - ) { - return false; - } - // Slack's Web API can reject a file operation with `ok: false` in an HTTP - // 200 response. The SDK's structured platform-error code is still a - // definitive provider rejection; the main disposition classifier has - // already excluded auth, destination, and rate-limit outcomes. - if (codes.includes("slack_webapi_platform_error")) return true; - return statuses.some( - (status) => - status >= 400 && - status < 500 && - ![401, 403, 404, 410, 429].includes(status), - ); -} - -function attachmentFailureKind( - payload: SafeChatPublicationPayload, -): - | { kind: "generated_response"; provider: "discord" | "telegram" } - | { kind: "selected_file"; provider: null } - | null { - if (payload.transportPart?.mode === "discord_markdown_attachment") { - return { kind: "generated_response", provider: "discord" }; - } - if (payload.transportPart?.mode === "telegram_markdown_attachment") { - return { kind: "generated_response", provider: "telegram" }; - } - if (payload.attachmentIds?.length) { - return { kind: "selected_file", provider: null }; - } - return null; -} type ConversationRow = typeof chatConversations.$inferSelect; type DeliveryRow = typeof chatDeliveries.$inferSelect; -type LiveInboundMessage = { - endpoint: EndpointRow; - message: Message; - receiptReactionSupported: boolean; - providerUrl: string | null; - runtimeContext?: InboundRuntimeContext; - thread: Thread; - trigger: ChatSdkMessageCallbackEvent["trigger"]; -}; + export interface ChatChannelServiceOptions { /** @@ -903,165 +575,6 @@ export interface ChatChannelServiceOptions { storage?: StorageService; } -type DiscordGatewayOwnership = { - companyId: string; - context: RuntimeContext; - endpointId: string; - expiresAt: Date; - leaseKey: typeof DISCORD_GATEWAY_LEASE_KEY | "photon_receiver_runtime"; - renewTimer: ReturnType | null; - renewal: Promise | null; - stopPromise: Promise | null; - stopping: boolean; - token: string; -}; - -function iso(value: Date | null | undefined): string | null { - return value ? value.toISOString() : null; -} - -function providerResourceType( - provider: ChatProvider, - surfaceKind: ChatSurfaceKind, -): string { - if (surfaceKind === "direct_message") return "direct_message"; - if (provider === "github") return "repository"; - if (provider === "imessage-photon") return "group_chat"; - if (provider === "discord") return "channel"; - if (provider === "microsoft-teams") - return surfaceKind === "linear_group" ? "group_chat" : "channel"; - // Telegram topics are task/thread boundaries inside one group resource. The - // operator grants access to the chat once rather than having to rediscover - // and enable every topic independently. - if (provider === "telegram") return "chat"; - return "channel"; -} - -type ChatSurfaceKind = "direct_message" | "linear_group" | "native_thread"; - - -function sameTeamsThreadIdentity(left: string, right: string): boolean { - const leftConversationId = teamsConversationId(left); - const rightConversationId = teamsConversationId(right); - return ( - leftConversationId !== null && - rightConversationId !== null && - leftConversationId === rightConversationId - ); -} - - -function durableExternalThreadIdentity(threadId: string): string { - return canonicalTeamsThreadId(threadId) ?? threadId; -} - - -function microsoftTeamsServiceUrl(raw: unknown): string | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const value = (raw as { serviceUrl?: unknown }).serviceUrl; - return typeof value === "string" && value ? value : null; -} - -function teamsThreadRootMessageId(threadId: string): string | null { - const conversationId = teamsConversationId(threadId); - if (!conversationId) return null; - return /;messageid=([^;]+)/i.exec(conversationId)?.[1] ?? null; -} - -function canonicalProviderResourceId( - provider: ChatProvider, - thread: Pick, -): string { - if (provider === "slack") return thread.channelId.replace(/^slack:/, ""); - if (provider === "github") - return thread.channelId.replace(/^github:/, "").toLowerCase(); - if (provider === "discord") { - const parts = thread.channelId.split(":"); - return parts[2] ?? thread.channelId; - } - if (provider === "microsoft-teams") { - const conversationId = - teamsConversationId(thread.channelId) ?? teamsConversationId(thread.id); - return conversationId - ? baseTeamsConversationId(conversationId) - : baseTeamsConversationId(thread.channelId); - } - if (provider === "telegram") - return thread.channelId.replace(/^telegram:/, ""); - return thread.channelId; -} - -function providerResourceLabelFromThread( - provider: ChatProvider, - thread: Pick, -): { label: string; fallback: boolean } { - const providerResourceId = canonicalProviderResourceId(provider, thread); - const candidate = thread.channel.name?.trim(); - if (!candidate) return { label: providerResourceId, fallback: true }; - if ( - (provider === "slack" && - slackResourceLabelIsFallback(providerResourceId, candidate)) || - (provider === "telegram" && - telegramResourceLabelIsFallback(providerResourceId, candidate)) - ) { - return { label: providerResourceId, fallback: true }; - } - return { label: candidate, fallback: false }; -} - -/** - * Native channel threads keep one issue forever. Linear surfaces instead - * advance through Paperclip task generations because their provider id is - * reused after a task completes or the user explicitly starts a new task. - */ -function chatSurfaceKind( - provider: ChatProvider, - thread: Thread, -): ChatSurfaceKind { - if (thread.isDM) return "direct_message"; - if (provider === "imessage-photon") return "linear_group"; - if (provider === "telegram") { - return /^telegram:[^:]+:[^:]+$/.test(thread.id) - ? "native_thread" - : "linear_group"; - } - if (provider === "microsoft-teams") { - const conversationId = teamsConversationId(thread.id); - return conversationId?.includes(";messageid=") - ? "native_thread" - : "linear_group"; - } - return "native_thread"; -} - -/** - * Teams group chats are admitted as a class: installation at the provider and - * the endpoint's group-chat toggle are the two gates the operator can - * actually control. Teams channels, and every other non-DM destination, - * retain the explicit per-resource enablement gate shown in Settings. - */ -function nonDirectDestinationAllowed( - endpoint: EndpointRow, - resource: ResourceRow | null | undefined, -): boolean { - if (!resource || resource.availability !== "available") return false; - if ( - endpoint.provider === "microsoft-teams" && - resource.type === "group_chat" - ) { - return endpoint.allowGroupChats; - } - return resource.enabled; -} - -function linearControlCommand(text: string): "new" | "close" | "status" | null { - const match = /^\/(new|close|status)(?:@[\w.-]+)?\s*$/i.exec(text.trim()); - return ( - (match?.[1]?.toLowerCase() as "new" | "close" | "status" | undefined) ?? - null - ); -} - function safeTitle(text: string, fallback: string): string { const line = text .replace(/<@[A-Z0-9]+>/gi, "") @@ -1071,22 +584,6 @@ function safeTitle(text: string, fallback: string): string { return (line || fallback).slice(0, 160); } -function actionThreadMatchesConversation( - provider: ChatProvider, - actionThreadId: string, - conversationThreadId: string, -): boolean { - if (provider === "microsoft-teams") { - return sameTeamsThreadIdentity(actionThreadId, conversationThreadId); - } - if (provider !== "slack") return actionThreadId === conversationThreadId; - const actionChannelId = slackThreadChannelId(actionThreadId); - return ( - actionChannelId !== null && - actionChannelId === slackThreadChannelId(conversationThreadId) - ); -} - function sanitizeFilename(value: string | undefined): string | null { if (!value) return null; const leaf = value.replaceAll("\\", "/").split("/").pop()?.trim(); @@ -1105,57 +602,6 @@ function redactError(error: unknown): string { return redactSensitiveText(withoutTelegramBotTokens).slice(0, MAX_ERROR_TEXT); } -async function attemptProviderPublication( - send: () => Promise, -): Promise { - return await send(); -} - -async function editOrPostProviderPublication( - edit: () => Promise, - post: () => Promise, -): Promise { - try { - return await edit(); - } catch (error) { - // A definite 404 proves the old progress comment no longer exists. It is - // therefore safe to create a replacement without risking a duplicate. - // Ambiguous transport failures must still enter delivery_unknown. - if (classifyChatPublicationError(error, 1).kind !== "resource_unavailable") - throw error; - return await post(); - } -} - -function safeCardForPublication( - payload: SafeChatPublicationPayload, - provider: ChatProvider, -) { - if (!payload.card) return null; - const children = []; - if (payload.card.body) children.push(CardText(payload.card.body)); - const actions = (payload.card.actions ?? []).map((action) => - action.type === "callback" - ? Button({ - id: action.actionId, - label: action.label, - style: action.style ?? "default", - // Telegram encodes both id and value into callback_data, whose hard - // limit is 64 bytes. Its opaque action id resolves through the - // durable token ledger, so repeating the interaction UUID is both - // unnecessary and too large. Other adapters retain the value as a - // defense-in-depth binding supported by their larger envelopes. - value: provider === "telegram" ? undefined : payload.interactionId, - actionType: isChatQuestionFormOpenActionId(action.actionId) - ? "modal" - : undefined, - }) - : LinkButton({ label: action.label, url: action.url }), - ); - if (actions.length) children.push(Actions(actions)); - return Card({ title: payload.card.title, children }); -} - function absoluteBaseUrl(value: string | null | undefined): string | null { if (!value) return null; try { @@ -1182,187 +628,6 @@ function isSupportedTelegramWebhookBaseUrl(value: string | null): boolean { } } -function providerSetupState( - endpoint: Pick< - EndpointRow, - | "provider" - | "providerAccountId" - | "publicId" - | "status" - | "setup" - | "botUsername" - >, - publicBaseUrl: string | null, - assignedAgentName?: string | null, -) { - const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; - const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null; - const step = endpoint.status === "active" ? "complete" : endpoint.setup.step; - switch (endpoint.provider) { - case "speko": - return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const; - case "imessage-photon": return { step, providerUrl: "https://photon.codes/", testStartedAt: endpoint.setup.testStartedAt } as const; - case "agentmail": return endpoint.setup; - case "slack": { - const observations = (endpoint.setup as InternalSetupState) - .slackCallbackSurfaces; - const currentUrl = webhookUrl ? canonicalCallbackUrl(webhookUrl) : null; - const surfaceState = ( - surface: SlackCallbackSurface, - ): ChatEndpointCallbackSurfaces[SlackCallbackSurface] => { - const observation = observations?.[surface]; - if (!observation) return { status: "unverified" }; - return { - status: - currentUrl !== null && - slackCallbackMatchesPublicUrl(observation.url, currentUrl) - ? "current" - : "stale", - observedAt: observation.observedAt, - }; - }; - const callbackSurfaces: ChatEndpointCallbackSurfaces = { - events: surfaceState("events"), - interactivity: surfaceState("interactivity"), - slashCommands: surfaceState("slashCommands"), - }; - return { - step, - testStartedAt: endpoint.setup.testStartedAt ?? null, - testSkipped: endpoint.setup.testSkipped ?? false, - authorizationUrl: "https://api.slack.com/apps?new_app=1", - providerUrl: "https://app.slack.com/", - webhookUrl, - webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, - callbackSurfaces, - callbacksNeedUpdate: Object.values(callbackSurfaces).some( - (surface) => surface.status === "stale", - ), - slackApp: endpoint.setup.slackApp, - slackSetupMethod: endpoint.setup.slackSetupMethod, - slackAvatar: endpoint.setup.slackAvatar, - slackManagerError: endpoint.setup.slackManagerError, - slackAccount: endpoint.setup.slackAccount, - // Slack registers the slash command in the provider configuration. - // Keep that identity immutable when the assigned agent is renamed; - // deriving it remains only a compatibility path for older rows. - command: - typeof endpoint.setup.command === "string" - ? endpoint.setup.command - : assignedAgentName - ? slackCommandForAgent(assignedAgentName, endpoint.publicId) - : null, - } as const; - } - case "github": - return { - step, - github: endpoint.setup.github, - testStartedAt: endpoint.setup.testStartedAt, - testSkipped: endpoint.setup.testSkipped, - authorizationUrl: "https://github.com/settings/apps/new", - providerUrl: "https://github.com/settings/installations", - webhookUrl, - webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, - } as const; - case "discord": - return { - step, - authorizationUrl: "https://discord.com/developers/applications", - providerUrl: endpoint.providerAccountId - ? `https://discord.com/channels/${encodeURIComponent(endpoint.providerAccountId)}` - : "https://discord.com/channels/@me", - webhookUrl, - } as const; - case "microsoft-teams": - return { - step, - authorizationUrl: "https://dev.teams.microsoft.com/apps", - providerUrl: "https://teams.microsoft.com/", - messagingEndpoint: webhookUrl, - } as const; - case "telegram": - return { - step, - providerUrl: endpoint.botUsername - ? `https://t.me/${encodeURIComponent(endpoint.botUsername)}` - : "https://t.me/BotFather", - webhookUrl, - } as const; - } -} - -export function createChatSdkStatePersistence(db: Db): ChatSdkStatePersistence { - return { - async read(scope: ChatSdkStateScope, key: string) { - return db - .select({ - value: chatSdkState.value, - version: chatSdkState.version, - expiresAt: chatSdkState.expiresAt, - }) - .from(chatSdkState) - .where( - and( - eq(chatSdkState.companyId, scope.companyId), - eq(chatSdkState.endpointId, scope.endpointId), - eq(chatSdkState.stateKey, key), - ), - ) - .then((rows) => rows[0] ?? null); - }, - async compareAndSet(input: ChatSdkStateCompareAndSetInput) { - if (input.expectedVersion === null) { - const inserted = await db - .insert(chatSdkState) - .values({ - companyId: input.companyId, - endpointId: input.endpointId, - stateKey: input.key, - version: 1, - value: input.value, - expiresAt: input.expiresAt, - }) - .onConflictDoNothing() - .returning({ id: chatSdkState.id }); - return inserted.length === 1; - } - const updated = await db - .update(chatSdkState) - .set({ - version: input.expectedVersion + 1, - value: input.value, - expiresAt: input.expiresAt, - updatedAt: new Date(), - }) - .where( - and( - eq(chatSdkState.companyId, input.companyId), - eq(chatSdkState.endpointId, input.endpointId), - eq(chatSdkState.stateKey, input.key), - eq(chatSdkState.version, input.expectedVersion), - ), - ) - .returning({ id: chatSdkState.id }); - return updated.length === 1; - }, - async deleteIfVersion(input: ChatSdkStateDeleteInput) { - const deleted = await db - .delete(chatSdkState) - .where( - and( - eq(chatSdkState.companyId, input.companyId), - eq(chatSdkState.endpointId, input.endpointId), - eq(chatSdkState.stateKey, input.key), - eq(chatSdkState.version, input.expectedVersion), - ), - ) - .returning({ id: chatSdkState.id }); - return deleted.length === 1; - }, - }; -} - export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const runtime = options.runtime ?? createChatRuntime(); const githubManualMessages = new WeakMap(); @@ -1385,10 +650,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { version: string; } >(); + const { recordCurrentMicrosoftTeamsRoute } = createTeamsRoutes(db, { runtime, runtimeCallbackEndpoint }); const persistence = createChatSdkStatePersistence(db); const fetchImpl = options.fetch ?? globalThis.fetch; const { inspectPhotonCredentials, verifyPhotonCredentials } = createPhotonCredentialChecks(fetchImpl); - const { hydrateSlackResourceLabel } = createSlackInventory(db, { endpointRecord, resolveCredentials, fetchImpl }); + const { hydrateSlackResourceLabel } = createSlackInventory(db, { endpointRecord, resolveCredentials: (...args) => resolveCredentials(...args), fetchImpl }); const configuredPublicBaseUrl = absoluteBaseUrl(options.publicBaseUrl); const configuredWebhookPublicBaseUrl = parseChatWebhookPublicBaseUrl(options.webhookPublicBaseUrl); // A warm Cloud instance is constructed before it receives its signed claim. @@ -1404,6 +670,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const getTaskBaseUrl = () => runtimeCanonicalOrigin() ?? options.publicBaseUrl; const getWebhookPublicBaseUrl = () => configuredWebhookPublicBaseUrl ?? getPublicBaseUrl(); const voiceStore = voiceSessionStore(db, { allowLocalBoard: options.allowLocalVoiceBoard === true }); + const { findAuthorizedIdentityLink, ensurePrincipal, sponsorAllowsGuest, lockCurrentPrincipalAuthorization, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, ensureResource } = createChatInboundAuthority(db, { voiceStore, runtimeCallbackEndpoint }); const voice = voiceSessionService(db, { allowLocalBoard: options.allowLocalVoiceBoard === true, credentials: async (endpoint, expectedFingerprint) => { @@ -1421,6 +688,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const issuesSvc = issueService(db); const { runIdFromMilestonePublication, runOwnershipMilestoneSupersessionReason, runPublicationToReplace, receiptReactionCompletionRunId, inboundWakePublicationToReplace, interactionResolutionPublicationToReplace, interactionPromptPublicationToReplace, progressSupersededByFastResponse, runProgressSupersededByPublishedInteraction, taskStatusPublicationToReplace, closeProgressToReplace } = createChatPublicationReplacement(db, { endpointRecord, authorizeRetainedChatSourcePublication }); const secrets = secretService(db); + const { normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, legacyGitHubLabelsMatchVerifiedCredentials, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, clearCredentials, resolveCredentialRefs, resolveCredentials, prepareProviderInventory } = createChatCredentials(db, { + secrets, PROVIDER_LABELS, REQUIRED_CREDENTIALS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, + fetchImpl, redactError, credentialFingerprint, verifyPhotonCredentials, + }); + const { inspectPhoton } = createPhotonSetupInspection(db, { endpointRecord, inspectPhotonCredentials }); const questionResponses = questionResponseDeliveryService(db, { heartbeat: options.heartbeat as QuestionResponseDeliveryServiceOptions["heartbeat"], @@ -2051,34 +1323,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { : null; } - async function recordCurrentMicrosoftTeamsRoute( - endpoint: EndpointRow, - context: InboundRuntimeContext | undefined, - threadId: string, - raw: unknown, - recordAcceptedActivity = true, - ): Promise { - if (endpoint.provider !== "microsoft-teams" || !context?.endpointRuntime) { - return; - } - const serviceUrl = microsoftTeamsServiceUrl(raw); - if (!serviceUrl) return; - await db.transaction(async (tx) => { - const current = await runtimeCallbackEndpoint(tx, endpoint.id, context, [ - "verifying", - "active", - ]); - if (!current || runtime.get(endpoint.id) !== context.endpointRuntime) { - return; - } - await context.endpointRuntime.recordMicrosoftTeamsRoute( - threadId, - serviceUrl, - recordAcceptedActivity ? raw : undefined, - ); - }); - } - function discordGatewayRuntimeIsCurrent( endpointId: string, context: RuntimeContext, @@ -2095,762 +1339,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function serializeEndpoint( - row: Awaited>, - ): ChatEndpoint { - if (!row) throw notFound("Chat endpoint not found"); - const endpoint = row.endpoint; - return { - id: endpoint.id, - companyId: endpoint.companyId, - connectionId: endpoint.connectionId, - publicationMode: endpoint.publicationMode, - externalExecutionPolicy: endpoint.externalExecutionPolicy, - provider: endpoint.provider, - publicId: endpoint.publicId, - status: endpoint.status, - deploymentMode: endpoint.deploymentMode, - assignedAgentId: endpoint.assignedAgentId, - assignedAgentName: row.assignedAgentName, - sponsorUserId: endpoint.sponsorUserId, - providerAccountId: endpoint.providerAccountId, - providerAccountLabel: endpoint.providerAccountLabel, - botExternalId: endpoint.botExternalId, - botUsername: endpoint.botUsername, - botLabel: endpoint.botDisplayName ?? row.assignedAgentName, - botAvatarUrl: endpoint.botAvatarUrl, - communicationInstructions: endpoint.communicationInstructions, - executionDefaults: endpoint.executionDefaults, - ...(endpoint.provider === "imessage-photon" && endpoint.botExternalId ? { photonAllocation: endpoint.botExternalId.startsWith("photon-project:") ? "shared" as const : "dedicated" as const } : {}), - allowDirectMessages: endpoint.allowDirectMessages, - requireAtMention: endpoint.requireAtMention, - allowGroupChats: endpoint.allowGroupChats, - allowUnlinkedPeople: endpoint.allowUnlinkedPeople, - replyMode: "subscribed", - capabilities: endpoint.capabilities, - setup: { - ...providerSetupState( - endpoint, - getWebhookPublicBaseUrl(), - row.assignedAgentName, - ), - ...(endpoint.provider === "github" - ? { - webhookSecretConfigured: row.credentialSecretRefs.some( - (ref) => ref.configPath === "credentials.webhookSecret", - ), - } - : endpoint.provider === "speko" ? { - webhookSecretConfigured: row.credentialSecretRefs.some( - (ref) => ref.configPath === "credentials.signingSecret", - ), - } : {}), - }, - healthMessage: endpoint.healthMessage, - lastError: endpoint.lastError, - lastActivityAt: iso(endpoint.lastEventAt), - lastPublicationAt: iso(endpoint.lastPublicationAt), - activatedAt: iso(endpoint.activatedAt), - createdAt: endpoint.createdAt.toISOString(), - updatedAt: endpoint.updatedAt.toISOString(), - }; - } - - async function list(companyId: string) { - const rows = await db - .select({ - endpoint: chatEndpoints, - assignedAgentName: agents.name, - connectionName: toolConnections.name, - applicationId: toolConnections.applicationId, - credentialSecretRefs: toolConnections.credentialSecretRefs, - }) - .from(chatEndpoints) - .innerJoin( - agents, - and( - eq(agents.id, chatEndpoints.assignedAgentId), - eq(agents.companyId, chatEndpoints.companyId), - ), - ) - .innerJoin( - toolConnections, - and( - eq(toolConnections.id, chatEndpoints.connectionId), - eq(toolConnections.companyId, chatEndpoints.companyId), - ), - ) - .where( - and( - eq(chatEndpoints.companyId, companyId), - ne(chatEndpoints.status, "archived"), - ), - ) - .orderBy(desc(chatEndpoints.updatedAt)); - return rows.map((row) => serializeEndpoint(row)); - } - - async function get(endpointId: string) { - const result = serializeEndpoint(await endpointRecord(endpointId)); - if (result.provider === "slack") { - const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); - result.setup.slackRegistration = registration ? slackRegistrationProjection(registration) : undefined; - const publicOrigin = getPublicBaseUrl(); - result.setup.slackOAuthCallbackUri = publicOrigin ? `${publicOrigin}/api/chat-slack/oauth/callback` : null; - } - return result; - } - - async function create( - companyId: string, - input: CreateChatEndpointInput, - actorUserId?: string | null, - ) { - if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); - if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); - if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); - const agent = await db - .select({ id: agents.id, name: agents.name, status: agents.status }) - .from(agents) - .where( - and( - eq(agents.companyId, companyId), - eq(agents.id, input.assignedAgentId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!agent) - throw unprocessable("The selected agent does not belong to this company"); - if (!isAgentStatusInvokable(agent.status)) { - throw unprocessable( - "The selected agent must be active before it can be connected to chat", - { - code: "chat_agent_not_invokable", - }, - ); - } - - const endpointId = randomUUID(); - const publicId = randomBytes(32).toString("base64url"); - const applicationId = input.applicationId ?? randomUUID(); - const connectionId = randomUUID(); - const suffix = endpointId.slice(0, 8); - const name = ( - input.name?.trim() || `${PROVIDER_LABELS[input.provider]} — ${agent.name}` - ).slice(0, 145); - await db.transaction(async (tx) => { - if (input.applicationId) { - const application = await tx - .select({ - id: toolApplications.id, - applicationKey: toolApplications.applicationKey, - metadata: toolApplications.metadata, - status: toolApplications.status, - }) - .from(toolApplications) - .where( - and( - eq(toolApplications.companyId, companyId), - eq(toolApplications.id, input.applicationId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!application) throw notFound("App not found"); - const sourceTemplateKey = - typeof application.metadata.sourceTemplateKey === "string" - ? application.metadata.sourceTemplateKey - : null; - if ( - application.status === "archived" || - (application.applicationKey !== input.provider && - sourceTemplateKey !== input.provider) - ) { - throw unprocessable( - `The selected App is not the ${PROVIDER_LABELS[input.provider]} connector`, - ); - } - } else { - await tx.insert(toolApplications).values({ - id: applicationId, - companyId, - applicationKey: `chat:${input.provider}:${endpointId}`, - name: `${name} ${suffix}`, - description: `People talk to ${agent.name} through ${PROVIDER_LABELS[input.provider]}.`, - type: "chat", - status: "draft", - metadata: { sourceTemplateKey: input.provider, purpose: "channel" }, - ownerUserId: actorUserId ?? null, - }); - } - await tx.insert(toolConnections).values({ - id: connectionId, - companyId, - applicationId, - name, - uid: `chat-${input.provider}-${endpointId}`, - connectionKind: "managed", - connectionPurpose: "channel", - ownership: "customer", - transport: input.provider === "speko" ? "voice" : "chat_sdk", - authKind: "api_key", - credentialPolicy: "shared", - status: "draft", - enabled: false, - config: { provider: input.provider }, - transportConfig: {}, - createdByUserId: actorUserId ?? null, - }); - await tx.insert(chatEndpoints).values({ - id: endpointId, - companyId, - connectionId, - provider: input.provider, - publicId, - assignedAgentId: agent.id, - sponsorUserId: actorUserId ?? null, - // Group chat is a broad Teams reach grant rather than a discovered - // destination toggle. Keep it closed until the operator explicitly - // enables that surface, even when this process is running against a - // database created before the column default was hardened. - requireAtMention: input.provider === "slack", - allowGroupChats: !["microsoft-teams", "speko"].includes(input.provider), - allowUnlinkedPeople: !["slack", "imessage-photon", "speko"].includes(input.provider), - capabilities: CAPABILITIES[input.provider], - setup: { - step: "provider_setup", - ...(input.provider === "slack" - ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), - command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? slackRegistration.managerGrants.defaultMethod() } - : {}), - }, - }); - }); - await logActivity(db, { - companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.created", - entityType: "tool_connection", - entityId: connectionId, - details: { - endpointId, - provider: input.provider, - assignedAgentId: agent.id, - }, - }); - return get(endpointId); - } - - async function update( - endpointId: string, - input: UpdateChatEndpointInput, - actorUserId?: string | null, - ) { - const initial = await endpointRecord(endpointId); - if (!initial) throw notFound("Chat endpoint not found"); - if (initial.endpoint.provider === "agentmail" && Object.keys(input).some((key) => key !== "executionDefaults")) throw badRequest("Use the email inbox API for AgentMail"); - await withCredentialMutationLease( - initial.endpoint, - async (credentialLease) => { - // Reach changes share the provider-transport lane with publications. - // Once a disable returns, no send that sampled the previous grant can - // still begin or finish behind it. - const existing = await endpointRecord(endpointId); - if (!existing) throw notFound("Chat endpoint not found"); - const values: Partial = { - updatedAt: new Date(), - }; - if (input.communicationInstructions !== undefined) { - if (existing.endpoint.provider !== "slack") { - throw unprocessable("Communication instructions are currently supported for Slack connections"); - } - values.communicationInstructions = input.communicationInstructions; - } - if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); - if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) { - throw conflict("Setup method can only change before connecting the app"); - } - if (input.slackApp || input.slackSetupMethod) { - const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); - if (registration && (registration.managerGrantId || registration.status !== "failed")) { - if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); - // Manual recovery fences pending authorization under the same credential lease. - await slackRegistration.cleanup(endpointId, credentialLease); - } - } - if (input.slackSetupMethod) { - values.setup = { ...existing.endpoint.setup, slackSetupMethod: input.slackSetupMethod }; - } - if (input.slackApp) { - if (existing.endpoint.provider !== "slack") { - throw unprocessable("Slack app details only apply to Slack connections"); - } - if (existing.endpoint.status !== "draft" || existing.endpoint.botExternalId) { - throw conflict("Slack app details can only be edited before connecting the app"); - } - values.setup = { - ...(values.setup ?? existing.endpoint.setup), - slackApp: input.slackApp, - command: input.slackApp.command, - }; - } - if (input.allowDirectMessages !== undefined) - values.allowDirectMessages = input.allowDirectMessages; - if (input.requireAtMention !== undefined) { - if (existing.endpoint.provider !== "slack") - throw unprocessable("Require at-mention only applies to Slack connections"); - values.requireAtMention = input.requireAtMention; - } - if (input.allowGroupChats && existing.endpoint.provider === "imessage-photon" && (!existing.endpoint.botExternalId || existing.endpoint.botExternalId.startsWith("photon-project:"))) - throw unprocessable("Photon shared channels support direct messages only; groups require a dedicated channel"); - if (input.allowGroupChats !== undefined) - values.allowGroupChats = input.allowGroupChats; - if (input.allowUnlinkedPeople !== undefined) - values.allowUnlinkedPeople = input.allowUnlinkedPeople; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - if (input.executionDefaults !== undefined) { - await assertChatExecutionDefaultsAccess(db, { - companyId: existing.endpoint.companyId, - assigneeAgentId: values.assignedAgentId ?? existing.endpoint.assignedAgentId, - actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, - defaults: input.executionDefaults, - }, tx); - values.executionDefaults = input.executionDefaults; - } - await tx - .update(chatEndpoints) - .set(values) - .where(eq(chatEndpoints.id, endpointId)); - await logActivity(tx as unknown as Db, { - companyId: existing.endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.updated", - entityType: "tool_connection", - entityId: existing.endpoint.connectionId, - details: { endpointId, fields: Object.keys(input) }, - }); - await credentialLease.assertOwned(tx); - }); - }, - ); - return get(endpointId); - } - - async function normalizedCredentials( - endpoint: EndpointRow, - supplied: Record | undefined, - ) { - const values = await resolveCredentials(endpoint).catch( - () => ({}) as Record, - ); - for (const [key, rawValue] of Object.entries(supplied ?? {})) { - if (typeof rawValue === "string" && rawValue.trim()) - values[key] = key === "privateKey" ? rawValue : rawValue.trim(); - } - if (endpoint.provider === "telegram" && !values.webhookSecret) { - values.webhookSecret = randomBytes(32).toString("hex"); - } - const required = - endpoint.provider === "github" - ? ["appId", "privateKey", "webhookSecret"] - : REQUIRED_CREDENTIALS[endpoint.provider]; - const missing = required.filter((key) => !values[key]); - if (missing.length > 0) { - throw unprocessable( - `Missing required ${PROVIDER_LABELS[endpoint.provider]} credentials: ${missing.join(", ")}`, - ); - } - return endpoint.provider === "microsoft-teams" - ? normalizeMicrosoftTeamsCredentialIds(values) - : values; - } - - async function verifyCredentials( - provider: ChatProvider, - credentials: Record, - ): Promise { - if (provider === "imessage-photon") return verifyPhotonCredentials(credentials); - if (provider === "slack") return verifySlackCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); - if (provider === "discord") return verifyDiscordCredentials(credentials, fetchImpl, redactError); - if (provider === "telegram") return verifyTelegramCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); - if (provider === "github") return verifyGitHubCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); - if (provider === "speko") return verifySpekoCredentials(credentials, fetchImpl); - return verifyTeamsCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); - } - - function nativeBotIdentityKey( - provider: ChatProvider, - identity: VerifiedProviderIdentity, - ): string | null { - const account = identity.providerAccountId?.trim().toLowerCase(); - const bot = identity.botExternalId?.trim().toLowerCase(); - if (provider === "github" || provider === "discord") { - if (bot) return `${provider}:app:${bot}`; - return null; - } - if (!account || !bot) return null; - return `${provider}:${account}:${bot}`; - } - - function providerUsesGlobalApplicationIdentity( - provider: ChatProvider, - ): boolean { - return ( - provider === "github" || - provider === "discord" || - provider === "microsoft-teams" || provider === "imessage-photon" - ); - } - - function nativeBotIdentityMatches( - provider: ChatProvider, - current: VerifiedProviderIdentity, - incoming: VerifiedProviderIdentity, - ): boolean { - const currentKey = nativeBotIdentityKey(provider, current); - return ( - currentKey !== null && - currentKey === nativeBotIdentityKey(provider, incoming) - ); - } - - function legacyGitHubLabelsMatchVerifiedCredentials( - current: VerifiedProviderIdentity, - verified: VerifiedProviderIdentity, - ): boolean { - return ( - !current.botExternalId && - Boolean(verified.botExternalId) && - current.providerAccountId?.trim().toLowerCase() === - verified.providerAccountId?.trim().toLowerCase() && - current.botUsername?.trim().toLowerCase() === - verified.botUsername?.trim().toLowerCase() - ); - } - - async function assertNativeBotIdentityAvailable( - endpoint: EndpointRow, - identity: VerifiedProviderIdentity, - ): Promise { - const key = nativeBotIdentityKey(endpoint.provider, identity); - if (!key) { - throw unprocessable( - `${PROVIDER_LABELS[endpoint.provider]} did not return a stable native bot identity`, - ); - } - const candidates = await db - .select({ - id: chatEndpoints.id, - companyId: chatEndpoints.companyId, - assignedAgentId: chatEndpoints.assignedAgentId, - providerAccountId: chatEndpoints.providerAccountId, - botExternalId: chatEndpoints.botExternalId, - botUsername: chatEndpoints.botUsername, - }) - .from(chatEndpoints) - .where( - and( - providerUsesGlobalApplicationIdentity(endpoint.provider) - ? undefined - : eq(chatEndpoints.companyId, endpoint.companyId), - eq(chatEndpoints.provider, endpoint.provider), - ne(chatEndpoints.id, endpoint.id), - endpoint.provider === "imessage-photon" ? ne(chatEndpoints.status, "archived") : inArray(chatEndpoints.status, [ - "verifying", - "active", - "paused", - "attention", - ]), - ), - ); - const conflictEndpoint = candidates.find((candidate) => { - if (!providerUsesGlobalApplicationIdentity(endpoint.provider)) { - return nativeBotIdentityKey(endpoint.provider, candidate) === key; - } - const candidateAppId = candidate.botExternalId?.trim().toLowerCase(); - const incomingAppId = identity.botExternalId?.trim().toLowerCase(); - if (candidateAppId && incomingAppId) - return candidateAppId === incomingAppId; - return ( - candidate.providerAccountId?.trim().toLowerCase() === - identity.providerAccountId?.trim().toLowerCase() && - candidate.botUsername?.trim().toLowerCase() === - identity.botUsername?.trim().toLowerCase() - ); - }); - if (conflictEndpoint) { - throw conflict( - `This ${PROVIDER_LABELS[endpoint.provider]} bot already represents another Paperclip agent connection`, - conflictEndpoint.companyId === endpoint.companyId - ? { - code: "chat_bot_identity_in_use", - endpointId: conflictEndpoint.id, - assignedAgentId: conflictEndpoint.assignedAgentId, - } - : { code: "chat_bot_identity_in_use" }, - ); - } - } - - function isNativeBotIdentityUniqueViolation(error: unknown): boolean { - return ( - isUniqueViolation(error, "chat_endpoints_photon_number_uq") || - isUniqueViolation(error, "chat_endpoints_live_bot_external_uq") || - isUniqueViolation(error, "chat_endpoints_live_discord_bot_external_uq") || - isUniqueViolation( - error, - "chat_endpoints_live_global_app_bot_external_uq", - ) || - isUniqueViolation(error, "chat_endpoints_live_bot_username_uq") - ); - } - - function nativeBotIdentityConflict(provider: ChatProvider) { - return conflict( - `This ${PROVIDER_LABELS[provider]} bot already represents another Paperclip agent connection`, - { code: "chat_bot_identity_in_use" }, - ); - } - - async function persistCredentials( - endpoint: EndpointRow, - credentials: Record, - credentialLease: CredentialMutationLeaseGuard, - actorUserId?: string | null, - ) { - await credentialLease.assertOwned(); - const previousRefs = await db - .select({ refs: toolConnections.credentialSecretRefs }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .then((rows) => rows[0]?.refs ?? []); - const refs: ToolCredentialSecretRef[] = []; - try { - for (const [key, value] of Object.entries(credentials)) { - if (endpoint.provider === "imessage-photon" && key !== "projectSecret") continue; - await credentialLease.assertOwned(); - const suffix = randomUUID(); - const secret = await secrets.create( - endpoint.companyId, - { - name: `chat.${endpoint.provider}.${endpoint.id.slice(0, 8)}.${key}.${suffix.slice(0, 8)}`, - key: `CHAT_${endpoint.provider.replaceAll("-", "_")}_${endpoint.id.replaceAll("-", "_")}_${key}_${suffix}`.toUpperCase(), - provider: "local_encrypted", - managedMode: "paperclip_managed", - value, - description: `${PROVIDER_LABELS[endpoint.provider]} channel credential for endpoint ${endpoint.id}`, - }, - { userId: actorUserId ?? undefined }, - ); - refs.push({ - secretId: secret.id, - versionSelector: "latest", - configPath: `credentials.${key}`, - required: true, - label: key, - projectionClass: "unclassified", - }); - await credentialLease.assertOwned(); - } - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ refs: toolConnections.credentialSecretRefs }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Chat connection not found"); - if ( - credentialFingerprint(current.refs) !== - credentialFingerprint(previousRefs) - ) { - throw conflict("Chat credentials changed during rotation", { - code: "chat_endpoint_credentials_changed", - }); - } - await tx - .delete(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, endpoint.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq(companySecretBindings.targetId, endpoint.connectionId), - ), - ); - if (refs.length > 0) { - await tx.insert(companySecretBindings).values( - refs.map((ref) => ({ - companyId: endpoint.companyId, - secretId: ref.secretId, - targetType: "tool_connection", - targetId: endpoint.connectionId, - configPath: ref.configPath, - versionSelector: String(ref.versionSelector ?? "latest"), - required: ref.required, - label: ref.label ?? null, - projectionClass: ref.projectionClass ?? "unclassified", - projectionAllowlistKey: ref.projectionAllowlistKey ?? null, - })), - ); - } - await tx - .update(toolConnections) - .set({ credentialSecretRefs: refs, updatedAt: new Date() }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - } catch (error) { - // A lost database acknowledgement can be ambiguous even when the - // transaction committed. Delete only secrets proven unbound after the - // failure; when that proof cannot be read, leak safely for later cleanup - // instead of deleting a possibly-authoritative credential. - const boundSecretIds = - refs.length === 0 - ? new Set() - : await db - .select({ secretId: companySecretBindings.secretId }) - .from(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, endpoint.companyId), - inArray( - companySecretBindings.secretId, - refs.map((ref) => ref.secretId), - ), - ), - ) - .then((rows) => new Set(rows.map((row) => row.secretId))) - .catch(() => null); - if (boundSecretIds) { - await Promise.allSettled( - refs - .filter((ref) => !boundSecretIds.has(ref.secretId)) - .map((ref) => secrets.remove(ref.secretId)), - ); - } - throw error; - } - const currentIds = new Set(refs.map((ref) => ref.secretId)); - const retired = previousRefs.filter((ref) => !currentIds.has(ref.secretId)); - const results = await Promise.allSettled( - retired.map((ref) => secrets.remove(ref.secretId)), - ); - if (results.some((result) => result.status === "rejected")) { - logger.warn( - { endpointId: endpoint.id }, - "chat credential rotation left an unbound retired secret for later cleanup", - ); - } - return refs; - } - - async function clearCredentials( - endpoint: EndpointRow, - credentialLease: CredentialMutationLeaseGuard, - ) { - let refs: ToolCredentialSecretRef[] = []; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ refs: toolConnections.credentialSecretRefs }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Chat connection not found"); - refs = current.refs; - await tx - .delete(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, endpoint.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq(companySecretBindings.targetId, endpoint.connectionId), - ), - ); - await tx - .update(toolConnections) - .set({ credentialSecretRefs: [], updatedAt: new Date() }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - const results = await Promise.allSettled( - refs.map((ref) => secrets.remove(ref.secretId)), - ); - if (results.some((result) => result.status === "rejected")) { - logger.warn( - { endpointId: endpoint.id }, - "chat endpoint removal left an unbound retired secret for later cleanup", - ); - } - } - - async function resolveCredentialRefs( - endpoint: EndpointRow, - refs: ToolCredentialSecretRef[], - database: DbOrTransaction = db, - ): Promise> { - const values: Record = {}; - for (const ref of refs) { - const key = ref.configPath.replace(/^credentials\./, ""); - values[key] = await (database === db ? secrets : secretService(database as Db)).resolveSecretValue( - endpoint.companyId, - ref.secretId, - ref.versionSelector ?? "latest", - { - consumerType: "tool_connection", - consumerId: endpoint.connectionId, - configPath: ref.configPath, - actorType: "system", - actorId: null, - }, - ); - } - return values; - } - - async function resolveCredentials( - endpoint: EndpointRow, - database: DbOrTransaction = db, - ): Promise> { - const connection = await database - .select({ refs: toolConnections.credentialSecretRefs, config: toolConnections.config }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!connection) throw notFound("Chat connection not found"); - const values = await resolveCredentialRefs(endpoint, connection.refs, database); - if (endpoint.provider === "imessage-photon") { - const configuration = photonChannelConfigurationSchema.parse(connection.config.photon); - return { ...values, ...configuration, lineId: configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId }; - } - return values; - } - const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { list, get, create, update } = createChatEndpoints(db, { + endpointRecord, CAPABILITIES, PROVIDER_LABELS, withCredentialMutationLease, + getPublicBaseUrl, getWebhookPublicBaseUrl, + get slackRegistration() { return slackRegistration; }, + }); const { generateSetupSecret } = createGitHubSetupSecret(db, { endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError, @@ -2895,92 +1389,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { normalizedLifecycleWithRuntimeFence, MAX_ERROR_TEXT, }); - async function prepareProviderInventory( - endpoint: EndpointRow, - credentials: Record, - ): Promise<{ - credentials: Record; - inventory: ChatProviderInventoryResult | null; - managementUrl?: string; - }> { - try { - if (endpoint.provider === "slack") return await prepareSlackInventory(credentials, fetchImpl); - if (endpoint.provider === "github") return await prepareGitHubInventory(credentials, fetchImpl); - if (endpoint.provider === "discord") return await prepareDiscordInventory(credentials, fetchImpl); - return { credentials, inventory: null }; - } catch (error) { - throw unprocessable(redactError(error), { - code: "chat_provider_inventory_failed", - }); - } - } - - function runtimeConfig( - endpoint: EndpointRow, - userName: string, - credentials: Record, - ): ResolvedChatSdkProviderConfig { - if (endpoint.provider === "speko") throw conflict("Speko uses the voice runtime"); - if (endpoint.provider === "imessage-photon") return { - provider: "imessage-photon", userName, - intakeAfter: Date.parse(String((endpoint.setup as InternalSetupState).photonIntakeAfter ?? endpoint.setup.testStartedAt ?? endpoint.createdAt.toISOString())), - credentials: { allocation: credentials.allocation === "shared" ? "shared" : "dedicated", projectId: credentials.projectId, lineId: credentials.lineId, projectSecret: credentials.projectSecret, phoneNumber: endpoint.botExternalId! }, - }; - if (endpoint.provider === "slack") - return { - provider: "slack", - userName, - credentials: { - botToken: credentials.botToken, - signingSecret: credentials.signingSecret, - botUserId: endpoint.botExternalId ?? undefined, - }, - }; - if (endpoint.provider === "telegram") - return { - provider: "telegram", - userName, - maxDownloadBytes: MAX_ATTACHMENT_BYTES, - credentials: { - botToken: credentials.botToken, - secretToken: credentials.webhookSecret, - }, - }; - if (endpoint.provider === "discord") - return { - provider: "discord", - userName, - credentials: { - applicationId: credentials.applicationId, - botToken: credentials.botToken, - guildId: credentials.guildId, - }, - }; - if (endpoint.provider === "microsoft-teams") - return { - provider: "microsoft-teams", - userName, - credentials: { - appId: credentials.clientId, - appPassword: credentials.clientSecret, - appTenantId: credentials.tenantId, - appType: "SingleTenant", - }, - }; - return { - provider: "github", - userName, - credentials: { - appId: credentials.appId, - privateKey: credentials.privateKey, - installationId: credentials.installationId - ? Number(credentials.installationId) - : undefined, - webhookSecret: credentials.webhookSecret, - }, - }; - } - async function runtimeFor( endpoint: EndpointRow, optionsForRuntime: { @@ -3403,1331 +1811,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function inspectPhoton( - endpointId: string, - input: { projectId: string; projectSecret: string }, - ) { - const record = await endpointRecord(endpointId); - if (!record || record.endpoint.provider !== "imessage-photon") - throw notFound("iMessage Photon endpoint not found"); - const inspection = structuredClone(await inspectPhotonCredentials( - input.projectId, - input.projectSecret, - )); - const reserved = await db - .select({ number: chatEndpoints.botExternalId }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.provider, "imessage-photon"), - ne(chatEndpoints.id, endpointId), - ne(chatEndpoints.status, "archived"), - ), - ); - for (const line of inspection.lines) { - if (reserved.some((row) => row.number === line.phoneNumber)) { - line.eligible = false; - line.unavailableReason = - "This number already belongs to another channel"; - } - } - inspection.eligible = inspection.allocation === "shared" ? inspection.eligible && !reserved.some((row) => row.number === photonSharedIdentity(inspection.projectId)) : inspection.lines.some((line) => line.eligible); - return inspection; - } - - async function configure( - endpointId: string, - input: ConfigureChatEndpointInput, - actorUserId?: string | null, - ) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - if (record.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail"); - if (input.photon && record.endpoint.provider !== "imessage-photon") throw badRequest("Photon configuration is only valid for iMessage Photon"); - const suppliedCredentialKeys = Object.keys(input.credentials ?? {}); - if (suppliedCredentialKeys.length > 0) { - const credentialAction = - input.action === "configure" || input.action === "reconnect"; - const allowedKeys = new Set( - credentialAction - ? SUPPLIED_CREDENTIAL_KEYS[record.endpoint.provider] - : [], - ); - const unsupportedKeys = suppliedCredentialKeys.filter( - (key) => !allowedKeys.has(key), - ); - if (unsupportedKeys.length > 0) { - throw unprocessable( - `Unsupported ${PROVIDER_LABELS[record.endpoint.provider]} credential fields for ${input.action}: ${unsupportedKeys.join(", ")}`, - { - code: "chat_endpoint_credentials_invalid", - provider: record.endpoint.provider, - action: input.action, - unsupportedKeys, - }, - ); - } - } - return withCredentialMutationLease(record.endpoint, (credentialLease) => - configureWithCredentialLease( - endpointId, - input, - credentialLease, - actorUserId, - ), - ); - } - - async function configureWithCredentialLease( - endpointId: string, - input: ConfigureChatEndpointInput, - credentialLease: CredentialMutationLeaseGuard, - actorUserId?: string | null, - ) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const endpoint = record.endpoint; - if (input.action === "pause") { - if (endpoint.status !== "active") { - throw conflict("Only an active chat connection can be paused", { - code: "chat_endpoint_not_active", - }); - } - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const pausedAt = new Date(); - const current = await tx - .select({ setup: chatEndpoints.setup, status: chatEndpoints.status }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current || current.status !== "active") { - throw conflict("Only an active chat connection can be paused", { - code: "chat_endpoint_not_active", - }); - } - await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status: "paused", - setup: { - ...current.setup, - runtimeGeneration: runtimeGeneration(current.setup) + (endpoint.provider === "imessage-photon" ? 0 : 1), - } as InternalSetupState, - updatedAt: pausedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ status: "disabled", enabled: false, updatedAt: new Date() }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - redactedError: "Connection was paused before processing", - processedAt: pausedAt, - updatedAt: pausedAt, - }) - .where( - and( - endpoint.provider === "imessage-photon" ? sql`false` : undefined, - eq(chatDeliveries.endpointId, endpoint.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ); - await credentialLease.assertOwned(tx); - }); - await invalidateRuntime(endpoint.id); - await logActivity(db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.paused", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: { endpointId: endpoint.id }, - }); - return get(endpoint.id); - } - if (input.action === "resume") { - if (endpoint.status !== "paused" || endpoint.setup.step !== "complete") { - throw conflict( - "Only a previously active chat connection can be resumed", - { - code: "chat_endpoint_not_resumable", - }, - ); - } - let credentials = await resolveCredentials(endpoint).catch(() => { - throw conflict("Reconnect the provider credentials before resuming", { - code: "chat_endpoint_credentials_missing", - }); - }); - const identity = await verifyCredentials(endpoint.provider, credentials); - const upgradingLegacyGitHubIdentity = - endpoint.provider === "github" && - !endpoint.botExternalId && - legacyGitHubLabelsMatchVerifiedCredentials(endpoint, identity); - if ( - !upgradingLegacyGitHubIdentity && - !nativeBotIdentityMatches(endpoint.provider, endpoint, identity) - ) { - throw conflict( - "The provider credentials now identify a different bot; reconnect this connection instead", - { - code: "chat_bot_identity_changed", - }, - ); - } - await assertNativeBotIdentityAvailable(endpoint, identity); - const prepared = await prepareProviderInventory(endpoint, credentials); - if ( - endpoint.provider === "github" && - prepared.credentials.installationId !== credentials.installationId - ) { - await persistCredentials( - endpoint, - prepared.credentials, - credentialLease, - actorUserId, - ); - } - credentials = prepared.credentials; - if (prepared.inventory) - await reconcileProviderResourceRows( - endpoint, - prepared.inventory, - credentialLease, - ); - let activatedEndpoint!: EndpointRow; - try { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const resumedAt = new Date(); - const current = await tx - .select() - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current || current.status !== "paused") { - throw conflict( - "Only a previously active chat connection can be resumed", - { - code: "chat_endpoint_not_resumable", - }, - ); - } - // Fail closed for any legacy or racing delivery that remained open - // while this endpoint was paused. Resume must never execute traffic - // that Paperclip acknowledged during the inactive interval. - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - redactedError: "Connection was paused when this event arrived", - processedAt: resumedAt, - updatedAt: resumedAt, - }) - .where( - and( - endpoint.provider === "imessage-photon" ? sql`false` : undefined, - eq(chatDeliveries.endpointId, endpoint.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ); - await notifyChatEndpointWork(tx); - [activatedEndpoint] = await tx - .update(chatEndpoints) - .set({ - status: "active", - ...(upgradingLegacyGitHubIdentity - ? { botExternalId: identity.botExternalId } - : {}), - healthMessage: "Connected", - lastError: null, - setup: { - ...current.setup, - ...(endpoint.provider === "imessage-photon" ? { photonIntakeAfter: resumedAt.toISOString() } : {}), - runtimeGeneration: runtimeGeneration(current.setup) + (endpoint.provider === "imessage-photon" ? 0 : 1), - } as InternalSetupState, - updatedAt: resumedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)) - .returning(); - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: "Connected", - lastError: null, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - await invalidateRuntime(endpoint.id); - if (activatedEndpoint.provider === "discord") - await reconcileDiscordCommands(endpoint.id, credentialLease, true); - await runtimeFor(activatedEndpoint, { - requireDiscordOwnership: leasedChatProvider(activatedEndpoint.provider), - waitForDiscordOwnership: leasedChatProvider(activatedEndpoint.provider), - }); - } catch (error) { - await invalidateRuntime(endpoint.id).catch(() => undefined); - if (activatedEndpoint) { - const failure = redactError(error); - const failedAt = new Date(); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: "Provider resume needs attention", - lastError: failure, - updatedAt: failedAt, - }) - .where( - and( - eq(chatEndpoints.id, endpoint.id), - eq(chatEndpoints.status, "active"), - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${runtimeGeneration(activatedEndpoint.setup)}`, - ), - ); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: "Provider resume failed", - lastError: failure, - healthCheckedAt: failedAt, - updatedAt: failedAt, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - redactedError: "Connection activation failed", - processedAt: failedAt, - updatedAt: failedAt, - }) - .where( - and( - eq(chatDeliveries.endpointId, endpoint.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ); - await credentialLease.assertOwned(tx); - }); - } - throw error; - } - await logActivity(db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.resumed", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: { endpointId: endpoint.id }, - }); - return get(endpoint.id); - } - if (input.action === "remove") { - if (endpoint.status === "archived") { - if (endpoint.provider === "slack") await slackRegistration.cleanup(endpoint.id, credentialLease); - // Archival is the durable ingress fence and intentionally commits - // before secret-store cleanup. If that cleanup failed, a repeated - // remove is the recovery operation; once refs are empty, retain the - // normal already-removed conflict contract. - if (record.credentialSecretRefs.length > 0) { - await invalidateRuntime(endpoint.id).catch(() => undefined); - if (endpoint.provider === "telegram") { - await credentialLease.assertOwned(); - const action = await stageTelegramMaintenance( - endpoint, - "remove_endpoint", - ); - if (action) - await processTelegramMaintenance( - action.id, - true, - credentialLease, - ); - } else { - await clearCredentials(endpoint, credentialLease); - } - return get(endpoint.id); - } - throw conflict("This chat connection has already been removed", { - code: "chat_endpoint_already_removed", - }); - } - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ setup: chatEndpoints.setup, status: chatEndpoints.status }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current || current.status === "archived") { - throw conflict("This chat connection has already been removed", { - code: "chat_endpoint_already_removed", - }); - } - await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status: "archived", - setup: { - ...current.setup, - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - archivedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(chatConversations) - .set({ state: "endpoint_removed", updatedAt: new Date() }) - .where(eq(chatConversations.endpointId, endpoint.id)); - await tx - .update(toolConnections) - .set({ status: "archived", enabled: false, updatedAt: new Date() }) - .where(eq(toolConnections.id, endpoint.connectionId)); - const otherConnection = await tx - .select({ id: toolConnections.id }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.applicationId, record.applicationId), - ne(toolConnections.id, endpoint.connectionId), - ne(toolConnections.status, "archived"), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (!otherConnection) { - await tx - .update(toolApplications) - .set({ - status: "archived", - archivedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolApplications.id, record.applicationId)); - } - await credentialLease.assertOwned(tx); - }); - if (endpoint.provider === "slack") await slackRegistration.cleanup(endpoint.id, credentialLease); - await invalidateRuntime(endpoint.id).catch(() => undefined); - if (endpoint.provider === "telegram") { - await credentialLease.assertOwned(); - const action = await stageTelegramMaintenance( - endpoint, - "remove_endpoint", - ); - if (action) { - await processTelegramMaintenance(action.id, true, credentialLease); - } - } else { - await clearCredentials(endpoint, credentialLease); - } - return get(endpoint.id); - } - if ( - input.action !== "configure" && - input.action !== "reconnect" && - input.action !== "verify" - ) { - throw unprocessable("Unsupported chat endpoint setup action"); - } - if (!getWebhookPublicBaseUrl() && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon" && !(endpoint.provider === "github" && endpoint.setup.github?.cloudRegistrationId)) { - throw unprocessable( - `A public HTTPS Paperclip URL is required before connecting ${PROVIDER_LABELS[endpoint.provider]}`, - ); - } - if ( - endpoint.provider === "telegram" && - (input.action === "configure" || input.action === "reconnect") && - !isSupportedTelegramWebhookBaseUrl(getWebhookPublicBaseUrl()) - ) { - throw unprocessable( - "Telegram webhooks require PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL to use HTTPS on port 443, 80, 88, or 8443", - { - code: "chat_telegram_webhook_url_unsupported", - provider: "telegram", - supportedPorts: [443, 80, 88, 8443], - }, - ); - } - if (input.action === "verify") { - if ( - endpoint.provider !== "slack" || - endpoint.status !== "verifying" || - endpoint.setup.step !== "provider_setup" - ) { - throw conflict( - "Provider verification is not available at this setup step", - { - code: "chat_endpoint_invalid_setup_step", - }, - ); - } - if (!endpoint.setup.webhookVerifiedAt) { - throw conflict("Paperclip has not received a verified Slack callback yet", { - code: "chat_webhook_not_verified", - }); - } - } else if ( - input.action === "configure" && - !["draft", "attention", "revoked"].includes(endpoint.status) && - // GitHub installation discovery can advance the status before the - // wizard has configured its runtime. Finish that exact setup stage; - // configured connections still require the explicit reconnect flow. - !( - endpoint.provider === "github" && - endpoint.status === "verifying" && - endpoint.setup.step === "provider_setup" - ) - ) { - throw conflict( - "This connection is already configured; use its reconnect flow if credentials need repair", - { - code: "chat_endpoint_already_configured", - }, - ); - } else if ( - input.action === "reconnect" && - !["verifying", "active", "attention", "revoked", "paused"].includes( - endpoint.status, - ) - ) { - throw conflict( - "This chat connection does not currently need reconnecting", - { - code: "chat_endpoint_not_reconnectable", - }, - ); - } - if ( - endpoint.provider === "github" && - (input.action === "configure" || input.action === "reconnect") && - !endpoint.setup.webhookVerifiedAt - ) { - throw conflict( - "GitHub has not delivered a signed webhook ping for this secret yet", - { - code: "chat_webhook_not_verified", - }, - ); - } - - let verifiedCurrentIdentity: VerifiedProviderIdentity = endpoint; - if ( - input.action === "reconnect" && - endpoint.provider === "github" && - !endpoint.botExternalId - ) { - const storedCredentials = await resolveCredentials(endpoint).catch(() => { - throw conflict( - "Reconnect the existing GitHub App credentials before changing them", - { - code: "chat_bot_identity_unverifiable", - }, - ); - }); - const storedIdentity = await verifyCredentials( - "github", - storedCredentials, - ); - if ( - !legacyGitHubLabelsMatchVerifiedCredentials(endpoint, storedIdentity) - ) { - throw conflict( - "The stored credentials no longer verify the GitHub App that owns this connection", - { - code: "chat_bot_identity_unverifiable", - }, - ); - } - verifiedCurrentIdentity = storedIdentity; - } - - const previousSpekoSigningSecret = endpoint.provider === "speko" - ? (await resolveCredentials(endpoint).catch(() => ({} as Record))).signingSecret : undefined; - let credentials = - input.credentials && Object.keys(input.credentials).length > 0 - ? await normalizedCredentials(endpoint, input.credentials) - : await resolveCredentials(endpoint).catch(() => { - throw unprocessable( - `${PROVIDER_LABELS[endpoint.provider]} credentials are required`, - ); - }); - if (endpoint.provider === "imessage-photon") { - const configuration = photonChannelConfigurationSchema.parse(input.photon ?? (credentials.allocation === "shared" ? { allocation: "shared", projectId: credentials.projectId } : { projectId: credentials.projectId, lineId: credentials.lineId })); - const lineId = configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId; - if (endpoint.botExternalId && (configuration.projectId !== endpoint.providerAccountId || credentials.lineId && lineId !== credentials.lineId)) throw conflict("A different Photon identity requires a new channel"); - credentials = { ...credentials, ...configuration, lineId }; - } - const identity = await verifyCredentials(endpoint.provider, credentials); - // Once setup has claimed a provider bot identity, every credential repair - // must prove that same identity before secrets can be replaced. A process - // interruption can leave the endpoint in `attention` after the claim but - // before credential persistence; a repeated first-setup request must not - // turn that recovery state into an identity-swap escape hatch. - if (input.action === "reconnect" || Boolean(endpoint.botExternalId)) { - if ( - !nativeBotIdentityMatches( - endpoint.provider, - verifiedCurrentIdentity, - identity, - ) - ) { - throw conflict( - "The provider credentials identify a different bot; create a new connection for that bot instead", - { code: "chat_bot_identity_changed" }, - ); - } - } - await assertNativeBotIdentityAvailable(endpoint, identity); - const prepared = await prepareProviderInventory(endpoint, credentials); - const credentialsChangedByDiscovery = - endpoint.provider === "github" && - prepared.credentials.installationId !== credentials.installationId; - credentials = prepared.credentials; - await options.nativeBotIdentityClaimBarrier?.(); - // Claim a fresh provider identity before any newly supplied secrets are - // persisted. The provider-global unique indexes are the multi-process - // arbitration boundary when two setup requests both pass the optimistic - // preflight. A process that stops after this small claim leaves a - // fail-closed attention endpoint that the same operator can reconnect; - // it never leaves credentials bound to the losing endpoint. - if (!endpoint.botExternalId) { - try { - const claimed = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); - const rows = await tx - .update(chatEndpoints) - .set({ - status: "attention", - providerAccountId: identity.providerAccountId ?? null, - providerAccountLabel: identity.providerAccountLabel ?? null, - botExternalId: identity.botExternalId ?? null, - botUsername: identity.botUsername ?? null, - botDisplayName: - identity.botLabel ?? - endpoint.botDisplayName ?? - record.assignedAgentName, - healthMessage: "Provider setup must be completed", - lastError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatEndpoints.id, endpoint.id), - eq(chatEndpoints.status, endpoint.status), - isNull(chatEndpoints.botExternalId), - ), - ) - .returning({ id: chatEndpoints.id }); - await credentialLease.assertOwned(tx); - return rows; - }); - if (claimed.length !== 1) { - throw conflict("This chat connection changed during setup", { - code: "chat_endpoint_setup_changed", - }); - } - } catch (error) { - if (isNativeBotIdentityUniqueViolation(error)) { - throw nativeBotIdentityConflict(endpoint.provider); - } - throw error; - } - } - if (endpoint.provider === "imessage-photon") { - await invalidateRuntime(endpoint.id); - await credentialLease.assertOwned(); - if (credentials.allocation === "shared") await db.update(chatEndpoints).set({ allowGroupChats: false }).where(eq(chatEndpoints.id, endpoint.id)); - await db.update(toolConnections).set({ config: { provider: endpoint.provider, photon: credentials.allocation === "shared" ? { allocation: "shared", projectId: credentials.projectId } : { allocation: "dedicated", projectId: credentials.projectId, lineId: credentials.lineId } } }).where(and(eq(toolConnections.companyId, endpoint.companyId), eq(toolConnections.id, endpoint.connectionId))); - } - if ( - (input.credentials && Object.keys(input.credentials).length > 0) || - credentialsChangedByDiscovery - ) - await persistCredentials( - endpoint, - credentials, - credentialLease, - actorUserId, - ); - if (prepared.inventory) - await reconcileProviderResourceRows( - endpoint, - prepared.inventory, - credentialLease, - ); - const updatedAt = new Date(); - const waitingForSlackConfiguration = - endpoint.provider === "slack" && - (input.action === "configure" || input.action === "reconnect"); - try { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ setup: chatEndpoints.setup }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Chat endpoint not found"); - if (endpoint.provider === "speko") { - if (!actorUserId) throw forbidden("An authenticated connection owner is required to install phone tools"); - await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); - } - const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) - && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") - && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); - await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status: "verifying", - providerAccountId: identity.providerAccountId ?? null, - providerAccountLabel: identity.providerAccountLabel ?? null, - botExternalId: identity.botExternalId ?? null, - botUsername: identity.botUsername ?? null, - botDisplayName: - identity.botLabel ?? - endpoint.botDisplayName ?? - record.assignedAgentName, - healthMessage: waitingForSlackConfiguration - ? "Finish provider webhook configuration" - : "Waiting for a test conversation", - lastError: null, - lastEventAt: null, - setup: { - ...current.setup, - ...(endpoint.provider === "github" ? { github: { ...current.setup.github, stage: current.setup.github?.stage ?? "connect", appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin } } : {}), - step: waitingForSlackConfiguration ? "provider_setup" : "test", - ...(endpoint.provider === "imessage-photon" ? { photonIntakeAfter: (current.setup as InternalSetupState).photonIntakeAfter ?? updatedAt.toISOString() } : {}), - testStartedAt: waitingForSlackConfiguration - ? null - : updatedAt.toISOString(), - webhookVerifiedAt: waitingForSlackConfiguration - ? preserveSlackVerification ? (current.setup.webhookVerifiedAt ?? null) : null - : (current.setup.webhookVerifiedAt ?? null), - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - updatedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: "Provider credentials verified", - healthCheckedAt: updatedAt, - updatedAt, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await tx - .update(toolApplications) - .set({ status: "active", archivedAt: null, updatedAt }) - .where(eq(toolApplications.id, record.applicationId)); - await credentialLease.assertOwned(tx); - }); - } catch (error) { - if (isNativeBotIdentityUniqueViolation(error)) { - throw nativeBotIdentityConflict(endpoint.provider); - } - throw error; - } - try { - const next = await endpointRecord(endpoint.id); - if (!next) throw notFound("Chat endpoint not found"); - await invalidateRuntime(endpoint.id); - if (endpoint.provider === "github" && input.action === "reconnect" && endpoint.setup.github?.cloudRegistrationId) { - if (!actorUserId) throw forbidden("A configuring member is required to recover this App"); - await credentialLease.assertOwned(); - await githubWizard.repairCloud(endpoint.id, actorUserId); - await credentialLease.assertOwned(); - } - if (endpoint.provider === "github" && input.action === "reconnect" && !endpoint.setup.github?.cloudRegistrationId) { - // The immutable App and its installation were verified above. Repair - // only its callback using the stored secret; never reinstall it or - // change repository access. Keep historical signed-ping evidence, but - // require the fresh test round trip established above before activation. - const auditWebhookSync = async (action: string) => { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action, - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: { endpointId: endpoint.id, provider: "github" }, - }); - await credentialLease.assertOwned(tx); - }); - }; - await auditWebhookSync("chat_endpoint.webhook_sync_started"); - await credentialLease.assertOwned(); - await resyncGitHubAppWebhook({ - fetch: fetchImpl, - appToken: githubAppJwt(credentials.appId, credentials.privateKey), - webhookUrl: `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/github`, - webhookSecret: credentials.webhookSecret, - }); - await auditWebhookSync("chat_endpoint.webhook_synced"); - } - if (next.endpoint.provider === "speko") { - const webhookPublicBaseUrl = getWebhookPublicBaseUrl(); - if (!webhookPublicBaseUrl?.startsWith("https://")) throw unprocessable("Speko needs an explicitly configured public HTTPS callback origin"); - await configureSpekoSessionTools(db, { companyId: endpoint.companyId, endpointId: endpoint.id, agentId: credentials.agentId, callbackUrl: `${webhookPublicBaseUrl}/api/voice-webhooks/${endpoint.publicId}/tools`, signingSecret: credentials.signingSecret, previousSigningSecret: previousSpekoSigningSecret, client: createSpekoProvider(credentials.apiKey, fetchImpl), assertOwned: () => credentialLease.assertOwned() }); - } - if (next.endpoint.provider === "discord") - await reconcileDiscordCommands(endpoint.id, credentialLease, true); - await runtimeFor(next.endpoint, { - requireDiscordOwnership: next.endpoint.provider === "discord", - waitForDiscordOwnership: next.endpoint.provider === "discord", - }); - - if (endpoint.provider === "telegram" && getWebhookPublicBaseUrl()) { - const webhookUrl = `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/telegram`; - const infoResponse = await fetchImpl( - `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getWebhookInfo`, - { signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) }, - ); - const infoResult = (await infoResponse.json()) as { - ok?: boolean; - description?: string; - result?: { url?: string }; - }; - if (!infoResponse.ok || !infoResult.ok) { - throw unprocessable( - `Telegram could not inspect the existing webhook: ${infoResult.description ?? infoResponse.status}`, - ); - } - await credentialLease.assertOwned(); - const response = await fetchImpl( - `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/setWebhook`, - { - method: "POST", - signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - url: webhookUrl, - secret_token: credentials.webhookSecret, - allowed_updates: [ - "message", - "edited_message", - "callback_query", - "stopped_message_generation", - "message_reaction", - "my_chat_member", - ], - // A first-time connection may inherit stale updates from a bot - // that was used before Paperclip owned it. Reconnect is - // different: Telegram may be holding legitimate updates while - // the old callback URL is unavailable. Preserve that backlog - // even when an operator is rotating the public origin. - drop_pending_updates: input.action === "configure", - }), - }, - ); - const result = (await response.json()) as { - ok?: boolean; - description?: string; - result?: unknown; - }; - if (!response.ok || !result.ok) - throw unprocessable( - `Telegram could not register the webhook: ${result.description ?? response.status}`, - ); - await credentialLease.assertOwned(); - if (result.ok === true && result.result === true) { - // Telegram's successful boolean receipt acknowledges the exact URL, - // secret and allowed_updates request above. GET alone cannot prove - // secret_token, which Telegram deliberately does not return. - await recordTelegramStopSubscription( - next.endpoint, - runtimeContextForRecord(next), - credentialLease, - ); - } - // Command discovery is optional for ingress, but its provider mutation - // is still a durable, auditable action. A transient or rate-limited - // failure is retried by the maintenance worker without blocking the - // otherwise-valid webhook connection. - const commandAction = await stageTelegramMaintenance( - endpoint, - "register_commands", - ); - if (commandAction) - await processTelegramMaintenance( - commandAction.id, - false, - credentialLease, - ); - } - } catch (error) { - const failure = redactError(error); - await invalidateRuntime(endpoint.id).catch(() => undefined); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: "Provider setup needs attention", - lastError: failure, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ - healthStatus: "degraded", - healthMessage: "Provider setup failed", - lastError: failure, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - throw unprocessable(failure, { code: "chat_provider_setup_failed" }); - } - - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: - input.action === "reconnect" - ? "chat_endpoint.reconnected" - : "chat_endpoint.credentials_verified", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: { endpointId: endpoint.id, provider: endpoint.provider }, - }); - await credentialLease.assertOwned(tx); - }); - return get(endpoint.id); - } - - async function findAuthorizedIdentityLink(endpoint: EndpointRow, userId: string) { - return db.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks) - .innerJoin(companyMemberships, and( - eq(companyMemberships.companyId, chatIdentityLinks.companyId), eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), - )) - .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"))) - .limit(1).then((rows) => rows[0] ?? null); - } - - async function ensurePrincipal( - endpoint: EndpointRow, - author: Author, - raw?: unknown, - ) { - const providerAccountId = endpoint.providerAccountId ?? "unknown"; - const externalId = stableExternalPrincipalId( - endpoint.provider, - author, - raw, - ); - const [principal] = await db - .insert(chatExternalPrincipals) - .values({ - companyId: endpoint.companyId, - provider: endpoint.provider, - providerAccountId, - externalId, - kind: - author.isBot === true ? "bot" : author.isSystem ? "system" : "user", - displayName: author.fullName, - handle: author.userName, - isBot: author.isBot === true || author.isMe, - lastSeenAt: new Date(), - }) - .onConflictDoUpdate({ - target: [ - chatExternalPrincipals.companyId, - chatExternalPrincipals.provider, - chatExternalPrincipals.providerAccountId, - chatExternalPrincipals.externalId, - ], - set: { - displayName: author.fullName, - handle: author.userName, - isBot: author.isBot === true || author.isMe, - lastSeenAt: new Date(), - updatedAt: new Date(), - }, - }) - .returning(); - if (endpoint.provider === "speko") { - try { - const authorization = await db.transaction((tx) => voiceStore.authorizePrincipal(tx, endpoint.companyId, endpoint.id, externalId)); - return { principal, userId: authorization.userId, linkedDenied: false }; - } catch (error) { - if (!(error instanceof HttpError && [403, 404].includes(error.status))) throw error; - return { principal, userId: null, linkedDenied: true }; - } - } - const link = await db - .select({ - userId: chatIdentityLinks.paperclipUserId, - status: chatIdentityLinks.status, - }) - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principal.id), - ), - ) - .then((rows) => rows[0] ?? null); - if (link?.status === "linked" && link.userId) { - const membership = await db - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, link.userId), - ), - ) - .then((rows) => rows[0] ?? null); - // External chat performs the same task mutations as the board. A linked - // viewer therefore remains read-only instead of gaining write authority - // merely by using a provider account. - if ( - membership?.status !== "active" || - membership.membershipRole === "viewer" - ) { - return { principal, userId: null, linkedDenied: true }; - } - return { principal, userId: link.userId, linkedDenied: false }; - } - return { principal, userId: null, linkedDenied: false }; - } - - async function sponsorAllowsGuest(endpoint: EndpointRow): Promise { - // A null sponsor is the local board/operator context used by self-hosted - // instances. Cloud endpoints retain the creating user and must revalidate - // that user's current company authority on every sponsored guest turn. - if (!endpoint.sponsorUserId) return true; - const membership = await db - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, endpoint.sponsorUserId), - ), - ) - .then((rows) => rows[0] ?? null); - return ( - membership?.status === "active" && membership.membershipRole !== "viewer" - ); - } - - async function lockCurrentPrincipalAuthorization( - tx: DbOrTransaction, - endpoint: EndpointRow, - principalId: string, - ): Promise<{ - allowed: boolean; - linkedDenied: boolean; - userId: string | null; - sponsorUserId?: string | null; - }> { - if (endpoint.provider === "speko") { - const principal = await tx.select({ externalId: chatExternalPrincipals.externalId }).from(chatExternalPrincipals) - .where(and(eq(chatExternalPrincipals.id, principalId), eq(chatExternalPrincipals.companyId, endpoint.companyId), eq(chatExternalPrincipals.provider, "speko"))).then((rows) => rows[0]); - if (!principal) return { allowed: false, linkedDenied: true, userId: null }; - try { return await voiceStore.authorizePrincipal(tx as import("./voice/voice-session-store.js").VoiceTransaction, endpoint.companyId, endpoint.id, principal.externalId); } - catch (error) { - if (!(error instanceof HttpError && [403, 404].includes(error.status))) throw error; - return { allowed: false, linkedDenied: true, userId: null }; - } - } - const githubAccess = await githubChatPrincipalAccess(tx, endpoint, principalId); - if (githubAccess) return githubAccess; - // Link confirmation already uses this transaction-scoped identity key. - // Taking it at the final task mutation boundary prevents a newly confirmed - // identity from racing the authorization snapshot. Row locks below also - // serialize revocation and membership changes that do not use this key. - await tx.execute( - sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0))`, - ); - const link = await tx - .select({ - status: chatIdentityLinks.status, - userId: chatIdentityLinks.paperclipUserId, - }) - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.companyId, endpoint.companyId), - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principalId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (link?.status === "linked") { - if (!link.userId) { - return { allowed: false, linkedDenied: true, userId: null }; - } - const membership = await tx - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, link.userId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const allowed = - membership?.status === "active" && - membership.membershipRole !== "viewer"; - return { - allowed, - linkedDenied: !allowed, - userId: allowed ? link.userId : null, - }; - } - if (!endpoint.allowUnlinkedPeople) { - return { allowed: false, linkedDenied: false, userId: null }; - } - if (!endpoint.sponsorUserId) { - return { allowed: true, linkedDenied: false, userId: null }; - } - const sponsorMembership = await tx - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, endpoint.sponsorUserId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - return { - allowed: - sponsorMembership?.status === "active" && - sponsorMembership.membershipRole !== "viewer", - linkedDenied: false, - userId: null, - }; - } - - async function requireCurrentExternalActionAuthorization( - tx: DbTransaction, - input: { - conversationId: string; - endpointId: string; - expectedUserId: string; - principalId: string; - runtimeContext: LifecycleRuntimeFence; - }, - ): Promise { - const endpoint = await runtimeCallbackEndpoint( - tx, - input.endpointId, - input.runtimeContext, - ["active", "verifying"], - ); - // Photon setup already admits linked senders and publishes agent prompts. - // Let those prompts resolve so a clarifying question cannot deadlock the - // actual-reply qualification. Other providers retain their active-only gate. - if (!endpoint || (endpoint.status !== "active" && - (endpoint.provider !== "imessage-photon" || endpoint.setup.step !== "test"))) { - throw forbidden("This chat action is no longer authorized", { - code: "chat_action_authorization_changed", - }); - } - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, input.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!conversation) { - throw forbidden("This chat action is no longer authorized", { - code: "chat_action_authorization_changed", - }); - } - const resource = conversation.resourceId - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const destinationAllowed = conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource); - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - input.principalId, - ); - // Governed actions never fall back to sponsored-guest authority. If the - // provider identity was relinked while this callback waited, reject this - // stale actor snapshot and let a fresh callback use the new identity. - if ( - !destinationAllowed || - !authorization.allowed || - authorization.userId !== input.expectedUserId - ) { - throw forbidden("This chat action is no longer authorized", { - code: "chat_action_authorization_changed", - }); - } - } - - function isExternalActionAuthorizationChange(error: unknown): boolean { - if (!error || typeof error !== "object") return false; - const value = error as { details?: unknown }; - return ( - Boolean(value.details) && - typeof value.details === "object" && - (value.details as { code?: unknown }).code === - "chat_action_authorization_changed" - ); - } - - async function ensureResource( - endpoint: EndpointRow, - thread: Thread, - enabledBySetupActivation: boolean, - database: DbOrTransaction = db, - ) { - const type = providerResourceType( - endpoint.provider, - chatSurfaceKind(endpoint.provider, thread), - ); - const providerResourceId = canonicalProviderResourceId( - endpoint.provider, - thread, - ); - const resourceLabel = providerResourceLabelFromThread( - endpoint.provider, - thread, - ); - const [resource] = await database - .insert(chatEndpointResources) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - type, - providerResourceId, - label: resourceLabel.label, - availability: "available", - // Slack can deliver the invitation's app_mention before the membership - // callback. Give either arrival order the same new-channel default. - enabled: - thread.isDM || - enabledBySetupActivation || - (endpoint.provider === "slack" && type === "channel"), - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { - // Provider inventory and membership lifecycle own availability. - // A delayed message must not resurrect a destination after the bot - // was removed or an installation lost access. - // Slack callbacks are not guaranteed to carry a channel name. Keep - // an inventory- or conversations.info-derived label instead of - // downgrading it to C… / slack:C… when a delayed message arrives. - ...(resourceLabel.fallback - ? {} - : { - label: sql`case - when ${chatEndpointResources.availability} = 'available' then ${resourceLabel.label} - else ${chatEndpointResources.label} - end`, - }), - updatedAt: new Date(), - }, - }) - .returning(); - return resource; - } - async function stageInboundWakeup( tx: DbOrTransaction, input: { @@ -5942,274 +3025,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // One provider message can match both a mention handler and the catch-all // new-message handler. The trigger is policy metadata, not provider event // identity, so it must not defeat durable deduplication. - const githubAutomatic = endpoint.provider === "github" ? githubAutomaticMessages.get(message) : undefined; - const githubIssue = endpoint.provider === "github" ? githubIssueMessages.get(message) : undefined; - let githubManual = githubManualMessages.get(message); - if (endpoint.provider === "github" && !githubAutomatic && !githubIssue && !githubManual) { - const [config] = await db.select().from(chatGitHubConfigurations).where(eq(chatGitHubConfigurations.endpointId, endpoint.id)); - if (config) { - const repository = thread.channelId.replace(/^github:/, "").toLowerCase(); - const [repo] = await db.select().from(chatEndpointResources).where(and(eq(chatEndpointResources.endpointId, endpoint.id), eq(chatEndpointResources.providerResourceId, repository))); - const repositoryId = String(repo?.metadata?.providerRepositoryId ?? ""); - githubManual = { policy: { ...config.configuration.defaults, ...config.configuration.repositories[repositoryId] }, revision: config.revision, event: trigger === "mention" || message.isMention ? "mention" : "comment" }; - } - } - const providerEventId = `${durableExternalThreadIdentity(thread.id)}:${message.id}`; - const surfaceKind = chatSurfaceKind(endpoint.provider, thread); - const addressed = - endpoint.provider === "imessage-photon" || - trigger === "mention" || - trigger === "direct_message" || - message.isMention === true; - const eventKind: ChatEventKind = - trigger === "direct_message" - ? "direct_message" - : trigger === "mention" - ? "mention" - : "message"; - // A callback remains tied to the runtime that authenticated and parsed it. - // In particular, do not resolve the registry again here: pause or rotation - // may already have removed that instance, and recreating a runtime before - // the durable admission fence would either throw or bind old payload data - // to new credentials. - const endpointRuntime = - runtimeContext?.endpointRuntime ?? (await runtimeFor(endpoint)); - // Non-personal generic files still require a separate Graph grant. Only - // exact inline image resources from the authenticated original activity - // receive a scope-bound, URL-free recovery locator. Reconstructed input - // can retain that proof, but cannot mint one from legacy fetch metadata. - const teamsNonPersonal = - endpoint.provider === "microsoft-teams" && !thread.isDM; - const attachmentSource = { - threadId: thread.id, - messageId: message.id, - runtimeGeneration: runtimeContext?.generation, - credentialFingerprint: runtimeContext?.credentialFingerprint, - principalExternalId: stableExternalPrincipalId( - endpoint.provider, - message.author, - message.raw, - ), - isDirectMessage: thread.isDM, - ...(runtimeContext?.endpointRuntime && !admittedDeliveryId - ? { message } - : {}), - }; - const inlineRecovery = new Map< - Attachment, - ReturnType - >(); - const nativeInboundAttachments = teamsNonPersonal - ? message.attachments.slice(0, 20).flatMap((attachment) => { - const descriptor = endpointRuntime.attachmentRecoveryDescriptor( - attachment, - attachmentSource, - ); - if (descriptor?.locator.kind !== "teams_inline_image") return []; - const restored = endpointRuntime.rehydrateAttachment( - descriptor, - attachmentSource, - ); - if (!restored) return []; - inlineRecovery.set(attachment, descriptor); - return [restored]; - }) - : message.attachments; - const unavailableTeamsReferences = teamsNonPersonal - ? Math.min(message.attachments.length, 20) - - nativeInboundAttachments.length - : 0; - const providerSentAt = - endpoint.provider === "microsoft-teams" - ? teamsActivitySentAt(message.raw) - : endpoint.provider === "telegram" - ? telegramMessageSentAt(message.raw) - : endpoint.provider === "slack" - ? slackMessageSentAt(message.raw, message.id) - : message.metadata.dateSent instanceof Date && - Number.isFinite(message.metadata.dateSent.getTime()) - ? message.metadata.dateSent - : null; - const providerSentAtSource = providerSentAt - ? endpoint.provider === "microsoft-teams" - ? "teams_activity_timestamp" - : endpoint.provider === "telegram" - ? "telegram_message_date" - : endpoint.provider === "slack" - ? "slack_message_ts" - : null - : null; - const providerUrl = - (githubAutomatic - ? `https://github.com/${githubAutomatic.context.repository}/pull/${githubAutomatic.context.pullNumber}` - : githubIssue ? `https://github.com/${githubIssue.context.repository}/issues/${githubIssue.context.issueNumber}` : null) ?? - // Description/review requests and edits have synthetic ledger IDs, not - // native comment IDs. Their verified source URL must not acquire a fake - // issuecomment anchor from that ledger identity. - (endpoint.provider === "github" && githubManual && !receiptReactionSupported ? recoveredProviderUrl : null) ?? - chatProviderConversationUrl({ - provider: endpoint.provider, - providerAccountId: endpoint.providerAccountId, - botUsername: endpoint.botUsername, - threadId: thread.id, - providerMessageId: message.id, - raw: message.raw, - }) ?? recoveredProviderUrl; - // Preserve only the closed personal-recipient proof from the runtime that - // authenticated this original activity. A reconstructed delivery's AAD-only - // author and SDK route caches cannot recreate its Bot Framework recipient. - const teamsPersonalRecipient = - endpoint.provider === "microsoft-teams" && - runtimeContext?.endpointRuntime && - !admittedDeliveryId && - endpoint.providerAccountId && - endpoint.botExternalId - ? deriveTeamsPersonalRecipient(message, { - companyId: endpoint.companyId, - endpointId: endpoint.id, - runtimeGeneration: runtimeContext.generation, - credentialFingerprint: runtimeContext.credentialFingerprint, - tenantId: endpoint.providerAccountId, - botAppId: endpoint.botExternalId, - providerEventId, - threadId: thread.id, - isDirectMessage: thread.isDM, - }) - : null; - const normalized = { - providerEventId, - kind: eventKind, - trigger, - ...(teamsPersonalRecipient ? { teamsPersonalRecipient } : {}), - ...(slackSlashControl - ? { admission: { origin: "slack_slash_control" } } - : suppressSetupDestinationActivation - ? // A slash-command root is provider-confirmed only after an enabled - // destination authorized its transport. Persist that closed origin so - // crash recovery can never reinterpret it as first-time setup traffic - // and undo a later operator reach revocation. - { admission: { origin: "provider_confirmed_action" } } - : {}), - ...(runtimeContext - ? { - runtimeContext: { - credentialFingerprint: runtimeContext.credentialFingerprint, - generation: runtimeContext.generation, - }, - } - : {}), - acknowledgement: { - // Some provider callbacks represent an auditable user command without - // a provider message that can receive a reaction. Persist the - // distinction so a crash/retry cannot mistake the synthetic ledger id - // for a native message id. - receiptReactionSupported, - }, - ...(githubAutomatic ? { githubAutomatic } : {}), - ...(githubIssue ? { githubIssue } : {}), - ...(githubManual ? { githubManual } : {}), - principal: { - // Bot/system authors have already been rejected at this intake boundary. - isBot: false, - externalId: stableExternalPrincipalId( - endpoint.provider, - message.author, - message.raw, - ), - displayName: message.author.fullName, - handle: message.author.userName, - }, - resource: { - type: providerResourceType(endpoint.provider, surfaceKind), - providerResourceId: canonicalProviderResourceId( - endpoint.provider, - thread, - ), - label: thread.channel.name ?? thread.channelId, - }, - conversation: { - externalConversationId: thread.channelId, - externalThreadId: thread.id, - label: thread.channel.name ?? thread.channelId, - isDirectMessage: thread.isDM, - providerUrl, - }, - message: { - providerMessageId: message.id, - ...(endpoint.provider === "github" && typeof (message.raw as { githubSourceMessageId?: unknown })?.githubSourceMessageId === "string" && /^[1-9][0-9]*$/.test((message.raw as { githubSourceMessageId: string }).githubSourceMessageId) - ? { sourceProviderMessageId: (message.raw as { githubSourceMessageId: string }).githubSourceMessageId } : {}), - ...(endpoint.provider === "imessage-photon" - ? { photonReply: photonReplyReference(message.raw) } - : {}), - providerMessageSequence: - endpoint.provider === "telegram" - ? telegramMessageSequence(message.raw) - : null, - providerUpdateId: - endpoint.provider === "telegram" ? (providerUpdateId ?? null) : null, - text: message.text.slice(0, MAX_INBOUND_TEXT), - mentionedBot: message.isMention === true, - providerSentAt: providerSentAt?.toISOString() ?? null, - ...(providerSentAtSource ? { providerSentAtSource } : {}), - ...((endpoint.provider === "github" || - endpoint.provider === "imessage-photon") && - (githubAttachmentLimitOmissions(message) || - Math.max(0, message.attachments.length - 20)) - ? { - attachmentLimitOmissions: - githubAttachmentLimitOmissions(message) || - Math.max(0, message.attachments.length - 20), - } - : {}), - attachments: message.attachments.slice(0, 20).map((attachment) => ({ - name: sanitizeFilename(attachment.name), - mimeType: normalizeContentType(attachment.mimeType), - size: attachment.size ?? null, - recovery: teamsNonPersonal - ? (inlineRecovery.get(attachment) ?? null) - : nativeInboundAttachments.includes(attachment) - ? endpointRuntime.attachmentRecoveryDescriptor( - attachment, - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? attachmentSource - : undefined, - ) - : null, - })), - }, - }; - // Teams RSC can deliver messages from every installed channel, and a - // Telegram bot can receive addressed traffic from a provider-available - // group that the operator has not enabled in Paperclip. Until the reach - // gate admits that destination, retain only identifiers required for - // deduplication and operator-visible filtering—not user text, attachment - // metadata, or an external principal profile. - const redactedDestinationNormalized = { - providerEventId, - kind: eventKind, - trigger, - ...(runtimeContext - ? { - runtimeContext: { - credentialFingerprint: runtimeContext.credentialFingerprint, - generation: runtimeContext.generation, - }, - } - : {}), - resource: { - type: providerResourceType(endpoint.provider, surfaceKind), - providerResourceId: canonicalProviderResourceId( - endpoint.provider, - thread, - ), - }, - conversation: { externalThreadId: thread.id }, - message: { - providerMessageId: message.id, - providerSentAt: providerSentAt?.toISOString() ?? null, - }, - filtering: { contentRetained: false }, - }; + const { githubAutomatic, githubIssue, githubManual, providerEventId, surfaceKind, addressed, eventKind, endpointRuntime, nativeInboundAttachments, unavailableTeamsReferences, providerSentAt, providerUrl, normalized, redactedDestinationNormalized } = await prepareInboundEnvelope({ endpoint, thread, message, trigger, receiptReactionSupported, recoveredProviderUrl, runtimeContext, providerUpdateId, admittedDeliveryId, suppressSetupDestinationActivation, slackSlashControl }); const reorderWindow = INGRESS_REORDER_WINDOW_MS[endpoint.provider]; const scheduledAt = ingressOnly && reorderWindow @@ -6224,406 +3040,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ownership: DiscordGatewayOwnership; }; } = {}; - const admission = await db.transaction(async (tx) => { - const currentEndpoint = await tx - .select({ - status: chatEndpoints.status, - setup: chatEndpoints.setup, - allowDirectMessages: chatEndpoints.allowDirectMessages, - requireAtMention: chatEndpoints.requireAtMention, - allowGroupChats: chatEndpoints.allowGroupChats, - botUsername: chatEndpoints.botUsername, - }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!currentEndpoint) return null; - // An SDK callback can finish parsing after a pause/resume cycle. Each - // runtime captures the durable endpoint generation at registration; - // resume advances it under the same row lock used by admission. This - // rejects stale callbacks without relying on provider/server clock sync. - const currentRuntimeGeneration = runtimeGeneration(currentEndpoint.setup); - const currentCredentialFingerprint = runtimeContext - ? await tx - .select({ refs: toolConnections.credentialSecretRefs }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .then((rows) => credentialFingerprint(rows[0]?.refs ?? [])) - : null; - const staleActivation = - runtimeContext !== undefined && - (runtimeContext.generation !== currentRuntimeGeneration || - runtimeContext.credentialFingerprint !== - currentCredentialFingerprint); - const endpointAccepting = - ["verifying", "active"].includes(currentEndpoint.status) && - !staleActivation; - if ( - endpointAccepting && - leasedChatProvider(endpoint.provider) && - runtimeContext && - !admittedDeliveryId - ) { - // The first process-local ownership check can precede a host pause. - // Reclaim the same durable token while the endpoint admission row is - // locked so a standby takeover cannot be followed by an obsolete - // callback write. This renews only the exact token that authenticated - // the callback; generation and credential checks above remain intact. - const gatewayOwnership = - await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpoint.id, - runtimeContext, - ); - if (gatewayOwnership.kind === "lost") { - if (gatewayOwnership.ownership) { - discordGatewayAdmissionState.lost = gatewayOwnership.ownership; - } - return null; - } - discordGatewayAdmissionState.renewed = gatewayOwnership; - } - let provisionalTeamsSetupReply = false; - let destinationAccepting = true; - if (endpointAccepting && thread.isDM) { - destinationAccepting = currentEndpoint.allowDirectMessages; - } - if ( - endpointAccepting && - endpoint.provider === "microsoft-teams" && - !thread.isDM - ) { - let resource = await ensureResource(endpoint, thread, false, tx); - const enabledChannelCount = await tx - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - eq(chatEndpointResources.type, "channel"), - ), - ) - .then((rows) => rows[0]?.count ?? 0); - const canActivateFirstSetupChannel = - currentEndpoint.status === "verifying" && - resource.type === "channel" && - resource.availability === "available" && - enabledChannelCount === 0 && - addressed; - if (canActivateFirstSetupChannel && !resource.enabled) { - resource = await tx - .update(chatEndpointResources) - .set({ enabled: true, updatedAt: new Date() }) - .where(eq(chatEndpointResources.id, resource.id)) - .returning() - .then((rows) => rows[0] ?? resource); - } - const teamsRootMessageId = teamsThreadRootMessageId(thread.id); - provisionalTeamsSetupReply = - currentEndpoint.status === "verifying" && - resource.type === "channel" && - resource.availability === "available" && - enabledChannelCount === 0 && - !addressed && - Boolean(teamsRootMessageId) && - teamsRootMessageId !== message.id; - destinationAccepting = - nonDirectDestinationAllowed( - { ...endpoint, allowGroupChats: currentEndpoint.allowGroupChats }, - resource, - ) || provisionalTeamsSetupReply; - } - if ( - endpointAccepting && - endpoint.provider === "telegram" && - !thread.isDM - ) { - let resource = await ensureResource(endpoint, thread, false, tx); - const enabledResourceCount = await tx - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - ne(chatEndpointResources.type, "direct_message"), - ne(chatEndpointResources.type, "group_chat"), - ), - ) - .then((rows) => rows[0]?.count ?? 0); - const canActivateFirstSetupGroup = - currentEndpoint.status === "verifying" && - resource.availability === "available" && - enabledResourceCount === 0 && - addressed; - if (canActivateFirstSetupGroup && !resource.enabled) { - resource = await tx - .update(chatEndpointResources) - .set({ enabled: true, updatedAt: new Date() }) - .where(eq(chatEndpointResources.id, resource.id)) - .returning() - .then((rows) => rows[0] ?? resource); - } - // Telegram's privacy-mode contract is explicit: in a group or forum - // topic, every admitted turn must address the bot. A direct reply to a - // bot message is normalized as a mention by the pinned adapter, while - // unrelated subscribed traffic remains unaddressed and is filtered. - destinationAccepting = - addressed && - nonDirectDestinationAllowed( - { ...endpoint, allowGroupChats: currentEndpoint.allowGroupChats }, - resource, - ); - } - if ( - endpointAccepting && - endpoint.provider === "imessage-photon" && - !thread.isDM - ) { - const resource = await ensureResource(endpoint, thread, false, tx); - destinationAccepting = - resource.enabled && - resource.availability === "available" && - currentEndpoint.allowGroupChats; - } - const mentionRequired = - endpoint.provider === "slack" && - currentEndpoint.requireAtMention && - trigger !== "mention" && - message.isMention !== true; - // GitHub sends the same comment to every installed App. Subscribed - // conversations are a fallback for unaddressed replies, not permission - // to wake every bot when the person names a different connected App. - // Recheck inside durable admission so recovered deliveries follow the - // same company-scoped routing decision before any task or reaction. - const githubAddressedElsewhere = - endpointAccepting && - endpoint.provider === "github" && - !githubAutomatic && - !githubIssue && - (await githubMessageAddressesAnotherBot(tx, { - id: endpoint.id, - companyId: endpoint.companyId, - botUsername: currentEndpoint.botUsername, - }, message.text)); - const accepting = endpointAccepting && destinationAccepting && !githubAddressedElsewhere && !mentionRequired; - const redactDestinationDelivery = - githubAddressedElsewhere || mentionRequired || (!accepting && thread.isDM) || - - (!thread.isDM && - (endpoint.provider === "microsoft-teams" || - endpoint.provider === "telegram" || - endpoint.provider === "imessage-photon") && - (!accepting || provisionalTeamsSetupReply)); - const ignoredAt = accepting ? null : new Date(); - const inactiveReason = !endpointAccepting - ? staleActivation - ? "Connection activation changed before admission" - : "Connection is not active" - : githubAddressedElsewhere - ? "GitHub message explicitly mentions a different connected bot" - : mentionRequired - ? "Message did not @mention the bot" - : endpoint.provider === "telegram" && !thread.isDM && !addressed - ? "Message did not address the agent" - : "Destination is not enabled in Paperclip"; - let candidate = admittedDeliveryId - ? await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, admittedDeliveryId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - if (!admittedDeliveryId) { - await notifyChatDeliveryWork(tx); - const [delivery] = await tx - .insert(chatDeliveries) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind, - normalizedEvent: redactDestinationDelivery - ? redactedDestinationNormalized - : deferDrainUntilFollowup && accepting - ? { - ...normalized, - providerThreadPending: true, - } - : normalized, - state: accepting - ? deferDrainUntilFollowup - ? "retry" - : "received" - : "filtered", - nextAttemptAt: - accepting && deferDrainUntilFollowup - ? new Date( - Date.now() + DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS, - ) - : accepting && scheduledAt - ? new Date(scheduledAt) - : null, - redactedError: - accepting && deferDrainUntilFollowup - ? "Waiting for Discord thread creation confirmation" - : accepting - ? null - : inactiveReason, - processedAt: ignoredAt, - }) - .onConflictDoNothing() - .returning(); - candidate = delivery ?? null; - } - if (!candidate && !admittedDeliveryId) { - const existingDelivery = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - if (existingDelivery) { - const hydrateTeamsDelivery = - endpoint.provider === "microsoft-teams" && - !thread.isDM && - accepting && - !provisionalTeamsSetupReply && - ["received", "retry", "processing"].includes( - existingDelivery.state, - ) && - deliveryContentWasRedacted(existingDelivery.normalizedEvent); - await notifyChatDeliveryWork(tx); - [candidate] = await tx - .update(chatDeliveries) - .set({ - // Increment under PostgreSQL's row lock so simultaneous handler - // fanout and provider retries cannot lose duplicate telemetry. - normalizedEvent: redactDestinationDelivery - ? sql`${JSON.stringify(redactedDestinationNormalized)}::jsonb - || jsonb_build_object( - 'deduplication', - jsonb_build_object( - 'duplicateCount', - coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, - 'lastDuplicateAt', - ${new Date().toISOString()}::text - ) - )` - : hydrateTeamsDelivery - ? sql`${JSON.stringify(normalized)}::jsonb - || jsonb_build_object( - 'deduplication', - coalesce(${chatDeliveries.normalizedEvent}->'deduplication', '{}'::jsonb) - || jsonb_build_object( - 'duplicateCount', - coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, - 'lastDuplicateAt', - ${new Date().toISOString()}::text - ) - )` - : sql`coalesce(${chatDeliveries.normalizedEvent}, '{}'::jsonb) - || jsonb_build_object( - 'deduplication', - coalesce(${chatDeliveries.normalizedEvent}->'deduplication', '{}'::jsonb) - || jsonb_build_object( - 'duplicateCount', - coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, - 'lastDuplicateAt', - ${new Date().toISOString()}::text - ) - )`, - ...(!accepting && - ["received", "retry"].includes(existingDelivery.state) - ? { - state: "filtered" as const, - nextAttemptAt: null, - redactedError: inactiveReason, - processedAt: ignoredAt, - } - : {}), - updatedAt: new Date(), - }) - .where(eq(chatDeliveries.id, existingDelivery.id)) - .returning(); - } - } - if ( - candidate && - admittedDeliveryId && - accepting && - !provisionalTeamsSetupReply && - endpoint.provider === "microsoft-teams" && - !thread.isDM && - ["received", "retry", "processing"].includes(candidate.state) && - deliveryContentWasRedacted(candidate.normalizedEvent) - ) { - [candidate] = await tx - .update(chatDeliveries) - .set({ normalizedEvent: normalized, updatedAt: new Date() }) - .where(eq(chatDeliveries.id, candidate.id)) - .returning(); - } - if ( - candidate && - admittedDeliveryId && - !accepting && - ["received", "retry", "processing"].includes(candidate.state) - ) { - await notifyChatDeliveryWork(tx); - [candidate] = await tx - .update(chatDeliveries) - .set({ - state: "filtered", - ...(redactDestinationDelivery - ? { - normalizedEvent: redactedDestinationNormalized, - principalId: null, - } - : {}), - nextAttemptAt: null, - redactedError: inactiveReason, - processedAt: ignoredAt, - updatedAt: new Date(), - }) - .where(eq(chatDeliveries.id, candidate.id)) - .returning(); - } - if (!accepting && ignoredAt) { - await tx - .update(chatEndpoints) - .set({ lastEventAt: ignoredAt, updatedAt: ignoredAt }) - .where(eq(chatEndpoints.id, endpoint.id)); - } - return { - accepting, - candidate: candidate ?? null, - provisionalTeamsSetupReply, - }; - }); + const admission = await admitInboundMessage({ endpoint, thread, message, trigger, runtimeContext, admittedDeliveryId, deferDrainUntilFollowup, scheduledAt, envelope: { providerEventId, addressed, eventKind, normalized, redactedDestinationNormalized, githubAutomatic, githubIssue } }, discordGatewayAdmissionState); // The database token is authoritative. Publish its new expiry to the // process-local fast path only after the admission transaction commits. // Conversely, synchronously fence an obsolete local owner after a failed @@ -6930,76 +3347,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { message.author, message.raw, ); - const mayEnableSetupDestination = - // Slack channels start enabled. Never use first-test activation to - // override an existing channel the operator explicitly disabled. - endpoint.provider !== "slack" && - endpoint.provider !== "imessage-photon" && - !thread.isDM && - endpoint.status === "verifying" && - addressed && - !suppressSetupDestinationActivation && - !( - endpoint.provider === "microsoft-teams" && - surfaceKind === "linear_group" - ); - const resourceAdmission = mayEnableSetupDestination - ? await db.transaction(async (tx) => { - const currentEndpoint = await tx - .select({ status: chatEndpoints.status }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!currentEndpoint) throw notFound("Chat endpoint not found"); - const enabledResourceCount = await tx - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - ne(chatEndpointResources.type, "direct_message"), - ne(chatEndpointResources.type, "group_chat"), - ), - ) - .then((rows) => rows[0]?.count ?? 0); - const enableSetupDestination = - currentEndpoint.status === "verifying" && - enabledResourceCount === 0; - let resource = await ensureResource( - endpoint, - thread, - enableSetupDestination, - tx, - ); - if (enableSetupDestination && !resource.enabled) { - resource = await tx - .update(chatEndpointResources) - .set({ enabled: true, updatedAt: new Date() }) - .where(eq(chatEndpointResources.id, resource.id)) - .returning() - .then((rows) => rows[0] ?? resource); - } - return { enabledResourceCount, resource }; - }) - : { - enabledResourceCount: await db - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - ne(chatEndpointResources.type, "direct_message"), - ne(chatEndpointResources.type, "group_chat"), - ), - ) - .then((rows) => rows[0]?.count ?? 0), - resource: await ensureResource(endpoint, thread, false), - }; + const resourceAdmission = await admitMessageResource({ endpoint, thread, addressed, suppressSetupDestinationActivation, surfaceKind }); const { enabledResourceCount, resource } = resourceAdmission; await db .update(chatDeliveries) @@ -8079,816 +4427,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { throw error; } } - - function deliveryContentWasRedacted(normalizedEvent: unknown): boolean { - if ( - !normalizedEvent || - typeof normalizedEvent !== "object" || - Array.isArray(normalizedEvent) - ) - return false; - const filtering = (normalizedEvent as { filtering?: unknown }).filtering; - return ( - Boolean(filtering) && - typeof filtering === "object" && - !Array.isArray(filtering) && - (filtering as { contentRetained?: unknown }).contentRetained === false - ); + async function drainConversationDeliveries(...args: Parameters["drainConversationDeliveries"]>) { + return conversationDeliveryQueue.drainConversationDeliveries(...args); } - function setupDestinationActivationSuppressed( - normalizedEvent: unknown, - ): boolean { - if ( - !normalizedEvent || - typeof normalizedEvent !== "object" || - Array.isArray(normalizedEvent) - ) - return false; - const normalized = normalizedEvent as { - admission?: { origin?: unknown }; - admissionAuthorization?: { version?: unknown }; - }; - return ( - normalized.admission?.origin === "provider_confirmed_action" || - normalized.admissionAuthorization?.version === 1 - ); - } - - function normalizedDeliveryThreadId(delivery: Pick): string | null { - const normalized = delivery.normalizedEvent as { - conversation?: { externalThreadId?: unknown }; - }; - return typeof normalized.conversation?.externalThreadId === "string" - ? normalized.conversation.externalThreadId - : null; - } - - function normalizedLifecycleTargetEventId( - delivery: Pick, - ): string | null { - if ( - delivery.eventKind !== "message_updated" && - delivery.eventKind !== "message_deleted" && - delivery.eventKind !== "message_restored" - ) - return null; - const normalized = delivery.normalizedEvent as { - message?: { targetProviderEventId?: unknown; admissionDependencyEventId?: unknown }; - }; - // An edit can also introduce a new addressed request. Process that durable - // admission before waiting for the old, potentially unadmitted source. - if (typeof normalized.message?.admissionDependencyEventId === "string") - return normalized.message.admissionDependencyEventId; - return typeof normalized.message?.targetProviderEventId === "string" - ? normalized.message.targetProviderEventId - : null; - } - - function normalizedLifecycleEffect( - delivery: Pick, - ): ChatProviderLifecycleEffect | null { - const normalized = delivery.normalizedEvent as { lifecycle?: unknown }; - const value = normalized.lifecycle; - if (!value || typeof value !== "object" || Array.isArray(value)) - return null; - const effect = value as Record; - if ( - (effect.provider !== "slack" && - effect.provider !== "github" && - effect.provider !== "discord" && - effect.provider !== "microsoft-teams" && - effect.provider !== "telegram") || - typeof effect.providerEventId !== "string" - ) - return null; - if ( - effect.kind === "endpoint" && - (effect.availability === "available" || - effect.availability === "attention" || - effect.availability === "revoked") && - typeof effect.reason === "string" - ) - return effect as ChatProviderLifecycleEffect; - if ( - effect.kind === "resource" && - (effect.availability === "available" || - effect.availability === "unavailable" || - effect.availability === "removed") && - typeof effect.providerResourceId === "string" && - (effect.previousProviderResourceId === undefined || - typeof effect.previousProviderResourceId === "string") && - typeof effect.resourceType === "string" && - typeof effect.label === "string" - ) - return effect as ChatProviderLifecycleEffect; - return null; - } - - function deliveryReady(delivery: DeliveryRow, now: Date): boolean { - // Accepted input may still own the conversation head through its wake - // outbox. The action dispatcher checks its own retry/claim deadline. - if (delivery.state === "processed") return true; - if (delivery.state === "received") - return !delivery.nextAttemptAt || delivery.nextAttemptAt <= now; - if (delivery.state === "retry") { - return !delivery.nextAttemptAt || delivery.nextAttemptAt <= now; - } - return ( - delivery.state === "processing" && - delivery.updatedAt <= - new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS) - ); - } - - function conversationDeliveryOrder() { - return [ - asc( - sql`coalesce(nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')::timestamptz, ${chatDeliveries.receivedAt})`, - ), - // GitHub timestamps and Telegram message dates have one-second - // resolution. GitHub's numeric comment id and Telegram's message_id - // are monotonic within one conversation, so use them before receipt - // order. The Telegram update_id is a final provider-native tie-breaker - // for unusual payloads that lack a usable message_id. - asc(sql`coalesce( - case - when ${chatDeliveries.normalizedEvent}->'message'->>'providerMessageSequence' ~ '^[0-9]+$' - then (${chatDeliveries.normalizedEvent}->'message'->>'providerMessageSequence')::numeric - else null - end, - case - when ${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId' ~ '^[0-9]+$' - then (${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId')::numeric - else null - end - )`), - asc(sql`case - when ${chatDeliveries.normalizedEvent}->'message'->>'providerUpdateId' ~ '^[0-9]+$' - then (${chatDeliveries.normalizedEvent}->'message'->>'providerUpdateId')::numeric - else null - end`), - // GitHub exposes no sortable webhook sequence. When an edit and delete - // share its whole-second updated_at value, preserve the only valid - // lifecycle state transition: update before delete. Provider-native - // update ids (Telegram) remain the stronger preceding key. - asc(sql`case ${chatDeliveries.eventKind} - when 'message_updated' then 1 - when 'message_deleted' then 2 - when 'message_restored' then 3 - else 0 - end`), - asc(chatDeliveries.receivedAt), - asc(chatDeliveries.id), - ]; - } - - async function earliestOpenConversationDelivery( - endpointId: string, - threadId: string, - ): Promise { - const externalThreadId = sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId'`; - const candidate = await db - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, endpointId), - or( - inArray(chatDeliveries.state, ["received", "retry", "processing"]), - and( - eq(chatDeliveries.state, "processed"), - pendingInboundWakeupCondition(), - ), - ), - notInArray(chatDeliveries.eventKind, [ - "reaction_added", - "reaction_removed", - ]), - externalThreadIdentityCondition(externalThreadId, threadId), - ), - ) - .orderBy(...conversationDeliveryOrder()) - .limit(1) - .then((rows) => rows[0] ?? null); - if (!candidate) return null; - - // A lifecycle callback can reach Paperclip before its create callback. If - // the root becomes durable while the lifecycle row is waiting, always - // return that exact dependency first even when a coarse or malformed - // provider timestamp would otherwise put the edit/delete at the head. - const targetProviderEventId = normalizedLifecycleTargetEventId(candidate); - if (!targetProviderEventId) return candidate; - return db - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, endpointId), - eq(chatDeliveries.providerEventId, targetProviderEventId), - inArray(chatDeliveries.state, ["received", "retry", "processing"]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? candidate); - } - - function pendingInboundWakeupCondition(readyOnly = false): SQL { - return sql`exists ( - select 1 from ${chatActions} - where ${chatActions.deliveryId} = ${chatDeliveries.id} - and ${chatActions.endpointId} = ${chatDeliveries.endpointId} - and ${chatActions.companyId} = ${chatDeliveries.companyId} - and ${chatActions.kind} = 'inbound_wakeup' - and ${chatActions.status} in ('issued', 'processing') - ${ - readyOnly - ? sql`and ( - (${chatActions.status} = 'issued' and (${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= now())) - or (${chatActions.status} = 'processing' and ${chatActions.updatedAt} <= ${new Date(Date.now() - DELIVERY_PROCESSING_STALE_MS).toISOString()}::timestamptz) - )` - : sql`` - } - )`; - } - - async function acquireConversationDeliveryLease(input: { - companyId: string; - endpointId: string; - threadId: string; - }): Promise<{ leaseKey: string; token: string } | null> { - const now = new Date(); - const token = randomUUID(); - const leaseKey = `inbound:${createHash("sha256").update(durableExternalThreadIdentity(input.threadId)).digest("hex")}`; - const expiresAt = new Date(now.getTime() + DELIVERY_LEASE_TTL_MS); - const inserted = await db - .insert(chatEndpointLeases) - .values({ - companyId: input.companyId, - endpointId: input.endpointId, - leaseKey, - token, - expiresAt, - }) - .onConflictDoNothing() - .returning({ id: chatEndpointLeases.id }); - if (inserted.length > 0) return { leaseKey, token }; - const reclaimed = await db - .update(chatEndpointLeases) - .set({ token, expiresAt, updatedAt: now }) - .where( - and( - eq(chatEndpointLeases.companyId, input.companyId), - eq(chatEndpointLeases.endpointId, input.endpointId), - eq(chatEndpointLeases.leaseKey, leaseKey), - lte(chatEndpointLeases.expiresAt, now), - ), - ) - .returning({ id: chatEndpointLeases.id }); - return reclaimed.length > 0 ? { leaseKey, token } : null; - } - - async function releaseConversationDeliveryLease(input: { - endpointId: string; - leaseKey: string; - token: string; - }): Promise { - await db - .delete(chatEndpointLeases) - .where( - and( - eq(chatEndpointLeases.endpointId, input.endpointId), - eq(chatEndpointLeases.leaseKey, input.leaseKey), - eq(chatEndpointLeases.token, input.token), - ), - ); - } - - function messageFromDelivery( - delivery: DeliveryRow, - thread: Thread, - endpointRuntime: ChatEndpointRuntime, - ): { - message: Message; - receiptReactionSupported: boolean; - suppressSetupDestinationActivation: boolean; - trigger: ChatSdkMessageCallbackEvent["trigger"]; - providerUrl: string | null; - } | null { - if (telegramDeliveryHasZeroMessageId(delivery, endpointRuntime.provider)) - return null; - const normalized = delivery.normalizedEvent as { - acknowledgement?: { receiptReactionSupported?: unknown }; - kind?: unknown; - trigger?: unknown; - principal?: { - externalId?: unknown; - displayName?: unknown; - handle?: unknown; - }; - message?: { - providerMessageId?: unknown; - sourceProviderMessageId?: unknown; - text?: unknown; - mentionedBot?: unknown; - attachments?: Array<{ - name?: unknown; - mimeType?: unknown; - size?: unknown; - recovery?: unknown; - }>; - attachmentLimitOmissions?: unknown; - photonReply?: { guid?: unknown; part?: unknown }; - }; - conversation?: { providerUrl?: unknown }; - }; - const providerMessageId = normalized.message?.providerMessageId; - const externalId = normalized.principal?.externalId; - if (typeof providerMessageId !== "string" || typeof externalId !== "string") - return null; - const isTeams = endpointRuntime.provider === "microsoft-teams"; - const referenceOnlyTeamsAttachments = isTeams && !thread.isDM; - const originFence = lifecycleRuntimeFence(delivery); - const storedAttachments = normalized.message?.attachments ?? []; - const attachments = ( - isTeams ? storedAttachments.slice(0, 20) : storedAttachments - ) - .map((attachment) => { - if (!attachment || typeof attachment !== "object") return null; - const rehydrated = - attachment.recovery && - (!referenceOnlyTeamsAttachments || - (typeof attachment.recovery === "object" && - attachment.recovery !== null && - "locator" in attachment.recovery && - typeof attachment.recovery.locator === "object" && - attachment.recovery.locator !== null && - "kind" in attachment.recovery.locator && - attachment.recovery.locator.kind === "teams_inline_image")) - ? endpointRuntime.rehydrateAttachment(attachment.recovery, { - threadId: thread.id, - messageId: typeof normalized.message?.sourceProviderMessageId === "string" ? normalized.message.sourceProviderMessageId : providerMessageId, - runtimeGeneration: originFence?.generation, - credentialFingerprint: originFence?.credentialFingerprint, - principalExternalId: externalId, - isDirectMessage: thread.isDM, - }) - : null; - if (rehydrated) return rehydrated; - if ( - isTeams || - endpointRuntime.provider === "imessage-photon" || - (endpointRuntime.provider === "telegram" && - typeof attachment.recovery === "object" && - attachment.recovery !== null && - "locator" in attachment.recovery && - typeof attachment.recovery.locator === "object" && - attachment.recovery.locator !== null && - "kind" in attachment.recovery.locator && - attachment.recovery.locator.kind === "telegram_media") - ) { - // Non-personal references and personal files whose safe download - // capability did not survive restart still belong to this input. - // Keep bounded metadata so ingestion records an explicit omission; - // never persist bearer URLs or fabricate a download capability. - // Even a legacy URL locator cannot enable non-personal downloads. - // A Telegram media locator which fails its exact source proof also - // remains an unavailable input, never an empty attachment-free turn. - return { - type: "file", - name: - sanitizeFilename( - typeof attachment.name === "string" - ? attachment.name - : undefined, - ) ?? undefined, - mimeType: normalizeContentType( - typeof attachment.mimeType === "string" - ? attachment.mimeType - : undefined, - ).slice(0, 255), - size: - typeof attachment.size === "number" && - Number.isSafeInteger(attachment.size) && - attachment.size >= 0 - ? attachment.size - : undefined, - } as Attachment; - } - return null; - }) - .filter((attachment): attachment is Attachment => Boolean(attachment)); - const message = { - id: providerMessageId, - threadId: thread.id, - text: - typeof normalized.message?.text === "string" - ? normalized.message.text - : "", - formatted: { type: "root", children: [] }, - raw: - endpointRuntime.provider === "imessage-photon" && - typeof normalized.message?.photonReply?.guid === "string" - ? { - replyTargetGuid: normalized.message.photonReply.guid, - threadOriginatorPart: normalized.message.photonReply.part, - } - : endpointRuntime.provider === "github" && typeof normalized.message?.sourceProviderMessageId === "string" - ? { githubSourceMessageId: normalized.message.sourceProviderMessageId } : {}, - author: { - userId: externalId, - userName: - typeof normalized.principal?.handle === "string" - ? normalized.principal.handle - : externalId, - fullName: - typeof normalized.principal?.displayName === "string" - ? normalized.principal.displayName - : externalId, - isBot: false, - isMe: false, - isSystem: false, - }, - metadata: { dateSent: delivery.receivedAt, edited: false }, - attachments, - links: [], - isMention: normalized.message?.mentionedBot === true, - } as unknown as Message; - const githubManual = delivery.normalizedEvent.githubManual; - if (endpointRuntime.provider === "github" && githubManual && typeof githubManual === "object") githubManualMessages.set(message, githubManual as { policy: GitHubReviewPolicy; revision: number; event: "mention" | "comment" }); - const githubAutomatic = delivery.normalizedEvent.githubAutomatic; - if (endpointRuntime.provider === "github" && githubAutomatic && typeof githubAutomatic === "object") { - githubAutomaticMessages.set(message, githubAutomatic as { context: GitHubReviewEventContext; revision: number; policy: GitHubReviewPolicy }); - } - const githubIssue = delivery.normalizedEvent.githubIssue; - if (endpointRuntime.provider === "github" && githubIssue && typeof githubIssue === "object") { - githubIssueMessages.set(message, githubIssue as { context: GitHubIssueEventContext; revision: number; policy: GitHubReviewPolicy }); - } - if ( - endpointRuntime.provider === "github" || - endpointRuntime.provider === "imessage-photon" - ) { - restoreGitHubAttachmentLimitOmissions( - message, - normalized.message?.attachmentLimitOmissions, - ); - } - const normalizedTrigger = normalized.trigger; - const trigger: ChatSdkMessageCallbackEvent["trigger"] = - normalizedTrigger === "direct_message" || - normalizedTrigger === "mention" || - normalizedTrigger === "subscribed_message" || - normalizedTrigger === "unaddressed_message" - ? normalizedTrigger - : normalized.kind === "direct_message" - ? "direct_message" - : normalized.kind === "mention" - ? "mention" - : "subscribed_message"; - return { - message, - // Legacy deliveries all originated from native provider messages and - // therefore retain the historical default. - receiptReactionSupported: - normalized.acknowledgement?.receiptReactionSupported !== false, - suppressSetupDestinationActivation: setupDestinationActivationSuppressed( - delivery.normalizedEvent, - ), - trigger, - providerUrl: - typeof normalized.conversation?.providerUrl === "string" - ? normalized.conversation.providerUrl - : null, - }; - } - - /** - * Drain one external conversation in durable receipt order. The database - * lease is endpoint + thread scoped, so separate server processes can work - * on different conversations concurrently but can never mutate the same - * Paperclip task from two inbound turns at once. - */ - async function drainConversationDeliveries( - endpointId: string, - threadId: string, - ): Promise { - const endpoint = await db - .select() - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpointId)) - .then((rows) => rows[0] ?? null); - if (!endpoint) return false; - if (endpoint.status === "paused" || endpoint.status === "attention") - return false; - - const lease = await acquireConversationDeliveryLease({ - companyId: endpoint.companyId, - endpointId, - threadId, - }); - if (!lease) return false; - - let leaseOwned = true; - let renewal: Promise | null = null; - const renewTimer = setInterval( - () => { - if (!leaseOwned || renewal) return; - const expiresAt = new Date(Date.now() + DELIVERY_LEASE_TTL_MS); - renewal = Promise.resolve() - .then(async () => { - const renewed = options.renewConversationDeliveryLease - ? await options.renewConversationDeliveryLease({ - endpointId, - leaseKey: lease.leaseKey, - token: lease.token, - expiresAt, - }) - : await db - .update(chatEndpointLeases) - .set({ expiresAt, updatedAt: new Date() }) - .where( - and( - eq(chatEndpointLeases.endpointId, endpointId), - eq(chatEndpointLeases.leaseKey, lease.leaseKey), - eq(chatEndpointLeases.token, lease.token), - ), - ) - .returning({ id: chatEndpointLeases.id }) - .then((rows) => rows.length > 0); - if (!renewed) leaseOwned = false; - }) - .catch((error) => { - // A failed renewal has unknown durability. Stop this local drain at - // the next delivery boundary so another worker can never overlap - // later turns after the original lease expires or is reclaimed. - leaseOwned = false; - logger.warn( - { endpointId, error: redactError(error) }, - "could not renew external chat conversation lease", - ); - }) - .finally(() => { - renewal = null; - }); - }, - options.conversationLeaseRenewalIntervalMs ?? DELIVERY_LEASE_TTL_MS / 3, - ); - renewTimer.unref?.(); - - try { - for ( - let processed = 0; - processed < DELIVERY_DRAIN_LIMIT; - processed += 1 - ) { - if (!leaseOwned) break; - const delivery = await earliestOpenConversationDelivery( - endpointId, - threadId, - ); - if (!delivery || !deliveryReady(delivery, new Date())) break; - if (delivery.state === "processed") { - if (!(await processInboundWakeup(delivery.id))) break; - continue; - } - if (telegramDeliveryHasZeroMessageId(delivery, endpoint.provider)) { - const filtered = await db - .transaction(async (tx) => { - // Do not round-trip a PostgreSQL microsecond timestamp through - // JS Date for a CAS. Lock the exact receipt, then revalidate the - // current source and claim before changing it. A competing live - // claim is a retry boundary, not permission to settle its work. - const [current] = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, delivery.id), - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - ), - ) - .for("update", { noWait: true }); - const filteredAt = new Date(); - if ( - !current || - current.state !== delivery.state || - current.eventKind !== delivery.eventKind || - !["received", "retry", "processing"].includes(current.state) || - normalizedDeliveryThreadId(current) !== threadId || - !deliveryReady(current, filteredAt) || - !telegramDeliveryHasZeroMessageId(current, endpoint.provider) - ) - return false; - await notifyChatDeliveryWork(tx); - const changed = await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - processedAt: filteredAt, - updatedAt: filteredAt, - redactedError: - "Telegram message ID zero cannot authorize ordinary work", - }) - .where(eq(chatDeliveries.id, current.id)) - .returning({ id: chatDeliveries.id }); - return changed.length === 1; - }) - .catch((error: unknown) => { - if (isExternalChatWaitAuthorizationContention(error)) - return false; - throw error; - }); - if (!filtered) break; - // Retain original evidence/comments; filtering adds no user content - // and cannot hydrate attachments, invoke commands, or send feedback. - await settleRejectedInboundWakeups(delivery.id); - liveInboundMessages.delete(delivery.id); - continue; - } - if (endpoint.status === "archived" || endpoint.status === "revoked") { - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "filtered", - redactedError: "Connection was removed before processing", - processedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(chatDeliveries.id, delivery.id)); - }); - liveInboundMessages.delete(delivery.id); - continue; - } - - if ( - endpoint.provider === "discord" && - discordRootThreadPending(delivery) - ) { - // This is the only recovery path for the narrow crash window after - // pre-admission but before Chat's ordinary callback. A successful - // read proves the root thread exists; an explicit 404/410 proves it - // does not. Ambiguous transport and auth failures remain retryable. - if (await reconcileDiscordRootThread(endpoint, delivery, threadId)) { - continue; - } - break; - } - - const live = liveInboundMessages.get(delivery.id); - try { - if ( - delivery.eventKind === "message_updated" || - delivery.eventKind === "message_deleted" || - delivery.eventKind === "message_restored" - ) { - await processLifecycleDelivery(endpoint, delivery); - } else if (live) { - await processMessage( - endpoint, - live.thread, - live.message, - live.trigger, - false, - live.providerUrl, - live.runtimeContext, - undefined, - delivery.id, - live.receiptReactionSupported, - undefined, - false, - setupDestinationActivationSuppressed(delivery.normalizedEvent), - ); - } else { - if (deliveryContentWasRedacted(delivery.normalizedEvent)) { - const filteredAt = new Date(); - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "filtered", - principalId: null, - nextAttemptAt: null, - processedAt: filteredAt, - redactedError: - "Provisional Teams setup reply could not be hydrated from a current provider event", - updatedAt: filteredAt, - }) - .where( - and( - eq(chatDeliveries.id, delivery.id), - inArray(chatDeliveries.state, [ - "received", - "retry", - "processing", - ]), - ), - ); - }); - continue; - } - const endpointRuntime = await runtimeFor(endpoint); - const deliveryRuntimeFence = lifecycleRuntimeFence(delivery); - const currentRuntimeContext = runtimeContexts.get( - endpointRuntime as object, - ); - const thread = endpointRuntime.thread(threadId); - const reconstructed = messageFromDelivery( - delivery, - thread, - endpointRuntime, - ); - if (!reconstructed) { - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "failed", - redactedError: "Normalized delivery is incomplete", - updatedAt: new Date(), - }) - .where(eq(chatDeliveries.id, delivery.id)); - }); - continue; - } - await processMessage( - endpoint, - thread, - reconstructed.message, - reconstructed.trigger, - false, - reconstructed.providerUrl, - deliveryRuntimeFence - ? { - ...deliveryRuntimeFence, - endpointRuntime, - } - : currentRuntimeContext, - undefined, - delivery.id, - reconstructed.receiptReactionSupported, - undefined, - false, - reconstructed.suppressSetupDestinationActivation, - ); - } - } catch (error) { - logger.warn( - { - endpointId, - deliveryId: delivery.id, - error: redactError(error), - }, - "external chat delivery will retry in receipt order", - ); - } - - const state = await db - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where(eq(chatDeliveries.id, delivery.id)) - .then((rows) => rows[0]?.state ?? null); - if ( - state === "processed" || - state === "filtered" || - state === "failed" - ) { - if (state !== "processed") - await settleRejectedInboundWakeups(delivery.id); - liveInboundMessages.delete(delivery.id); - continue; - } - // A retry delay or another still-live processing claim is a strict - // head-of-line barrier; later replies may not overtake it. - break; - } - } finally { - clearInterval(renewTimer); - await renewal; - await releaseConversationDeliveryLease({ endpointId, ...lease }); - } - - const next = await earliestOpenConversationDelivery(endpointId, threadId); - if (next?.state === "processed") { - const [ready] = await db - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, next.id), - pendingInboundWakeupCondition(true), - ), - ) - .limit(1); - return Boolean(ready); - } - return Boolean(next && deliveryReady(next, new Date())); - } async function handleSdkMessage( event: ChatSdkMessageCallbackEvent, @@ -8933,3947 +4475,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { messageOptions.slackSlashControl === true, ); } - - async function conversationForThread(endpointId: string, threadId: string) { - const exact = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.endpointId, endpointId), - eq(chatConversations.externalThreadId, threadId), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .then((rows) => rows[0] ?? null); - if (exact || !threadId.startsWith("teams:")) return exact; - - // Before Paperclip made Teams route state independent, the adapter - // embedded the mutable Bot Connector serviceUrl in every thread id. Read - // those rows by the immutable conversation id so a regional route change - // continues the same task. New rows use the canonical route-free id. - if (!teamsConversationId(threadId)) return null; - return db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.endpointId, endpointId), - externalThreadIdentityCondition( - sql`${chatConversations.externalThreadId}`, - threadId, - ), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .then( - (rows) => - rows.find((row) => - sameTeamsThreadIdentity(row.externalThreadId, threadId), - ) ?? null, - ); + async function handleMessageUpdated(...args: Parameters["handleMessageUpdated"]>) { + return messageLifecycle.handleMessageUpdated(...args); } - async function recordLifecycleDelivery( - input: { - actor?: LifecycleActor; - isBotMessage?: true; - endpointId: string; - threadId: string; - messageId: string; - eventKind: "message_updated" | "message_deleted" | "message_restored"; - text: string; - providerEventId?: string; - providerMessageSequence?: number | null; - providerSentAt?: string | null; - providerUpdateId?: number | null; - raw?: unknown; - revision?: string | null; - admissionDependencyEventId?: string; - }, - runtimeContext?: RuntimeContext, - githubSourceMapped = false, - ) { - if (!githubSourceMapped && input.threadId.startsWith("github:") && /^[1-9][0-9]*$/.test(input.messageId)) { - const requests = await db.select({ id: chatDeliveries.id, messageId: sql`${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId'` }) - .from(chatDeliveries).innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatDeliveries.endpointId), eq(chatEndpoints.companyId, chatDeliveries.companyId))) - .where(and(eq(chatDeliveries.endpointId, input.endpointId), eq(chatEndpoints.provider, "github"), - inArray(chatDeliveries.eventKind, ["mention", "message", "direct_message"]), - input.admissionDependencyEventId ? ne(chatDeliveries.providerEventId, input.admissionDependencyEventId) : undefined, - sql`${chatDeliveries.normalizedEvent}->'message'->>'sourceProviderMessageId' = ${input.messageId}`, - sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId' = ${input.threadId}`)); - if (requests.length) { - for (const request of requests) await recordLifecycleDelivery({ ...input, messageId: request.messageId, - providerEventId: `${input.providerEventId ?? `${input.eventKind}:${input.threadId}:${input.messageId}:${input.revision ?? "once"}`}:${request.id}` }, runtimeContext, true); - return; - } - } - const durableThreadId = durableExternalThreadIdentity(input.threadId); - const providerEventId = - input.providerEventId ?? - `${input.eventKind}:${durableThreadId}:${input.messageId}:${input.revision ?? "once"}`; - const targetProviderEventId = `${durableThreadId}:${input.messageId}`; - const admission = await db.transaction(async (tx) => { - const endpoint = await tx - .select() - .from(chatEndpoints) - .where(eq(chatEndpoints.id, input.endpointId)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!endpoint) throw notFound("Chat endpoint not found"); - if ( - runtimeContext && - !(await runtimeCallbackEndpoint(tx, endpoint.id, runtimeContext, [ - "verifying", - "active", - ])) - ) - return null; - // A provider has already authenticated this callback. During setup the - // message can belong to the test conversation, so retain its correction. - // Paused or unhealthy connections acknowledge late callbacks without - // mutating the bound task or causing provider retry storms. - if (endpoint.status !== "verifying" && endpoint.status !== "active") - return null; - const reorderWindow = INGRESS_REORDER_WINDOW_MS[endpoint.provider]; - const scheduledAt = - options.deferWebhookProcessing === true && reorderWindow - ? (scheduledConversationDrains.get( - conversationDrainKey(input.endpointId, input.threadId), - ) ?? Date.now() + reorderWindow) - : null; - await notifyChatDeliveryWork(tx); - const [delivery] = await tx - .insert(chatDeliveries) - .values({ - companyId: endpoint.companyId, - endpointId: input.endpointId, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind: input.eventKind, - normalizedEvent: { - providerEventId, - kind: input.eventKind, - ...(runtimeContext - ? { - runtimeContext: { - credentialFingerprint: runtimeContext.credentialFingerprint, - generation: runtimeContext.generation, - }, - } - : {}), - ...(input.actor ? { principal: input.actor } : {}), - conversation: { externalThreadId: input.threadId }, - message: { - providerMessageId: input.messageId, - targetProviderEventId, - ...(input.admissionDependencyEventId ? { admissionDependencyEventId: input.admissionDependencyEventId } : {}), - ...(endpoint.provider === "github" && input.isBotMessage === true - ? { isBotMessage: true } - : {}), - providerMessageSequence: input.providerMessageSequence ?? null, - providerSentAt: input.providerSentAt ?? null, - providerUpdateId: input.providerUpdateId ?? null, - text: input.text, - }, - }, - state: "received", - nextAttemptAt: scheduledAt ? new Date(scheduledAt) : null, - }) - .onConflictDoNothing() - .returning(); - if (delivery) return { delivery, endpoint, scheduledAt }; - const existing = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, input.endpointId), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - return existing ? { delivery: existing, endpoint, scheduledAt } : null; - }); - if (admission) { - // Lifecycle callbacks can carry a new regional Bot Connector route. Keep - // that provider-authenticated route current even when processing is - // deferred, but deliberately omit the raw activity here. Lifecycle - // callbacks never populate the adapter's user cache; the admitted root - // message already established the principal metadata they must match. - await recordCurrentMicrosoftTeamsRoute( - admission.endpoint, - runtimeContext, - input.threadId, - input.raw, - false, - ); - } - if ( - !admission || - admission.delivery.state === "processed" || - admission.delivery.state === "filtered" || - admission.delivery.state === "failed" - ) - return; - if (options.deferWebhookProcessing === true) { - scheduleConversationDrain( - input.endpointId, - input.threadId, - admission.scheduledAt ?? Date.now(), - ); - return; - } - await drainConversationDeliveries(input.endpointId, input.threadId); + async function handleMessageDeleted(...args: Parameters["handleMessageDeleted"]>) { + return messageLifecycle.handleMessageDeleted(...args); } - function lifecycleMessageFromDelivery(delivery: DeliveryRow): { - actor: LifecycleActor | null; - isBotMessage: boolean; - messageId: string; - providerSentAt: string | null; - targetProviderEventId: string; - text: string; - threadId: string; - } | null { - if ( - delivery.eventKind !== "message_updated" && - delivery.eventKind !== "message_deleted" && - delivery.eventKind !== "message_restored" - ) - return null; - const normalized = delivery.normalizedEvent as { - principal?: { - displayName?: unknown; - externalId?: unknown; - handle?: unknown; - }; - message?: { - isBotMessage?: unknown; - providerMessageId?: unknown; - providerSentAt?: unknown; - targetProviderEventId?: unknown; - text?: unknown; - }; - }; - const messageId = normalized.message?.providerMessageId; - const targetProviderEventId = normalized.message?.targetProviderEventId; - const providerSentAt = normalized.message?.providerSentAt; - const text = normalized.message?.text; - const threadId = normalizedDeliveryThreadId(delivery); - const externalId = normalized.principal?.externalId; - const actor = - typeof externalId === "string" && externalId - ? { - externalId, - displayName: - typeof normalized.principal?.displayName === "string" - ? normalized.principal.displayName - : externalId, - handle: - typeof normalized.principal?.handle === "string" - ? normalized.principal.handle - : externalId, - } - : null; - return typeof messageId === "string" && - typeof targetProviderEventId === "string" && - typeof text === "string" && - threadId !== null - ? { - actor, - isBotMessage: normalized.message?.isBotMessage === true, - messageId, - providerSentAt: - typeof providerSentAt === "string" ? providerSentAt : null, - targetProviderEventId, - text, - threadId, - } - : null; - } - - async function processLifecycleDelivery( - endpoint: EndpointRow, - candidate: DeliveryRow, - ): Promise { - const lifecycle = lifecycleMessageFromDelivery(candidate); - if (!lifecycle) { - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "failed", - redactedError: "Normalized lifecycle delivery is incomplete", - updatedAt: new Date(), - }) - .where(eq(chatDeliveries.id, candidate.id)); - }); - return; - } - - const now = new Date(); - const staleBefore = new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS); - const claimConditions = [ - eq(chatDeliveries.id, candidate.id), - eq(chatDeliveries.state, candidate.state), - ]; - if (candidate.state === "processing") - claimConditions.push(lte(chatDeliveries.updatedAt, staleBefore)); - const [activeDelivery] = await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "processing", - attempts: candidate.attempts + 1, - nextAttemptAt: null, - redactedError: null, - updatedAt: now, - }) - .where(and(...claimConditions)) - .returning(); - }); - if (!activeDelivery) return; - - try { - await db.transaction(async (tx) => { - const admittedRuntimeContext = lifecycleRuntimeFence(activeDelivery); - const currentEndpoint = admittedRuntimeContext - ? await runtimeCallbackEndpoint( - tx, - endpoint.id, - admittedRuntimeContext, - ["verifying", "active"], - ) - : null; - if (!admittedRuntimeContext || !currentEndpoint) { - const filteredAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - processedAt: filteredAt, - redactedError: - "Message lifecycle callback belonged to a superseded runtime", - updatedAt: filteredAt, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - let githubBotSourceEdit = false; - if ( - currentEndpoint.provider === "github" && - activeDelivery.eventKind === "message_updated" - ) { - // Bot callbacks can arrive before the outbound publication commits - // its message link. The Boolean comes only from GitHub's verified - // sender/comment-author type, never from comment text. Legacy rows - // lack it, so also recognize exact already-published outbound links. - const linkedMessages = await tx - .select({ direction: chatMessageLinks.direction }) - .from(chatMessageLinks) - .innerJoin( - chatConversations, - and( - eq(chatConversations.id, chatMessageLinks.conversationId), - eq(chatConversations.companyId, chatMessageLinks.companyId), - eq(chatConversations.endpointId, chatMessageLinks.endpointId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, currentEndpoint.companyId), - eq(chatMessageLinks.endpointId, currentEndpoint.id), - eq(chatMessageLinks.providerMessageId, lifecycle.messageId), - eq(chatConversations.externalThreadId, lifecycle.threadId), - ), - ); - const outbound = linkedMessages.some( - (row) => row.direction === "outbound", - ); - // A bot editor can change an already-admitted human source. That - // authenticated change revokes the old source, but never admits the - // bot's new content. Unknown/self/outbound updates remain suppressed. - githubBotSourceEdit = - lifecycle.isBotMessage && - !outbound && - linkedMessages.some((row) => row.direction === "inbound"); - if (outbound || (lifecycle.isBotMessage && !githubBotSourceEdit)) { - const filteredAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - principalId: null, - nextAttemptAt: null, - processedAt: filteredAt, - updatedAt: filteredAt, - redactedError: - "Provider bot or outbound message updates do not create inbound work", - normalizedEvent: { - providerEventId: - activeDelivery.normalizedEvent.providerEventId ?? - activeDelivery.providerEventId, - kind: activeDelivery.eventKind, - conversation: { externalThreadId: lifecycle.threadId }, - message: { - providerMessageId: lifecycle.messageId, - targetProviderEventId: lifecycle.targetProviderEventId, - }, - filtering: { contentRetained: false }, - }, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - } - if ( - activeDelivery.eventKind === "message_updated" || - activeDelivery.eventKind === "message_deleted" || - activeDelivery.eventKind === "message_restored" - ) { - const latestDeleteTransition = await tx - .select({ - eventKind: chatDeliveries.eventKind, - providerSentAt: sql< - string | null - >`nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')`, - }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, activeDelivery.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - inArray(chatDeliveries.eventKind, [ - "message_deleted", - "message_restored", - ]), - eq(chatDeliveries.state, "processed"), - externalThreadIdentityCondition( - sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId'`, - lifecycle.threadId, - ), - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lifecycle.targetProviderEventId}`, - ), - ) - .orderBy( - desc( - sql`coalesce(nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')::timestamptz, ${chatDeliveries.processedAt})`, - ), - desc(sql`case ${chatDeliveries.eventKind} - when 'message_restored' then 2 - when 'message_deleted' then 1 - else 0 - end`), - desc(chatDeliveries.processedAt), - desc(chatDeliveries.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - const latestTransitionAt = latestDeleteTransition?.providerSentAt - ? Date.parse(latestDeleteTransition.providerSentAt) - : Number.NaN; - const incomingTransitionAt = lifecycle.providerSentAt - ? Date.parse(lifecycle.providerSentAt) - : Number.NaN; - const incomingTransitionIsStale = - latestDeleteTransition !== null && - Number.isFinite(latestTransitionAt) && - Number.isFinite(incomingTransitionAt) && - (incomingTransitionAt < latestTransitionAt || - (incomingTransitionAt === latestTransitionAt && - activeDelivery.eventKind === "message_deleted" && - latestDeleteTransition.eventKind === "message_restored")); - const editTargetsDeletedMessage = - activeDelivery.eventKind === "message_updated" && - latestDeleteTransition?.eventKind === "message_deleted"; - const editPredatesRestore = - activeDelivery.eventKind === "message_updated" && - latestDeleteTransition?.eventKind === "message_restored" && - Number.isFinite(latestTransitionAt) && - Number.isFinite(incomingTransitionAt) && - incomingTransitionAt < latestTransitionAt; - if ( - incomingTransitionIsStale || - editTargetsDeletedMessage || - editPredatesRestore - ) { - const filteredAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - normalizedEvent: { - providerEventId: - activeDelivery.normalizedEvent.providerEventId ?? - activeDelivery.providerEventId, - kind: activeDelivery.eventKind, - conversation: { externalThreadId: lifecycle.threadId }, - message: { - providerMessageId: lifecycle.messageId, - targetProviderEventId: lifecycle.targetProviderEventId, - }, - filtering: { contentRetained: false }, - }, - principalId: null, - nextAttemptAt: null, - processedAt: filteredAt, - redactedError: editTargetsDeletedMessage - ? "Message edit arrived after the provider message was deleted" - : "Message lifecycle callback was older than the current provider state", - updatedAt: filteredAt, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - } - const originalDelivery = await tx - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, endpoint.id), - eq( - chatDeliveries.providerEventId, - lifecycle.targetProviderEventId, - ), - ), - ) - .then((rows) => rows[0] ?? null); - const linkedTargets = await tx - .select({ - conversationId: chatConversations.id, - externalThreadId: chatConversations.externalThreadId, - issueId: chatConversations.issueId, - }) - .from(chatMessageLinks) - .innerJoin( - chatConversations, - and( - eq(chatConversations.companyId, chatMessageLinks.companyId), - eq(chatConversations.id, chatMessageLinks.conversationId), - ), - ) - .where( - and( - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.providerMessageId, lifecycle.messageId), - eq(chatMessageLinks.direction, "inbound"), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .then((rows) => rows); - const linkedTarget = - linkedTargets.find((candidate) => - currentEndpoint.provider === "microsoft-teams" - ? sameTeamsThreadIdentity( - candidate.externalThreadId, - lifecycle.threadId, - ) - : candidate.externalThreadId === lifecycle.threadId, - ) ?? null; - - if (!linkedTarget) { - const originalIsOpen = - originalDelivery !== null && - ["received", "processing", "retry"].includes( - originalDelivery.state, - ); - const waitForRoot = - originalIsOpen || - activeDelivery.attempts <= ORPHAN_FOLLOW_UP_MAX_ATTEMPTS; - const resolutionAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set( - waitForRoot - ? { - state: "retry", - nextAttemptAt: new Date( - resolutionAt.getTime() + ORPHAN_FOLLOW_UP_GRACE_MS, - ), - redactedError: originalIsOpen - ? "Waiting for the original message to finish processing" - : "Waiting briefly for the original message", - updatedAt: resolutionAt, - } - : { - state: "filtered", - nextAttemptAt: null, - redactedError: - "Original message was not admitted to this conversation", - processedAt: resolutionAt, - updatedAt: resolutionAt, - }, - ) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - - const currentConversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, currentEndpoint.companyId), - eq(chatConversations.endpointId, currentEndpoint.id), - eq(chatConversations.id, linkedTarget.conversationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const currentResource = - currentConversation?.resourceId && - !currentConversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq( - chatEndpointResources.companyId, - currentEndpoint.companyId, - ), - eq(chatEndpointResources.endpointId, currentEndpoint.id), - eq( - chatEndpointResources.id, - currentConversation.resourceId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const retainSourceInvalidation = async ( - principalId: string | null, - reason: string, - ): Promise => { - if ( - !currentConversation || - activeDelivery.eventKind !== "message_updated" - ) - return false; - const invalidatedAt = new Date(); - // Provider authentication proves the exact source changed. The - // editor's current Paperclip rights govern admitting new content, - // not whether a later regrant can resurrect the stale old source. - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "processed", - conversationId: currentConversation.id, - principalId, - normalizedEvent: { - providerEventId: - activeDelivery.normalizedEvent.providerEventId ?? - activeDelivery.providerEventId, - kind: activeDelivery.eventKind, - runtimeContext: { - generation: admittedRuntimeContext.generation, - credentialFingerprint: - admittedRuntimeContext.credentialFingerprint, - }, - conversation: { externalThreadId: lifecycle.threadId }, - message: { - providerMessageId: lifecycle.messageId, - targetProviderEventId: lifecycle.targetProviderEventId, - providerSentAt: lifecycle.providerSentAt, - }, - filtering: { contentRetained: false }, - }, - nextAttemptAt: null, - processedAt: invalidatedAt, - redactedError: reason, - updatedAt: invalidatedAt, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return true; - }; - if ( - githubBotSourceEdit && - (await retainSourceInvalidation( - null, - "Provider bot edit invalidated an existing source without admitting bot content", - )) - ) - return; - - let lifecyclePrincipalId: string | null = null; - const requiresLifecycleActorAuthorization = - (activeDelivery.eventKind === "message_updated" || - activeDelivery.eventKind === "message_restored") && - (lifecycle.actor !== null || - currentEndpoint.provider === "github" || - currentEndpoint.provider === "telegram" || - currentEndpoint.provider === "microsoft-teams"); - if (requiresLifecycleActorAuthorization) { - const lifecyclePrincipal = lifecycle.actor - ? await tx - .select({ - id: chatExternalPrincipals.id, - isBot: chatExternalPrincipals.isBot, - kind: chatExternalPrincipals.kind, - }) - .from(chatExternalPrincipals) - .where( - and( - eq( - chatExternalPrincipals.companyId, - currentEndpoint.companyId, - ), - eq( - chatExternalPrincipals.provider, - currentEndpoint.provider, - ), - eq( - chatExternalPrincipals.providerAccountId, - currentEndpoint.providerAccountId ?? "unknown", - ), - eq( - chatExternalPrincipals.externalId, - lifecycle.actor.externalId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const authorization = - lifecyclePrincipal && - lifecyclePrincipal.kind === "user" && - !lifecyclePrincipal.isBot - ? await lockCurrentPrincipalAuthorization( - tx, - currentEndpoint, - lifecyclePrincipal.id, - ) - : null; - const authorizedPrincipalId = authorization?.allowed - ? (lifecyclePrincipal?.id ?? null) - : null; - if (!authorizedPrincipalId) { - if ( - await retainSourceInvalidation( - lifecyclePrincipal?.id ?? null, - "Provider edit invalidation retained without admitting content from an unauthorized actor", - ) - ) - return; - const filteredAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - principalId: lifecyclePrincipal?.id ?? null, - state: "filtered", - normalizedEvent: { - providerEventId: - activeDelivery.normalizedEvent.providerEventId ?? - activeDelivery.providerEventId, - kind: activeDelivery.eventKind, - conversation: { externalThreadId: lifecycle.threadId }, - message: { - providerMessageId: lifecycle.messageId, - targetProviderEventId: lifecycle.targetProviderEventId, - }, - filtering: { contentRetained: false }, - }, - nextAttemptAt: null, - processedAt: filteredAt, - redactedError: - "External message actor is no longer authorized for lifecycle events", - updatedAt: filteredAt, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - lifecyclePrincipalId = authorizedPrincipalId; - } - - const destinationAllowed = - currentConversation !== null && - ["active", "waiting"].includes(currentConversation.state) && - (currentConversation.isDirectMessage - ? currentEndpoint.allowDirectMessages - : nonDirectDestinationAllowed(currentEndpoint, currentResource)); - if (!destinationAllowed) { - const filteredAt = new Date(); - // A verified deletion or authorized edit still revokes the exactly - // linked source while reach is disabled. Re-enabling access must not - // revive stale files. Keep only the invalidation, never the new - // content: no task comment, attachment import or wake is allowed. - const retainInvalidation = - currentConversation !== null && - (activeDelivery.eventKind === "message_deleted" || - (activeDelivery.eventKind === "message_updated" && - lifecyclePrincipalId !== null)); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: retainInvalidation ? "processed" : "filtered", - ...(retainInvalidation - ? { - conversationId: currentConversation.id, - principalId: lifecyclePrincipalId, - } - : {}), - normalizedEvent: { - providerEventId: - activeDelivery.normalizedEvent.providerEventId ?? - activeDelivery.providerEventId, - kind: activeDelivery.eventKind, - ...(retainInvalidation - ? { - runtimeContext: { - generation: admittedRuntimeContext.generation, - credentialFingerprint: - admittedRuntimeContext.credentialFingerprint, - }, - } - : {}), - conversation: { externalThreadId: lifecycle.threadId }, - message: { - providerMessageId: lifecycle.messageId, - targetProviderEventId: lifecycle.targetProviderEventId, - ...(retainInvalidation - ? { providerSentAt: lifecycle.providerSentAt } - : {}), - }, - filtering: { contentRetained: false }, - }, - nextAttemptAt: null, - processedAt: filteredAt, - redactedError: retainInvalidation - ? activeDelivery.eventKind === "message_deleted" - ? "Provider deletion retained without task content while destination access is disabled" - : "Provider edit invalidation retained without task content while destination access is disabled" - : "Destination is no longer enabled for message lifecycle events", - updatedAt: filteredAt, - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return; - } - - await issuesSvc.addComment( - linkedTarget.issueId, - lifecycle.text, - {}, - { authorType: "system" }, - tx, - ); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - conversationId: linkedTarget.conversationId, - principalId: lifecyclePrincipalId, - state: "processed", - processedAt: new Date(), - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - }); - } catch (error) { - const terminal = activeDelivery.attempts >= 5; - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: terminal ? "failed" : "retry", - nextAttemptAt: terminal - ? null - : new Date( - Date.now() + - Math.min(60_000, 1000 * 2 ** activeDelivery.attempts), - ), - redactedError: redactError(error), - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - }); - throw error; - } - } - - async function handleMessageUpdated( - event: ChatSdkMessageUpdatedCallbackEvent, - runtimeContext: RuntimeContext, - ) { - if ( - event.provider === "discord" && - !(await ensureDiscordGatewayRuntimeIsCurrent( - event.endpointId, - runtimeContext, - )) - ) { - return; - } - const text = `An external message was edited:\n\n${event.message.text.slice(0, MAX_INBOUND_TEXT)}`; - const telegramRich = - event.provider === "telegram" - ? normalizeTelegramRichMessage(event.message.raw) - : null; - await recordLifecycleDelivery( - { - actor: lifecycleActorFromAuthor( - event.provider, - event.message.author, - event.message.raw, - ), - endpointId: event.endpointId, - threadId: event.thread.id, - messageId: event.message.id, - eventKind: "message_updated", - text, - providerMessageSequence: - event.provider === "telegram" - ? telegramMessageSequence(event.message.raw) - : null, - providerSentAt: - ( - event.message.metadata.editedAt ?? event.message.metadata.dateSent - )?.toISOString() ?? null, - raw: event.message.raw, - revision: `${event.message.metadata.editedAt?.toISOString() ?? "unknown"}:${createHash("sha256").update(event.message.text).digest("hex")}${event.provider === "slack" ? `:${slackLifecycleFilesDigest(event.message.raw)}` : event.provider === "discord" ? `:${discordLifecycleFilesDigest(event.message.raw)}` : telegramRich ? `:${telegramRich.mediaDigest}` : ""}`, - }, - runtimeContext, - ); - } - - async function handleMessageDeleted( - event: ChatSdkCallbackEvent, - runtimeContext: RuntimeContext, - ) { - if ( - event.provider === "discord" && - !(await ensureDiscordGatewayRuntimeIsCurrent( - event.endpointId, - runtimeContext, - )) - ) { - return; - } - await recordLifecycleDelivery( - { - endpointId: event.endpointId, - threadId: event.event.threadId, - messageId: event.event.messageId, - eventKind: "message_deleted", - text: "An external message in this conversation was deleted.", - providerSentAt: event.event.deletedAt?.toISOString() ?? null, - raw: event.event.raw, - revision: event.event.deletedAt?.toISOString() ?? null, - }, - runtimeContext, - ); - } - - async function denyExternalAction( - endpoint: EndpointRow, - event: ChatSdkCallbackEvent, - runtimeContext: LifecycleRuntimeFence, - safelyKnown: { - conversationId?: string | null; - principalId?: string | null; - } = {}, - ): Promise { - const telegramCallback = - endpoint.provider === "telegram" && endpoint.botExternalId - ? readTelegramCallbackProvenance(event.telegramCallback, { - companyId: endpoint.companyId, - endpointId: endpoint.id, - botUserId: endpoint.botExternalId, - threadId: event.event.threadId, - messageId: event.event.messageId, - userId: event.event.user.userId, - }) - : null; - const fingerprint = createHash("sha256") - .update( - JSON.stringify( - telegramCallback - ? [ - endpoint.id, - "telegram_callback_denial", - telegramCallback.botUserId, - telegramCallback.callbackId, - ] - : [ - endpoint.id, - event.provider, - event.event.threadId, - event.event.messageId, - event.event.user.userId, - event.event.actionId, - event.event.triggerId ?? null, - ], - ), - ) - .digest("hex"); - const providerEventId = `action-denied:${fingerprint}`; - const processedAt = new Date(); - const actionThread = event.event.thread; - const effectPayload = - actionThread && - typeof actionThread.postEphemeral === "function" && - CAPABILITIES[endpoint.provider].ephemeralMessages - ? ({ - version: 1, - effect: "ephemeral_message", - authorizationMode: "safe_notice", - threadId: event.event.threadId, - userId: event.event.user.userId, - text: "This action is no longer available. Open the linked Paperclip task or ask an operator to link this account.", - fallbackText: "This Paperclip action is no longer available.", - settleDelivery: false, - } as const) - : null; - let effect: typeof chatActions.$inferSelect | null = null; - try { - effect = await db.transaction(async (tx) => { - const currentEndpoint = telegramCallback - ? await runtimeCallbackEndpoint(tx, endpoint.id, runtimeContext, [ - "active", - ]) - : null; - const telegramAuthorizationSha256 = - currentEndpoint && telegramCallback - ? await lockTelegramCallbackNoticeAuthority( - tx, - currentEndpoint, - telegramCallback, - safelyKnown.principalId ?? null, - ) - : null; - await notifyChatDeliveryWork(tx); - const [inserted] = await tx - .insert(chatDeliveries) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: safelyKnown.conversationId ?? null, - principalId: safelyKnown.principalId ?? null, - providerEventId, - deduplicationKey: fingerprint, - eventKind: "action", - normalizedEvent: { - providerEventId, - kind: "action", - authorization: { outcome: "denied" }, - ...(telegramCallback - ? { telegramCallback, telegramAuthorizationSha256 } - : {}), - }, - state: "filtered", - attempts: 1, - redactedError: "External action denied by Paperclip authorization", - processedAt, - updatedAt: processedAt, - }) - .onConflictDoNothing() - .returning(); - const notice: Omit< - ProviderEffectPayload, - "runtimeGeneration" | "credentialFingerprint" - > | null = - telegramCallback && telegramAuthorizationSha256 - ? { - version: 1, - effect: "telegram_callback_notice", - authorizationMode: "safe_notice", - telegramCallback, - telegramAuthorizationSha256, - threadId: telegramCallbackThreadId(telegramCallback), - userId: telegramCallback.receiverUserId, - text: TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, - settleDelivery: false, - } - : effectPayload; - if (!inserted || !notice) return null; - return stageProviderEffect(tx, { - endpoint, - deliveryId: inserted.id, - conversationId: safelyKnown.conversationId ?? null, - principalId: safelyKnown.principalId ?? null, - providerActionId: `provider_effect:${providerEventId}`, - payload: notice, - runtimeContext, - }); - }); - } catch (error) { - logger.warn( - { - endpointId: endpoint.id, - provider: endpoint.provider, - error: redactError(error), - }, - "could not record denied external chat action", - ); - // Provider retries are useful only while the authoritative denial could - // not be recorded. Once the filtered delivery exists, a policy denial - // is a successfully handled webhook and must not be surfaced as a - // callback failure (which the Chat SDK correctly converts to a 503). - throw error; - } - if (effect && actionThread) { - // The callback is acknowledged after the authoritative denial and its - // provider effect are durable. Slow provider I/O runs out of band; a - // redelivery finds the same action and cannot enqueue another notice. - scheduleProviderEffect(effect.id, actionThread); - } - } - - async function recordModalOpenFailure( - endpoint: EndpointRow, - event: ChatSdkCallbackEvent, - runtimeContext: LifecycleRuntimeFence, - safelyKnown: { - attemptActionId?: string | null; - conversationId?: string | null; - principalId?: string | null; - resource?: ResourceRow | null; - }, - error: unknown, - ): Promise { - const disposition = classifyChatPublicationError(error, 1); - const fingerprint = createHash("sha256") - .update( - JSON.stringify([ - endpoint.id, - event.provider, - "modal_open", - event.event.triggerId ?? null, - event.event.threadId, - event.event.messageId, - event.event.user.userId, - event.event.actionId, - ]), - ) - .digest("hex"); - const providerEventId = `modal-open-failed:${fingerprint}`; - const failure = redactError(error); - const processedAt = new Date(); - const actionThread = event.event.thread; - const effectPayload = - actionThread && - typeof actionThread.postEphemeral === "function" && - CAPABILITIES[endpoint.provider].ephemeralMessages - ? ({ - version: 1, - effect: "ephemeral_message", - authorizationMode: "safe_notice", - threadId: event.event.threadId, - userId: event.event.user.userId, - text: "Paperclip could not open this form. Try the action again or open the linked Paperclip task.", - fallbackText: "Paperclip could not open this form.", - settleDelivery: false, - } as const) - : null; - let effect: typeof chatActions.$inferSelect | null = null; - let invalidateEndpointRuntime = false; - try { - effect = await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - const [inserted] = await tx - .insert(chatDeliveries) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: safelyKnown.conversationId ?? null, - principalId: safelyKnown.principalId ?? null, - providerEventId, - deduplicationKey: fingerprint, - eventKind: "action", - normalizedEvent: { - providerEventId, - kind: "action", - modal: { - phase: "open", - outcome: "failed", - disposition: disposition.kind, - }, - }, - // Modal callbacks are acknowledged after this audit row commits. - // They must never enter the replayable inbound-delivery queue. - state: "filtered", - attempts: 1, - redactedError: `Provider modal open failed: ${failure}`.slice( - 0, - MAX_ERROR_TEXT, - ), - processedAt, - updatedAt: processedAt, - }) - .onConflictDoNothing() - .returning(); - if (!inserted) return null; - - if (safelyKnown.attemptActionId) { - await tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: `question_form_open_${disposition.kind}`, - retryable: false, - }, - updatedAt: processedAt, - }) - .where( - and( - eq(chatActions.id, safelyKnown.attemptActionId), - eq(chatActions.status, "processing"), - ), - ); - } - - if (disposition.kind === "endpoint_attention") { - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - endpoint.id, - runtimeContext, - ["active"], - ); - if (currentEndpoint) { - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: - "Provider credentials or permissions need attention", - lastError: failure, - updatedAt: processedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: - "Provider credentials or permissions need attention", - lastError: failure, - healthCheckedAt: processedAt, - updatedAt: processedAt, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - invalidateEndpointRuntime = true; - } - } else if ( - disposition.kind === "resource_unavailable" && - safelyKnown.resource - ) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: processedAt }) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, safelyKnown.resource.id), - ), - ); - } - - if (!effectPayload) return null; - return stageProviderEffect(tx, { - endpoint, - deliveryId: inserted.id, - conversationId: safelyKnown.conversationId ?? null, - principalId: safelyKnown.principalId ?? null, - providerActionId: `provider_effect:${providerEventId}`, - payload: effectPayload, - runtimeContext, - }); - }); - } catch (auditError) { - logger.warn( - { - endpointId: endpoint.id, - provider: endpoint.provider, - error: redactError(auditError), - }, - "could not record external chat modal-open failure", - ); - throw auditError; - } - if (effect && actionThread) scheduleProviderEffect(effect.id, actionThread); - if (invalidateEndpointRuntime) { - await invalidateRuntime(endpoint.id).catch(() => undefined); - } - } - - async function recordModalSubmitDenial( - endpoint: EndpointRow, - event: ChatSdkCallbackEvent, - input: { - code: string; - conversationId?: string | null; - principalId?: string | null; - }, - database: DbOrTransaction = db, - ): Promise { - const fingerprint = createHash("sha256") - .update( - JSON.stringify([ - endpoint.id, - event.provider, - "modal_submit", - event.event.callbackId, - event.event.viewId, - event.event.user.userId, - input.code, - ]), - ) - .digest("hex"); - const providerEventId = `modal-submit-denied:${fingerprint}`; - const processedAt = new Date(); - try { - await database - .insert(chatDeliveries) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: input.conversationId ?? null, - principalId: input.principalId ?? null, - providerEventId, - deduplicationKey: fingerprint, - eventKind: "action", - normalizedEvent: { - providerEventId, - kind: "action", - authorization: { outcome: "denied" }, - modal: { - phase: "submit", - outcome: "denied", - code: input.code, - }, - }, - state: "filtered", - attempts: 1, - redactedError: "External chat modal submission denied by Paperclip", - processedAt, - updatedAt: processedAt, - }) - .onConflictDoNothing(); - } catch (error) { - logger.warn( - { - endpointId: endpoint.id, - provider: endpoint.provider, - error: redactError(error), - }, - "could not record denied external chat modal submission", - ); - throw error; - } - } - - async function settleTerminalConfirmationAction( - actionId: string, - ): Promise { - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "confirmation_response"), - ), - ) - .then((rows) => rows[0] ?? null); - if (action?.status === "processed") return true; - if (!action || action.status !== "processing" || !action.conversationId) - return false; - const token = action.payload; - if ( - token.version !== 1 || - typeof token.publicationId !== "string" || - typeof token.interactionId !== "string" || - (token.decision !== "accept" && token.decision !== "reject") - ) { - await db - .update(chatActions) - .set({ - status: "failed", - result: { code: "confirmation_action_payload_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ); - return false; - } - const [conversation, publication, endpoint] = await Promise.all([ - db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, action.companyId), - eq(chatConversations.endpointId, action.endpointId), - eq(chatConversations.id, action.conversationId), - ), - ) - .then((rows) => rows[0] ?? null), - db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, action.companyId), - eq(chatPublications.endpointId, action.endpointId), - eq(chatPublications.id, token.publicationId as string), - ), - ) - .then((rows) => rows[0] ?? null), - db - .select() - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.companyId, action.companyId), - eq(chatEndpoints.id, action.endpointId), - ), - ) - .then((rows) => rows[0] ?? null), - ]); - if ( - !conversation || - !publication || - !endpoint || - endpoint.provider !== "telegram" || - publication.conversationId !== conversation.id || - publication.payload.interactionId !== token.interactionId - ) { - await db - .update(chatActions) - .set({ - status: "failed", - result: { code: "confirmation_action_binding_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ); - return false; - } - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === token.interactionId); - if ( - !interaction || - interaction.kind !== "request_confirmation" || - !nativeTelegramConfirmation(interaction) - ) { - await db - .update(chatActions) - .set({ - status: "failed", - result: { code: "confirmation_interaction_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ); - return false; - } - if ( - interaction.status !== "accepted" && - interaction.status !== "rejected" - ) { - if (interaction.status === "pending") return false; - const [retired] = await db - .update(chatActions) - .set({ - status: "expired", - result: { - code: "confirmation_interaction_resolved_elsewhere", - interactionId: interaction.id, - interactionStatus: interaction.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ) - .returning({ id: chatActions.id }); - if (retired) return true; - const winner = await db - .select({ status: chatActions.status }) - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - return winner?.status === "expired" || winner?.status === "processed"; - } - const decisionMatches = - (token.decision === "accept" && interaction.status === "accepted") || - (token.decision === "reject" && interaction.status === "rejected"); - if (!decisionMatches) { - await db - .update(chatActions) - .set({ - status: "expired", - result: { code: "interaction_resolved_by_sibling_action" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ); - return false; - } - const issue = await db - .select({ - id: issues.id, - companyId: issues.companyId, - assigneeAgentId: issues.assigneeAgentId, - status: issues.status, - }) - .from(issues) - .where( - and( - eq(issues.companyId, action.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!issue || !interaction.resolvedByUserId) { - await db - .update(chatActions) - .set({ - status: "failed", - result: { code: "confirmation_resolution_context_missing" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ); - return false; - } - const resolvedByUserId = interaction.resolvedByUserId; - const shouldWake = - Boolean(issue.assigneeAgentId) && - issue.status !== "done" && - issue.status !== "cancelled" && - (interaction.continuationPolicy === "wake_assignee" || - (interaction.continuationPolicy === "wake_assignee_on_accept" && - interaction.status === "accepted")); - const resolutionLabel = - interaction.status === "accepted" ? "Accepted" : "Rejected"; - const resolvedByThisProviderAction = - action.result?.code === "confirmation_resolution_committed_by_provider"; - return db.transaction(async (tx) => { - const [completed] = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - code: resolvedByThisProviderAction - ? "confirmation_resolution_committed_by_provider" - : "confirmation_resolution_reconciled", - interactionId: interaction.id, - interactionStatus: interaction.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - ), - ) - .returning({ id: chatActions.id }); - if (!completed) { - // A publication sweep may reconcile the durable action while the - // original provider callback is still in flight. Treat that CAS loss - // as success once the winner has processed the same action so the - // provider does not receive a spurious 5xx and redeliver it. - const winner = await tx - .select({ status: chatActions.status }) - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - return winner?.status === "processed"; - } - await tx - .update(chatActions) - .set({ - status: "expired", - result: { code: "interaction_resolved_by_sibling_action" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.companyId, issue.companyId), - eq(chatActions.endpointId, action.endpointId), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.kind, "confirmation_response"), - inArray(chatActions.status, ["issued", "processing"]), - ne(chatActions.id, action.id), - eq( - sql`${chatActions.payload}->>'interactionId'`, - interaction.id, - ), - ), - ); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: issue.companyId, - endpointId: action.endpointId, - conversationId: conversation.id, - issueId: issue.id, - idempotencyKey: `interaction-resolution:${interaction.id}:${action.endpointId}`, - payload: projectSafeChatPublication({ - classification: "external", - source: "issue_interaction", - text: `${resolutionLabel}: ${interaction.payload.prompt}`, - interaction: { - id: interaction.id, - card: { - kind: "confirmation", - title: interaction.payload.prompt, - body: resolutionLabel, - actions: [], - }, - }, - }), - state: "pending", - }) - .onConflictDoNothing(); - if (shouldWake && issue.assigneeAgentId) { - await tx - .insert(chatActions) - .values({ - companyId: issue.companyId, - endpointId: action.endpointId, - conversationId: conversation.id, - principalId: action.principalId, - kind: "interaction_wakeup", - providerActionId: `interaction_wakeup:${interaction.id}`, - payload: { - version: 1, - interactionId: interaction.id, - interactionKind: interaction.kind, - interactionStatus: interaction.status, - issueId: issue.id, - agentId: issue.assigneeAgentId, - sourceCommentId: interaction.sourceCommentId ?? null, - sourceRunId: interaction.sourceRunId ?? null, - requestedByUserId: resolvedByUserId, - requestedByActorType: "user", - requestedByActorId: resolvedByUserId, - }, - status: "issued", - }) - .onConflictDoNothing(); - } - // A provider callback can lose the pending -> terminal interaction race - // to the Paperclip UI. In that case this transaction only retires the - // now-redundant provider action and must not fabricate a second external - // resolution event attributed to the board winner. - if (resolvedByThisProviderAction) { - await logActivity(tx as unknown as Db, { - companyId: issue.companyId, - actorType: "user", - actorId: resolvedByUserId, - action: - interaction.status === "accepted" - ? "issue.thread_interaction_accepted" - : "issue.thread_interaction_rejected", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - endpointId: action.endpointId, - provider: endpoint.provider, - conversationId: conversation.id, - publicationId: publication.id, - providerMessageId: publication.providerMessageId, - interactionId: interaction.id, - interactionKind: interaction.kind, - interactionStatus: interaction.status, - resolutionActorKind: "user", - requestedResolverPolicy: interaction.requestedResolverPolicy, - effectiveResolverPolicy: interaction.effectiveResolverPolicy, - }, - }); - } - return true; - }); - } - - async function reconcileTerminalConfirmationActions( - limit = 100, - ): Promise { - const candidates = await db - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.kind, "confirmation_response"), - eq(chatActions.status, "processing"), - ), - ) - .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) - .limit(limit); - let settled = 0; - for (const candidate of candidates) { - if (await settleTerminalConfirmationAction(candidate.id)) settled += 1; - } - return settled; - } - - async function processPendingInteractionWakeups( - limit = 100, - ): Promise { - const staleBefore = new Date(Date.now() - 30_000); - const retryBefore = new Date( - Date.now() - CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, - ); - const candidates = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.kind, "interaction_wakeup"), - or( - and( - eq(chatActions.status, "issued"), - or( - isNull(chatActions.result), - lte(chatActions.updatedAt, retryBefore), - ), - ), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, staleBefore), - ), - ), - ), - ) - .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) - .limit(limit); - let processed = 0; - for (const candidate of candidates) { - const [claimed] = await db - .update(chatActions) - .set({ status: "processing", updatedAt: new Date() }) - .where( - and( - eq(chatActions.id, candidate.id), - or( - and( - eq(chatActions.status, "issued"), - or( - isNull(chatActions.result), - lte(chatActions.updatedAt, retryBefore), - ), - ), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, staleBefore), - ), - ), - ), - ) - .returning(); - if (!claimed) continue; - const payload = claimed.payload; - const requestedByActorType = - payload.requestedByActorType === "user" || - payload.requestedByActorType === "agent" || - payload.requestedByActorType === "system" - ? payload.requestedByActorType - : typeof payload.requestedByUserId === "string" - ? "user" - : null; - const requestedByActorId = - typeof payload.requestedByActorId === "string" - ? payload.requestedByActorId - : typeof payload.requestedByUserId === "string" - ? payload.requestedByUserId - : null; - if ( - payload.version !== 1 || - typeof payload.interactionId !== "string" || - typeof payload.interactionKind !== "string" || - !["accepted", "rejected", "answered", "cancelled", "failed"].includes( - String(payload.interactionStatus), - ) || - typeof payload.issueId !== "string" || - typeof payload.agentId !== "string" || - !requestedByActorType || - !requestedByActorId - ) { - await db - .update(chatActions) - .set({ - status: "failed", - result: { code: "interaction_wakeup_payload_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, claimed.id), - eq(chatActions.status, "processing"), - ), - ); - continue; - } - const currentIssue = await db - .select({ - assigneeAgentId: issues.assigneeAgentId, - status: issues.status, - }) - .from(issues) - .where( - and( - eq(issues.companyId, claimed.companyId), - eq(issues.id, payload.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - const currentInteraction = currentIssue - ? ( - await issueThreadInteractionService(db).listForIssue( - payload.issueId, - ) - ).find((interaction) => interaction.id === payload.interactionId) - : null; - if ( - !currentIssue || - !currentInteraction || - currentInteraction.companyId !== claimed.companyId || - currentInteraction.kind !== payload.interactionKind || - currentInteraction.status !== payload.interactionStatus || - currentIssue.assigneeAgentId !== payload.agentId || - currentIssue.status === "done" || - currentIssue.status === "cancelled" - ) { - await db - .update(chatActions) - .set({ - status: "processed", - result: { code: "interaction_wakeup_no_longer_applicable" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, claimed.id), - eq(chatActions.status, "processing"), - ), - ); - processed += 1; - continue; - } - const idempotencyKey = `interaction:${payload.interactionId}:${payload.interactionStatus}`; - try { - const sourceCommentId = currentInteraction.sourceCommentId ?? null; - const sourceRunId = currentInteraction.sourceRunId ?? null; - // A resolved interaction is still part of the originating external - // turn. Preserve one verified inbound comment edge on the continuation - // run so queued/working/failure milestones return to this exact - // endpoint and conversation instead of disappearing as an internal - // automation run. - const chatOrigin = claimed.conversationId - ? await db - .select({ - commentId: chatMessageLinks.commentId, - provider: chatEndpoints.provider, - }) - .from(chatEndpoints) - .innerJoin( - chatMessageLinks, - and( - eq(chatMessageLinks.companyId, chatEndpoints.companyId), - eq(chatMessageLinks.endpointId, chatEndpoints.id), - ), - ) - .where( - and( - eq(chatEndpoints.companyId, claimed.companyId), - eq(chatEndpoints.id, claimed.endpointId), - eq(chatMessageLinks.conversationId, claimed.conversationId), - eq(chatMessageLinks.direction, "inbound"), - isNotNull(chatMessageLinks.commentId), - ), - ) - .orderBy( - desc(chatMessageLinks.createdAt), - desc(chatMessageLinks.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null) - : null; - const wakeCommentId = sourceCommentId ?? chatOrigin?.commentId ?? null; - const existing = await db - .select({ - agentId: agentWakeupRequests.agentId, - id: agentWakeupRequests.id, - }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, claimed.companyId), - eq(agentWakeupRequests.idempotencyKey, idempotencyKey), - notInArray(agentWakeupRequests.status, [ - "skipped", - "failed", - "cancelled", - ]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - const queuedRun = !existing - ? await options.heartbeat.wakeup(payload.agentId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { - issueId: payload.issueId, - interactionId: payload.interactionId, - interactionKind: payload.interactionKind, - interactionStatus: payload.interactionStatus, - sourceCommentId, - sourceRunId, - ...(wakeCommentId - ? { - wakeCommentId, - wakeCommentIds: [wakeCommentId], - } - : {}), - ...(payload.planReviewInteraction && - typeof payload.planReviewInteraction === "object" - ? { planReviewInteraction: payload.planReviewInteraction } - : {}), - mutation: "interaction", - }, - idempotencyKey, - allowRunCoalescing: false, - requestedByActorType, - requestedByActorId, - contextSnapshot: { - issueId: payload.issueId, - taskId: payload.issueId, - interactionId: payload.interactionId, - interactionKind: payload.interactionKind, - interactionStatus: payload.interactionStatus, - sourceCommentId, - sourceRunId, - ...(payload.planReviewInteraction && - typeof payload.planReviewInteraction === "object" - ? { planReviewInteraction: payload.planReviewInteraction } - : {}), - wakeReason: "issue_commented", - source: chatOrigin - ? `chat:${chatOrigin.provider}` - : "external_chat.interaction.resolve", - ...(wakeCommentId - ? { - wakeCommentId, - wakeCommentIds: [wakeCommentId], - } - : {}), - ...(payload.forceFreshSession === true - ? { forceFreshSession: true } - : {}), - ...(typeof payload.workspaceRefreshReason === "string" - ? { - workspaceRefreshReason: payload.workspaceRefreshReason, - } - : {}), - }, - }) - : null; - const durable = - existing ?? - (await db - .select({ - agentId: agentWakeupRequests.agentId, - id: agentWakeupRequests.id, - }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, claimed.companyId), - eq(agentWakeupRequests.idempotencyKey, idempotencyKey), - notInArray(agentWakeupRequests.status, [ - "skipped", - "failed", - "cancelled", - ]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null)); - if (!queuedRun && !durable) { - await db - .update(chatActions) - .set({ - status: "issued", - result: { code: "interaction_wakeup_deferred" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, claimed.id), - eq(chatActions.status, "processing"), - ), - ); - continue; - } - await db - .update(chatActions) - .set({ - status: "processed", - result: { - code: durable - ? durable.agentId === payload.agentId - ? "interaction_wakeup_already_durable" - : "interaction_wakeup_coalesced_after_reassignment" - : "interaction_wakeup_queued", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, claimed.id), - eq(chatActions.status, "processing"), - ), - ); - processed += 1; - } catch (error) { - const durable = isUniqueViolation(error) - ? await db - .select({ - agentId: agentWakeupRequests.agentId, - id: agentWakeupRequests.id, - }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, claimed.companyId), - eq(agentWakeupRequests.idempotencyKey, idempotencyKey), - notInArray(agentWakeupRequests.status, [ - "skipped", - "failed", - "cancelled", - ]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null) - : null; - const previousResult = - claimed.result && typeof claimed.result === "object" - ? (claimed.result as Record) - : {}; - const previousAttempts = - typeof previousResult.attemptCount === "number" && - Number.isInteger(previousResult.attemptCount) && - previousResult.attemptCount >= 0 - ? previousResult.attemptCount - : 0; - const attemptCount = previousAttempts + 1; - await db - .update(chatActions) - .set({ - status: durable ? "processed" : "issued", - result: { - code: durable - ? durable.agentId === payload.agentId - ? "interaction_wakeup_already_durable" - : "interaction_wakeup_coalesced_after_reassignment" - : "interaction_wakeup_failed", - ...(!durable ? { attemptCount } : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, claimed.id), - eq(chatActions.status, "processing"), - ), - ); - if (!durable) { - logger.warn( - { - endpointId: claimed.endpointId, - interactionId: payload.interactionId, - attemptCount, - error: redactError(error), - }, - "failed to queue external interaction continuation wake", - ); - } - } - } - return processed; - } - - async function handleAction( - event: ChatSdkCallbackEvent, - runtimeContext: RuntimeContext, - ) { - const record = await runtimeCallbackRecord( - event.endpointId, - runtimeContext, - ["active"], - ); - if (!record) { - throw forbidden("This chat action is not a current Paperclip question"); - } - const deny = async (safelyKnown?: { - conversationId?: string | null; - principalId?: string | null; - }) => { - await denyExternalAction( - record.endpoint, - event, - runtimeContext, - safelyKnown, - ); - if ( - event.provider === "discord" && - event.transport === "discord_gateway" - ) { - // Discord renders deferUpdate as a successful click. After the denial - // audit is durable, surface a transport-only sentinel so the Gateway - // adapter can withhold that misleading acknowledgement. The runtime, - // not raw JSON or discord.js methods, supplies the ingress context. - throw Object.assign( - new Error("Discord Gateway action was not admitted by Paperclip"), - { code: "chat_discord_gateway_action_rejected" }, - ); - } - }; - if ( - record.endpoint.status !== "active" || - record.endpoint.provider !== event.provider || - record.endpoint.capabilities.actions !== true - ) { - return deny(); - } - const principal = await ensurePrincipal( - record.endpoint, - event.event.user, - event.event.raw, - ); - // Executable external actions are deliberately stricter than ordinary - // sponsored-guest messages. They require a current endpoint-scoped link to - // an active non-viewer Paperclip member, and never run as a guest sponsor. - if ( - principal.linkedDenied || - !principal.userId || - principal.principal.kind !== "user" || - principal.principal.isBot - ) { - return deny({ principalId: principal.principal.id }); - } - const isCorrection = isDiscordQuestionFormCorrectionId( - event.event.actionId, - ); - const correction = - isCorrection && - event.provider === "discord" && - event.transport === "discord_gateway" - ? await loadDiscordQuestionFormCorrection( - persistence, - { - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - }, - event.event.actionId, - { - principalId: principal.principal.id, - userId: principal.userId, - externalUserId: event.event.user.userId, - }, - event.event.threadId, - ) - : null; - if ( - isCorrection && - (!correction || - discordQuestionFormThreadId(event.event.raw) !== event.event.threadId) - ) - return deny({ principalId: principal.principal.id }); - // The private correction handle selects the original publication, never - // the new ephemeral error message. It does not bypass any current source, - // destination, actor, open-token, or pending-interaction check below. - const sourceActionId = correction?.openActionId ?? event.event.actionId; - const sourceMessageId = - correction?.providerMessageId ?? event.event.messageId; - const actionBinding = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, record.endpoint.id), - inArray(chatActions.kind, [ - "question_answer", - "question_form_open", - "confirmation_response", - ]), - eq(chatActions.providerActionId, sourceActionId), - ), - ) - .then((rows) => (rows.length === 1 ? rows[0]! : null)); - const originalPublicationId = actionBinding?.payload.publicationId; - const actionInteractionId = actionBinding?.payload.interactionId; - if ( - correction && - (actionBinding?.kind !== "question_form_open" || - actionBinding.conversationId !== correction.conversationId || - originalPublicationId !== correction.publicationId || - actionInteractionId !== correction.interactionId || - actionBinding.payload.formActionId !== correction.submitActionId) - ) - return deny({ principalId: principal.principal.id }); - if ( - !actionBinding?.conversationId || - typeof originalPublicationId !== "string" || - typeof actionInteractionId !== "string" - ) { - return deny({ principalId: principal.principal.id }); - } - // The opaque provider action is the immutable link to the original - // publication. Resolve the conversation from that token, then verify the - // provider's thread independently; never trust a callback message id to - // select a Paperclip task. - const conversation = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, record.endpoint.companyId), - eq(chatConversations.endpointId, record.endpoint.id), - eq(chatConversations.id, actionBinding.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - !conversation || - !actionThreadMatchesConversation( - event.provider, - event.event.threadId, - conversation.externalThreadId, - ) - ) { - return deny({ principalId: principal.principal.id }); - } - const safelyKnown = { - conversationId: conversation.id, - principalId: principal.principal.id, - }; - const resource = conversation.resourceId - ? await db - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, record.endpoint.companyId), - eq(chatEndpointResources.endpointId, record.endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .then((rows) => rows[0] ?? null) - : null; - const resourceAllowed = conversation.isDirectMessage - ? record.endpoint.allowDirectMessages - : nonDirectDestinationAllowed(record.endpoint, resource); - if (!resourceAllowed) return deny(safelyKnown); - - let originalPublication = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, record.endpoint.companyId), - eq(chatPublications.endpointId, record.endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.id, originalPublicationId), - inArray(chatPublications.state, ["published", "delivery_unknown"]), - or( - isNull(chatPublications.providerMessageId), - eq(chatPublications.providerMessageId, sourceMessageId), - ), - ), - ) - .then((rows) => rows[0] ?? null); - if (!originalPublication) return deny(safelyKnown); - if ( - correction && - (originalPublication.state !== "published" || - originalPublication.providerMessageId !== correction.providerMessageId) - ) - return deny(safelyKnown); - if (!originalPublication.providerMessageId) { - const reconciledPublication = await db.transaction(async (tx) => { - const current = await tx - .select() - .from(chatPublications) - .where(eq(chatPublications.id, originalPublicationId)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !current || - current.companyId !== record.endpoint.companyId || - current.endpointId !== record.endpoint.id || - current.conversationId !== conversation.id || - current.issueId !== conversation.issueId || - !["published", "delivery_unknown"].includes(current.state) || - (current.providerMessageId !== null && - current.providerMessageId !== event.event.messageId) - ) { - return null; - } - const occupied = await tx - .select({ publicationId: chatMessageLinks.publicationId }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.endpointId, record.endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.providerMessageId, event.event.messageId), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .then((rows) => rows[0] ?? null); - if (occupied && occupied.publicationId !== current.id) return null; - const reconciledAt = new Date(); - await notifyChatPublicationWork(tx); - const [reconciled] = await tx - .update(chatPublications) - .set({ - state: "published", - providerMessageId: event.event.messageId, - publishedAt: current.publishedAt ?? reconciledAt, - nextAttemptAt: null, - redactedError: null, - updatedAt: reconciledAt, - }) - .where( - and( - eq(chatPublications.id, current.id), - inArray(chatPublications.state, [ - "published", - "delivery_unknown", - ]), - isNull(chatPublications.providerMessageId), - ), - ) - .returning(); - if (!reconciled) return null; - await tx - .insert(chatMessageLinks) - .values({ - companyId: current.companyId, - endpointId: current.endpointId, - conversationId: current.conversationId, - publicationId: current.id, - commentId: current.commentId, - providerMessageId: event.event.messageId, - direction: "outbound", - }) - .onConflictDoNothing(); - const linked = await tx - .select({ publicationId: chatMessageLinks.publicationId }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.endpointId, record.endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.providerMessageId, event.event.messageId), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .then((rows) => rows[0] ?? null); - return linked?.publicationId === reconciled.id ? reconciled : null; - }); - if (!reconciledPublication) return deny(safelyKnown); - originalPublication = reconciledPublication; - } - const messageBinding = await db - .select({ - link: chatMessageLinks, - linkedPublication: chatPublications, - }) - .from(chatMessageLinks) - .innerJoin( - chatPublications, - and( - eq(chatPublications.companyId, chatMessageLinks.companyId), - eq(chatPublications.endpointId, chatMessageLinks.endpointId), - eq(chatPublications.conversationId, chatMessageLinks.conversationId), - eq(chatPublications.id, chatMessageLinks.publicationId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, record.endpoint.companyId), - eq(chatMessageLinks.endpointId, record.endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.providerMessageId, sourceMessageId), - eq(chatMessageLinks.direction, "outbound"), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.state, "published"), - eq(chatPublications.providerMessageId, sourceMessageId), - ), - ) - .then((rows) => (rows.length === 1 ? rows[0]! : null)); - if (!messageBinding) return deny(safelyKnown); - const linkedPayload = messageBinding.linkedPublication - .payload as SafeChatPublicationPayload; - const linkStillAuthoritative = - messageBinding.link.publicationId === originalPublicationId || - (messageBinding.linkedPublication.idempotencyKey === - `interaction-resolution:${actionInteractionId}:${record.endpoint.id}` && - linkedPayload.interactionId === actionInteractionId); - if (!originalPublication || !linkStillAuthoritative) { - return deny(safelyKnown); - } - const issued = { - publication: originalPublication, - link: messageBinding.link, - conversation, - }; - - const payload = issued.publication.payload as SafeChatPublicationPayload; - if (event.provider === "telegram") { - const raw = event.event.raw; - const callbackData = - raw && typeof raw === "object" && "data" in raw - ? (raw as { data?: unknown }).data - : null; - if ( - typeof callbackData !== "string" || - Buffer.byteLength(callbackData, "utf8") > - TELEGRAM_CALLBACK_DATA_LIMIT_BYTES || - callbackData !== telegramChatSdkCallbackData(event.event.actionId) || - event.event.value !== undefined - ) { - return deny(safelyKnown); - } - } - if ( - !payload.interactionId || - (!correction && - event.provider !== "telegram" && - event.event.value !== payload.interactionId) || - !payload.card?.actions?.some( - (action) => - action.type === "callback" && action.actionId === sourceActionId, - ) - ) { - return deny(safelyKnown); - } - - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === payload.interactionId); - if ( - !interaction || - interaction.companyId !== record.endpoint.companyId || - interaction.issueId !== conversation.issueId - ) { - return deny(safelyKnown); - } - if (interaction.status !== "pending") { - if (correction) return deny(safelyKnown); - const completedAction = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.providerActionId, event.event.actionId), - eq(chatActions.principalId, principal.principal.id), - eq(chatActions.status, "processed"), - ), - ) - .then((rows) => rows[0] ?? null); - const completedPayload = completedAction?.payload; - const completedResult = completedAction?.result; - const completedResultMatches = - completedResult?.interactionId === interaction.id && - completedResult?.interactionStatus === interaction.status && - (interaction.kind !== "request_confirmation" || - completedResult.code === - "confirmation_resolution_committed_by_provider"); - if ( - completedAction && - completedResultMatches && - completedPayload?.publicationId === issued.publication.id && - completedPayload?.interactionId === interaction.id && - completedPayload?.messageId === event.event.messageId && - completedPayload?.actionId === event.event.actionId - ) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - return; - } - return deny(safelyKnown); - } - if (interaction.kind === "request_confirmation") { - if ( - event.provider !== "telegram" || - !nativeTelegramConfirmation(interaction) - ) { - return deny(safelyKnown); - } - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.kind, "confirmation_response"), - eq(chatActions.providerActionId, event.event.actionId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!action || action.status !== "issued") return deny(safelyKnown); - const tokenPayload = action.payload; - const tokenExpiresAt = - typeof tokenPayload.expiresAt === "string" - ? Date.parse(tokenPayload.expiresAt) - : Number.NaN; - if (!Number.isFinite(tokenExpiresAt) || tokenExpiresAt <= Date.now()) { - await db - .update(chatActions) - .set({ - status: "expired", - result: { code: "confirmation_action_token_expired" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ); - return deny(safelyKnown); - } - if ( - tokenPayload.version !== 1 || - tokenPayload.publicationId !== issued.publication.id || - tokenPayload.interactionId !== interaction.id || - (tokenPayload.decision !== "accept" && - tokenPayload.decision !== "reject") - ) { - return deny(safelyKnown); - } - - const issue = await db - .select({ - id: issues.id, - companyId: issues.companyId, - projectId: issues.projectId, - goalId: issues.goalId, - status: issues.status, - assigneeAgentId: issues.assigneeAgentId, - }) - .from(issues) - .where( - and( - eq(issues.companyId, record.endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!issue) { - return deny(safelyKnown); - } - - const decision = tokenPayload.decision; - try { - if (decision === "accept") { - await issueThreadInteractionService(db).acceptInteraction( - issue, - interaction.id, - {}, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - }, - afterResolveInTransaction: async (tx, resolved) => { - const [owned] = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processing", - payload: { - ...tokenPayload, - messageId: event.event.messageId, - actionId: event.event.actionId, - value: null, - }, - result: { - code: "confirmation_resolution_committed_by_provider", - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!owned) { - throw new Error( - "External confirmation action ownership changed before interaction commit", - ); - } - }, - }, - ); - } else { - await issueThreadInteractionService(db).rejectInteraction( - issue, - interaction.id, - {}, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - }, - afterResolveInTransaction: async (tx, resolved) => { - const [owned] = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processing", - payload: { - ...tokenPayload, - messageId: event.event.messageId, - actionId: event.event.actionId, - value: null, - }, - result: { - code: "confirmation_resolution_committed_by_provider", - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!owned) { - throw new Error( - "External confirmation action ownership changed before interaction commit", - ); - } - }, - }, - ); - } - } catch (error) { - if (isExternalActionAuthorizationChange(error)) { - return deny(safelyKnown); - } - const currentStatus = await db - .select({ status: issueThreadInteractions.status }) - .from(issueThreadInteractions) - .where(eq(issueThreadInteractions.id, interaction.id)) - .then((rows) => rows[0]?.status ?? null); - if (currentStatus !== null && currentStatus !== "pending") { - await settleTerminalConfirmationAction(action.id); - const completedAction = await db - .select({ status: chatActions.status, result: chatActions.result }) - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - if ( - completedAction?.status === "processed" && - completedAction.result?.code === - "confirmation_resolution_committed_by_provider" - ) { - return; - } - return deny(safelyKnown); - } - throw error; - } - - await options.confirmationResolutionPersistBarrier?.(); - if (!(await settleTerminalConfirmationAction(action.id))) { - throw new Error( - "External confirmation action ownership changed before settlement", - ); - } - - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - return; - } - if (interaction.kind !== "ask_user_questions") { - return deny(safelyKnown); - } - if (isChatQuestionFormOpenActionId(sourceActionId)) { - if ( - event.provider !== "slack" && - event.provider !== "microsoft-teams" && - !( - event.provider === "discord" && - event.transport === "discord_gateway" && - record.endpoint.capabilities.modals === true - ) - ) { - return deny(safelyKnown); - } - await options.questionFormOpenAuthorizationBarrier?.(); - let providerAttempted = false; - let attemptActionId: string | null = null; - try { - const attemptProviderActionId = event.event.triggerId - ? `question_form_open_attempt:${createHash("sha256") - .update( - JSON.stringify([ - record.endpoint.id, - event.provider, - event.event.triggerId, - ]), - ) - .digest("hex")}` - : null; - const preflight = await db.transaction(async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - const resolved = await resolveChatQuestionFormOpen(tx, { - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - conversationId: conversation.id, - interaction, - openActionId: sourceActionId, - }); - if (!resolved || resolved.publicationId !== issued.publication.id) { - return { kind: "denied" as const }; - } - if (!attemptProviderActionId) { - return { - kind: "ready" as const, - attemptActionId: null, - resolved, - }; - } - const [attempt] = await tx - .insert(chatActions) - .values({ - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - conversationId: conversation.id, - principalId: principal.principal.id, - kind: "question_form_open_attempt", - providerActionId: attemptProviderActionId, - payload: { version: 1 }, - status: "processing", - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - return attempt - ? { - kind: "ready" as const, - attemptActionId: attempt.id, - resolved, - } - : { kind: "duplicate" as const }; - }); - if (preflight.kind === "denied") return deny(safelyKnown); - if (preflight.kind === "duplicate") { - if (event.provider === "discord") { - // A prior attempt may have consumed Discord's single response. - // A fresh handler has no showModal closure state for that attempt; - // do not turn its processing/failed receipt into a success ACK. - throw Object.assign( - new Error("Discord modal response was already attempted"), - { code: "chat_discord_gateway_modal_response_indeterminate" }, - ); - } - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - return; - } - attemptActionId = preflight.attemptActionId; - - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - - // A provider modal open can take seconds and can outlive Slack's - // trigger. The final Paperclip authorization snapshot above commits - // before transport starts so provider latency never holds endpoint, - // destination, identity-link, or membership row locks. - const modal = correction - ? discordQuestionFormCorrectionModal( - preflight.resolved.modal, - correction, - ) - : preflight.resolved.modal; - if (!modal) return deny(safelyKnown); - providerAttempted = true; - const opened = await event.event.openModal(modal); - if (!opened) { - await recordModalOpenFailure( - record.endpoint, - event, - runtimeContext, - { ...safelyKnown, attemptActionId, resource }, - new Error("Provider did not confirm the modal open"), - ); - if (event.provider === "discord") return deny(safelyKnown); - return; - } - if (attemptActionId) { - const [settledAttempt] = await db - .update(chatActions) - .set({ - status: "processed", - result: { code: "question_form_opened" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, attemptActionId), - eq(chatActions.status, "processing"), - ), - ) - .returning({ id: chatActions.id }); - if (!settledAttempt) { - throw new Error( - "Question-form modal ownership changed after provider acceptance", - ); - } - } - } catch (error) { - if (isExternalActionAuthorizationChange(error)) { - return deny(safelyKnown); - } - if (!providerAttempted) throw error; - await recordModalOpenFailure( - record.endpoint, - event, - runtimeContext, - { ...safelyKnown, attemptActionId, resource }, - error, - ); - if (event.provider === "discord") return deny(safelyKnown); - // The provider attempt is now a durable, non-replayable Activity row. - // Acknowledge the callback so Chat SDK does not turn a definite denial - // or an ambiguous modal open into a duplicate retry loop. - return; - } - return; - } - - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.kind, "question_answer"), - eq(chatActions.providerActionId, event.event.actionId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!action || action.status !== "issued") return deny(safelyKnown); - const tokenPayload = action.payload; - const tokenExpiresAt = - typeof tokenPayload.expiresAt === "string" - ? Date.parse(tokenPayload.expiresAt) - : Number.NaN; - if (!Number.isFinite(tokenExpiresAt) || tokenExpiresAt <= Date.now()) { - await db - .update(chatActions) - .set({ - status: "expired", - result: { code: "question_action_token_expired" }, - updatedAt: new Date(), - }) - .where( - and(eq(chatActions.id, action.id), eq(chatActions.status, "issued")), - ); - return deny(safelyKnown); - } - if ( - tokenPayload.version !== 1 || - tokenPayload.publicationId !== issued.publication.id || - tokenPayload.interactionId !== payload.interactionId || - typeof tokenPayload.questionId !== "string" || - typeof tokenPayload.optionId !== "string" - ) { - return deny(safelyKnown); - } - - const question = nativeChatQuestion(interaction); - const option = question?.options.find( - (candidate) => candidate.id === tokenPayload.optionId, - ); - if (!question || question.id !== tokenPayload.questionId || !option) - return deny(safelyKnown); - - await options.questionResolutionPersistBarrier?.(); - - const issue = await db - .select({ - id: issues.id, - companyId: issues.companyId, - status: issues.status, - }) - .from(issues) - .where( - and( - eq(issues.companyId, record.endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!issue) return deny(safelyKnown); - try { - const answered = await issueThreadInteractionService(db).answerQuestions( - issue, - interaction.id, - { - answers: [{ questionId: question.id, optionIds: [option.id] }], - }, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - }, - afterResolveInTransaction: async (tx, resolved) => { - const [completed] = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - payload: { - ...tokenPayload, - messageId: event.event.messageId, - actionId: event.event.actionId, - value: event.event.value ?? null, - }, - result: { - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!completed) { - throw new Error( - "External question action ownership changed before commit", - ); - } - await tx - .update(chatActions) - .set({ - status: "expired", - result: { code: "interaction_resolved_by_sibling_action" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.companyId, issue.companyId), - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.kind, "question_answer"), - eq(chatActions.status, "issued"), - ne(chatActions.id, action.id), - eq( - sql`${chatActions.payload}->>'interactionId'`, - resolved.id, - ), - ), - ); - await logActivity(tx as unknown as Db, { - companyId: issue.companyId, - actorType: "user", - actorId: principal.userId!, - action: "issue.thread_interaction_answered", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - endpointId: record.endpoint.id, - provider: record.endpoint.provider, - conversationId: conversation.id, - publicationId: issued.publication.id, - providerMessageId: event.event.messageId, - interactionId: resolved.id, - interactionKind: resolved.kind, - interactionStatus: resolved.status, - resolutionActorKind: "user", - answeredQuestionCount: 1, - }, - }); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: issue.companyId, - endpointId: record.endpoint.id, - conversationId: conversation.id, - issueId: issue.id, - idempotencyKey: `interaction-resolution:${resolved.id}:${record.endpoint.id}`, - payload: projectSafeChatPublication({ - classification: "external", - source: "issue_interaction", - text: `Answered: ${option.label}.`, - interaction: { - id: resolved.id, - card: { - kind: "question", - title: question.prompt, - body: `Answered: ${option.label}.`, - actions: [], - }, - }, - }), - state: "pending", - }) - .onConflictDoNothing(); - }, - }, - ); - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - // The interaction, provider token, and response-delivery receipt are - // already durable. A provider callback must not wait for agent startup; - // drain the outbox after acknowledging the provider callback. - scheduleMessageProcessing(async () => { - await questionResponses.deliver(answered.id); - }); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) { - return deny(safelyKnown); - } - // `answerQuestions` commits the interaction and this action in one - // transaction, then performs a best-effort issue timestamp touch. If - // that post-commit touch fails, the provider callback is nevertheless - // durably complete. Do not corrupt the processed token to `failed` or - // return a retryable webhook response that turns Telegram's redelivery - // into a misleading stale-action notice. - try { - const raceResult = await db.transaction(async (tx) => { - const currentAction = await tx - .select({ status: chatActions.status }) - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - const currentInteraction = ( - await issueThreadInteractionService( - tx as unknown as Db, - ).listForIssue(issue.id) - ).find((candidate) => candidate.id === interaction.id); - if (currentInteraction && currentInteraction.status !== "pending") { - await tx - .update(chatActions) - .set({ - status: "expired", - result: { code: "interaction_resolved_elsewhere" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - inArray(chatActions.status, ["issued", "processing"]), - ), - ); - await enqueueTerminalIssueInteractionChatPublications( - tx, - currentInteraction, - ); - return { actionStatus: currentAction?.status, settledRace: true }; - } - return { actionStatus: currentAction?.status, settledRace: false }; - }); - if (raceResult.actionStatus === "processed") { - logger.warn( - { - endpointId: record.endpoint.id, - interactionId: interaction.id, - error: redactError(error), - }, - "external chat question resolved but post-commit follow-up failed", - ); - return; - } - if (raceResult.settledRace) { - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - return; - } - } catch (recoveryError) { - logger.warn( - { - endpointId: record.endpoint.id, - interactionId: interaction.id, - error: redactError(recoveryError), - }, - "could not reconcile an external chat question after resolution failed", - ); - } - throw error; - } - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - } - - async function handleModalSubmit( - event: ChatSdkCallbackEvent, - runtimeContext: RuntimeContext, - ): Promise { - const denialError = () => - forbidden("This chat form is not a current Paperclip question"); - const record = await runtimeCallbackRecord( - event.endpointId, - runtimeContext, - ["active"], - ); - if ( - !record || - record.endpoint.status !== "active" || - record.endpoint.provider !== event.provider || - record.endpoint.capabilities.modals !== true || - (event.provider !== "slack" && - event.provider !== "microsoft-teams" && - !( - event.provider === "discord" && event.transport === "discord_gateway" - )) - ) { - const staleRecord = - record ?? - (await endpointRecord(event.endpointId).then((candidate) => - candidate?.endpoint.provider === event.provider ? candidate : null, - )); - if (staleRecord) { - await recordModalSubmitDenial(staleRecord.endpoint, event, { - code: "chat_modal_runtime_not_current", - }); - } - return event.provider === "discord" - ? discordQuestionFormDenialResponse() - : chatQuestionFormDenialResponse(); - } - const deny = async ( - code: string, - safelyKnown: { - conversationId?: string | null; - principalId?: string | null; - } = {}, - payload?: Parameters[0], - ): Promise => { - await recordModalSubmitDenial(record.endpoint, event, { - code, - ...safelyKnown, - }); - return event.provider === "discord" - ? discordQuestionFormDenialResponse() - : chatQuestionFormDenialResponse(payload); - }; - const loaded = await loadChatQuestionFormSubmissionToken(db, { - callbackId: event.event.callbackId, - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - includeProcessed: true, - }); - if (!loaded) return deny("chat_modal_token_not_current"); - const conversation = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, record.endpoint.companyId), - eq(chatConversations.endpointId, record.endpoint.id), - eq(chatConversations.id, loaded.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - !conversation || - (event.provider === "discord" && - discordQuestionFormThreadId(event.event.raw) !== - conversation.externalThreadId) || - (event.event.relatedThread && - (event.provider === "microsoft-teams" - ? !sameTeamsThreadIdentity( - event.event.relatedThread.id, - conversation.externalThreadId, - ) - : event.event.relatedThread.id !== conversation.externalThreadId)) - ) { - return deny( - "chat_modal_conversation_not_current", - { - conversationId: conversation?.id ?? null, - }, - loaded.payload, - ); - } - const principal = await ensurePrincipal( - record.endpoint, - event.event.user, - event.event.raw, - ); - if ( - principal.linkedDenied || - !principal.userId || - principal.principal.kind !== "user" || - principal.principal.isBot - ) { - return deny( - "chat_modal_principal_not_authorized", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const resource = conversation.resourceId - ? await db - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, record.endpoint.companyId), - eq(chatEndpointResources.endpointId, record.endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .then((rows) => rows[0] ?? null) - : null; - const resourceAllowed = conversation.isDirectMessage - ? record.endpoint.allowDirectMessages - : nonDirectDestinationAllowed(record.endpoint, resource); - if (!resourceAllowed) { - return deny( - "chat_modal_destination_not_authorized", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - - const originalPublication = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, record.endpoint.companyId), - eq(chatPublications.endpointId, record.endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.id, loaded.publicationId), - eq(chatPublications.state, "published"), - event.provider !== "discord" && event.event.relatedMessage - ? eq( - chatPublications.providerMessageId, - event.event.relatedMessage.id, - ) - : undefined, - ), - ) - .then((rows) => rows[0] ?? null); - const providerMessageId = - (event.provider === "discord" - ? undefined - : event.event.relatedMessage?.id) ?? - originalPublication?.providerMessageId ?? - null; - if (!originalPublication || !providerMessageId) { - return deny( - "chat_modal_publication_not_current", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const currentMessageBinding = await db - .select({ - link: chatMessageLinks, - publication: chatPublications, - }) - .from(chatMessageLinks) - .innerJoin( - chatPublications, - and( - eq(chatPublications.companyId, chatMessageLinks.companyId), - eq(chatPublications.endpointId, chatMessageLinks.endpointId), - eq(chatPublications.conversationId, chatMessageLinks.conversationId), - eq(chatPublications.id, chatMessageLinks.publicationId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, record.endpoint.companyId), - eq(chatMessageLinks.endpointId, record.endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.providerMessageId, providerMessageId), - eq(chatMessageLinks.direction, "outbound"), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.state, "published"), - eq(chatPublications.providerMessageId, providerMessageId), - ), - ) - .then((rows) => (rows.length === 1 ? rows[0]! : null)); - const linkedPayload = currentMessageBinding?.publication.payload as - | SafeChatPublicationPayload - | undefined; - const linkStillAuthoritative = - currentMessageBinding?.link.publicationId === originalPublication.id || - (currentMessageBinding?.publication.idempotencyKey === - `interaction-resolution:${loaded.interactionId}:${record.endpoint.id}` && - linkedPayload?.interactionId === loaded.interactionId); - if (!currentMessageBinding || !linkStillAuthoritative) { - return deny( - "chat_modal_message_binding_not_current", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const issued = { - publication: originalPublication, - link: currentMessageBinding.link, - }; - const payload = issued.publication.payload as SafeChatPublicationPayload; - if (payload.interactionId !== loaded.interactionId) { - return deny( - "chat_modal_interaction_binding_not_current", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === loaded.interactionId); - const discordReplayAnswersMatch = ( - resolved: typeof interaction, - ): boolean => { - if (event.provider !== "discord") return true; - if ( - resolved?.kind !== "ask_user_questions" || - resolved.status !== "answered" - ) - return false; - // Parse only for comparison after current token/source/actor checks. - // This local pending projection cannot reopen or resolve an interaction. - const submitted = validateChatQuestionFormSubmission({ - callbackId: event.event.callbackId, - privateMetadata: event.event.privateMetadata, - interaction: { ...resolved, status: "pending" }, - payload: loaded.payload, - values: event.event.values, - }); - return ( - submitted.ok && - JSON.stringify(submitted.answers) === - JSON.stringify(resolved.result?.answers) - ); - }; - const isExactProcessedReplay = ( - current: Awaited>, - ) => - interaction?.companyId === record.endpoint.companyId && - interaction.issueId === conversation.issueId && - interaction.kind === "ask_user_questions" && - interaction.status === "answered" && - interaction.resolvedByUserId === principal.userId && - current?.status === "processed" && - current.actionRowId === loaded.actionRowId && - current.conversationId === conversation.id && - current.publicationId === originalPublication.id && - current.interactionId === loaded.interactionId && - current.principalId === principal.principal.id && - current.result?.code === "question_form_answered" && - current.result?.interactionId === loaded.interactionId && - discordReplayAnswersMatch(interaction); - const acceptedResponse = async (): Promise => { - if (event.provider === "discord") { - try { - await deleteDiscordQuestionFormCorrection( - persistence, - { - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - }, - { - principalId: principal.principal.id, - userId: principal.userId!, - externalUserId: event.event.user.userId, - }, - loaded.payload.formActionId, - ); - } catch { - // The answer receipt is already committed. Cleanup is retryable on - // exact replay/expiry access and cannot relabel an accepted answer. - logger.warn( - { - endpointId: record.endpoint.id, - code: "discord_question_correction_cleanup_failed", - }, - "Discord correction draft cleanup will be retried", - ); - } - } - return { action: "clear" }; - }; - if (loaded.status === "processed") { - if (!isExactProcessedReplay(loaded)) { - return deny( - "chat_modal_replay_not_authorized", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - if (event.event.relatedThread) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.relatedThread.id, - event.event.raw, - ); - } - return acceptedResponse(); - } - if ( - !interaction || - interaction.companyId !== record.endpoint.companyId || - interaction.issueId !== conversation.issueId || - interaction.kind !== "ask_user_questions" || - interaction.status !== "pending" - ) { - // Another callback can commit the same token between the initial token - // read and this interaction read. Only its exact processed receipt may - // acknowledge the stale snapshot; an answer by another token or actor - // does not authorize this form. Recheck current policy under its locks. - let processedReplay = false; - if ( - interaction?.companyId === record.endpoint.companyId && - interaction.issueId === conversation.issueId && - interaction.kind === "ask_user_questions" && - interaction.status === "answered" - ) { - try { - processedReplay = await db.transaction(async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - return isExactProcessedReplay( - await loadChatQuestionFormSubmissionToken(tx, { - callbackId: event.event.callbackId, - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - includeProcessed: true, - }), - ); - }); - } catch (error) { - if (!isExternalActionAuthorizationChange(error)) throw error; - } - } - if (processedReplay) { - if (event.event.relatedThread) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.relatedThread.id, - event.event.raw, - ); - } - return acceptedResponse(); - } - return deny( - "chat_modal_interaction_not_pending", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const validation = validateChatQuestionFormSubmission({ - callbackId: event.event.callbackId, - privateMetadata: event.event.privateMetadata, - interaction, - payload: loaded.payload, - values: event.event.values, - }); - if (!validation.ok) { - if (validation.code === "invalid_form") { - if (event.provider === "discord") { - const openAction = payload.card?.actions?.find( - (action) => - action.type === "callback" && - isChatQuestionFormOpenActionId(action.actionId), - ); - const openActionId = - openAction?.type === "callback" ? openAction.actionId : null; - if (!openActionId) return deny("chat_modal_open_token_not_current"); - try { - return await db.transaction(async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: record.endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - const pending = await tx - .select({ id: issueThreadInteractions.id }) - .from(issueThreadInteractions) - .where( - and( - eq( - issueThreadInteractions.companyId, - record.endpoint.companyId, - ), - eq(issueThreadInteractions.issueId, conversation.issueId), - eq(issueThreadInteractions.id, interaction.id), - eq(issueThreadInteractions.status, "pending"), - ), - ) - .for("update") - .then((rows) => rows[0]); - const resolved = pending - ? await resolveChatQuestionFormOpen(tx, { - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - conversationId: conversation.id, - interaction, - openActionId, - }) - : null; - if ( - !resolved || - resolved.submitActionId !== loaded.payload.formActionId || - resolved.publicationId !== originalPublication.id - ) - throw denialError(); - return retainDiscordQuestionFormCorrection( - createChatSdkStatePersistence(tx as unknown as Db), - { - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - }, - { - principalId: principal.principal.id, - userId: principal.userId!, - externalUserId: event.event.user.userId, - conversationId: conversation.id, - publicationId: originalPublication.id, - providerMessageId, - threadId: conversation.externalThreadId, - interactionId: interaction.id, - openActionId, - submitActionId: resolved.submitActionId, - parentExpiresAt: loaded.payload.expiresAt, - modal: resolved.modal, - values: event.event.values, - fieldErrors: validation.fieldErrors, - }, - ); - }); - } catch (error) { - if ( - isExternalActionAuthorizationChange(error) || - (error && - typeof error === "object" && - "status" in error && - error.status === 403) - ) - return deny("chat_modal_correction_not_authorized"); - throw error; - } - } - return chatQuestionFormValidationResponse({ - provider: event.provider, - callbackId: event.event.callbackId, - privateMetadata: event.event.privateMetadata, - interaction, - payload: loaded.payload, - values: event.event.values, - }); - } - return deny( - `chat_modal_${validation.code}`, - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - const issue = await db - .select({ - id: issues.id, - companyId: issues.companyId, - status: issues.status, - }) - .from(issues) - .where( - and( - eq(issues.companyId, record.endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!issue) { - return deny( - "chat_modal_issue_not_current", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - await options.questionResolutionPersistBarrier?.(); - let answered: Awaited< - ReturnType< - ReturnType["answerQuestions"] - > - >; - try { - answered = await issueThreadInteractionService(db).answerQuestions( - issue, - interaction.id, - { answers: validation.answers }, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: event.endpointId, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext, - }); - }, - afterResolveInTransaction: async (tx, resolved) => { - const claimed = await claimChatQuestionFormSubmission(tx, { - actionRowId: loaded.actionRowId, - principalId: principal.principal.id, - }); - if (!claimed) throw denialError(); - const completed = await completeChatQuestionFormSubmission(tx, { - actionRowId: loaded.actionRowId, - interactionId: resolved.id, - }); - if (!completed) throw denialError(); - await tx - .update(chatActions) - .set({ - status: "expired", - result: { code: "question_form_submitted" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.companyId, issue.companyId), - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.kind, "question_form_open"), - eq(chatActions.status, "issued"), - eq( - sql`${chatActions.payload}->>'interactionId'`, - resolved.id, - ), - ), - ); - await logActivity(tx as unknown as Db, { - companyId: issue.companyId, - actorType: "user", - actorId: principal.userId!, - action: "issue.thread_interaction_answered", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat_modal", - endpointId: record.endpoint.id, - provider: record.endpoint.provider, - conversationId: conversation.id, - publicationId: issued.publication.id, - interactionId: resolved.id, - interactionKind: resolved.kind, - interactionStatus: resolved.status, - resolutionActorKind: "user", - answeredQuestionCount: validation.answers.length, - }, - }); - }, - }, - ); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) { - await db.transaction(async (tx) => { - await tx - .update(chatActions) - .set({ - status: "expired", - result: { code: "chat_action_authorization_changed" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, loaded.actionRowId), - eq(chatActions.status, "issued"), - ), - ); - await recordModalSubmitDenial( - record.endpoint, - event, - { - code: "chat_action_authorization_changed", - conversationId: conversation.id, - principalId: principal.principal.id, - }, - tx, - ); - }); - return event.provider === "discord" - ? discordQuestionFormDenialResponse() - : chatQuestionFormDenialResponse(loaded.payload); - } - const settledRace = await db.transaction(async (tx) => { - const currentInteraction = ( - await issueThreadInteractionService(tx as unknown as Db).listForIssue( - issue.id, - ) - ).find((candidate) => candidate.id === interaction.id); - if (!currentInteraction || currentInteraction.status === "pending") { - return false; - } - await enqueueTerminalIssueInteractionChatPublications( - tx, - currentInteraction, - ); - return true; - }); - if (!settledRace) { - const status = - error && typeof error === "object" - ? (error as { status?: unknown }).status - : null; - if (status === 403) { - return deny( - "chat_modal_resolution_not_authorized", - { - conversationId: conversation.id, - principalId: principal.principal.id, - }, - loaded.payload, - ); - } - throw error; - } - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - if (event.provider === "discord") { - const current = await loadChatQuestionFormSubmissionToken(db, { - callbackId: event.event.callbackId, - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - includeProcessed: true, - }); - const resolved = ( - await issueThreadInteractionService(db).listForIssue( - conversation.issueId, - ) - ).find((candidate) => candidate.id === interaction.id); - if ( - current?.status !== "processed" || - current.actionRowId !== loaded.actionRowId || - current.principalId !== principal.principal.id || - current.result?.code !== "question_form_answered" || - current.result.interactionId !== interaction.id || - resolved?.status !== "answered" || - resolved.resolvedByUserId !== principal.userId || - !discordReplayAnswersMatch(resolved) - ) - return deny("chat_modal_resolution_committed_elsewhere"); - } - return acceptedResponse(); - } - if (event.event.relatedThread) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.relatedThread.id, - event.event.raw, - ); - } - scheduleMessageProcessing(async () => { - await questionResponses.deliver(answered.id); - }); - return acceptedResponse(); - } async function handleSlashCommand( event: ChatSdkCallbackEvent, @@ -13100,43 +4709,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return true; } - function lifecycleRuntimeFence( - delivery: DeliveryRow, - ): LifecycleRuntimeFence | null { - const normalized = delivery.normalizedEvent; - if ( - !normalized || - typeof normalized !== "object" || - Array.isArray(normalized) - ) - return null; - const value = (normalized as Record).runtimeContext; - if (!value || typeof value !== "object" || Array.isArray(value)) - return null; - const context = value as Record; - return typeof context.generation === "number" && - Number.isSafeInteger(context.generation) && - typeof context.credentialFingerprint === "string" - ? { - generation: context.generation as number, - credentialFingerprint: context.credentialFingerprint, - } - : null; - } - - function normalizedLifecycleWithRuntimeFence( - delivery: DeliveryRow, - runtimeContext: LifecycleRuntimeFence, - ): Record { - const normalized = - delivery.normalizedEvent && - typeof delivery.normalizedEvent === "object" && - !Array.isArray(delivery.normalizedEvent) - ? (delivery.normalizedEvent as Record) - : {}; - return { ...normalized, runtimeContext }; - } - async function applyProviderLifecycleEffects( endpoint: EndpointRow, effects: ChatProviderLifecycleEffect[], @@ -15964,2887 +7536,18 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { publication ); } - - async function recordExactOutboundPublicationLink( - tx: DbOrTransaction, - input: { - publication: typeof chatPublications.$inferSelect; - providerMessageId: string; - }, - ): Promise { - const inserted = await tx - .insert(chatMessageLinks) - .values({ - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - publicationId: input.publication.id, - commentId: input.publication.commentId, - providerMessageId: input.providerMessageId, - direction: "outbound", - }) - .onConflictDoNothing() - .returning({ id: chatMessageLinks.id }); - if (inserted.length) return true; - return tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, input.publication.companyId), - eq(chatMessageLinks.endpointId, input.publication.endpointId), - eq(chatMessageLinks.conversationId, input.publication.conversationId), - eq(chatMessageLinks.publicationId, input.publication.id), - input.publication.commentId - ? eq(chatMessageLinks.commentId, input.publication.commentId) - : isNull(chatMessageLinks.commentId), - eq(chatMessageLinks.providerMessageId, input.providerMessageId), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); + async function recordExactOutboundPublicationLink(...args: Parameters["recordExactOutboundPublicationLink"]>) { + return publicationTransport.recordExactOutboundPublicationLink(...args); } - async function postSafePublication(input: { - endpoint: EndpointRow; - conversation: ConversationRow; - publication: typeof chatPublications.$inferSelect; - payload: SafeChatPublicationPayload; - replaceProviderMessageId?: string | null; - telegramDraftControl?: TelegramDraftControl; - beforePhotonWrite?(): Promise; - onSlackFileUploadAccepted?: ( - receipt: SlackFileUploadAcceptedReceipt, - ) => Promise; - }) { - if (input.endpoint.provider === "speko") { - const receipt = await voiceStore.enqueuePublication(input.endpoint.companyId, input.publication.id); - scheduleMessageProcessing(() => voice.pushReplies(25, input.endpoint.companyId, input.endpoint.id).then(() => undefined)); - return receipt; - } - if (input.endpoint.provider === "github" && ["failed", "completed"].includes(input.payload.progressState ?? "") && - !input.payload.interactionId && !isExplicitOperatorPublication(input.publication) && - (await db.select({ endpointId: chatGitHubConfigurations.endpointId }).from(chatGitHubConfigurations).where(and( - eq(chatGitHubConfigurations.companyId, input.endpoint.companyId), eq(chatGitHubConfigurations.endpointId, input.endpoint.id))).limit(1)).length > 0) { - const runId = runIdFromMilestonePublication(input.publication); - if (!runId) throw forbidden("GitHub failure has no run binding"); - const receipt = await githubResponseCommentService(db, fetchImpl).failRun({ - companyId: input.publication.companyId, endpointId: input.endpoint.id, - issueId: input.publication.issueId, runId, body: input.payload.progressState === "completed" - ? "This turn ended without publishing a final response. See the Paperclip task for details." - : renderPublicationTransportText(input.payload), - }); - if (!receipt) throw conflict("GitHub response publication is busy"); - return { id: receipt.id, threadId: input.conversation.externalThreadId, raw: {}, text: input.payload.text }; - } - const endpointRuntime = await runtimeFor(input.endpoint); - const thread = endpointRuntime.thread(input.conversation.externalThreadId); - const card = safeCardForPublication(input.payload, input.endpoint.provider); - let attachments: Attachment[] = []; - let files: FileUpload[] = []; - let text = renderPublicationTransportText(input.payload); - if (input.payload.attachmentIds?.length) { - if ( - input.endpoint.provider === "microsoft-teams" && - !input.conversation.isDirectMessage - ) { - // The staged personal-file lane remains authoritative for consent. - // Channel/group pictures need no OneDrive upload or public asset URL: - // send only verified original picture bytes through the native adapter. - const candidates = await publicationFiles( - input.publication, - input.payload, - { teamsInlineImagesOnly: true }, - ); - const pictures = await Promise.all( - candidates.map(prepareTeamsInlineImage), - ); - if (pictures.length && pictures.every((picture) => picture !== null)) { - files = pictures; - } - } - const nativeFileSurface = - CAPABILITIES[input.endpoint.provider].files && - input.endpoint.provider !== "microsoft-teams"; - if (nativeFileSurface) { - const uploads = await publicationFiles( - input.publication, - input.payload, - ); - if (input.endpoint.provider === "telegram") { - attachments = await Promise.all( - uploads.map(telegramAttachmentForUpload), - ); - } else { - files = uploads; - } - } else if (files.length === 0) { - const taskUrl = safeChatTaskUrl( - getTaskBaseUrl(), - input.publication.issueId, - ); - if ( - input.endpoint.provider === "github" || - input.endpoint.provider === "microsoft-teams" - ) { - // GitHub lacks a bot upload contract. Teams reaches this fallback - // only without an admissible personal-consent destination. Replace - // our generated caption, preserving any future custom text. - const generatedFileLabel = /^Shared (.+)\.$/s.exec(text)?.[1] ?? null; - const limitation = - input.endpoint.provider === "github" - ? "This GitHub App connection cannot upload file bytes into comments." - : "Direct file delivery isn't available for this Teams conversation."; - if (generatedFileLabel) { - const saved = taskUrl - ? `File saved on the Paperclip task: ${generatedFileLabel}.` - : `File saved on the private Paperclip task: ${generatedFileLabel}.`; - text = taskUrl - ? `${saved} ${limitation} Download it: ${taskUrl}` - : `${saved} ${limitation}`; - } else { - const handoff = taskUrl - ? `Open the file on its Paperclip task: ${taskUrl}` - : "The file remains available only on the private Paperclip task."; - text = `${text}\n\n${limitation} ${handoff}`; - } - } else { - const attachmentFallback = taskUrl - ? `Open the task in Paperclip: ${taskUrl}` - : "Open the task in Paperclip to download the attachment."; - text = `${text}\n\n${attachmentFallback}`; - } - } - } - if (input.endpoint.provider === "imessage-photon") { - const adapter = endpointRuntime.getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) - throw new Error("Photon publication runtime unavailable"); - const assertCurrent = async () => { - await input.beforePhotonWrite?.(); - const current = await endpointRecord(input.endpoint.id); - if ( - !current || - !["verifying", "active"].includes(current.endpoint.status) || - runtimeGeneration(current.endpoint.setup) !== - runtimeGeneration(input.endpoint.setup) || - runtime.get(input.endpoint.id) !== endpointRuntime - ) - throw new PhotonError( - "rejected", - "Photon publication authority changed", - ); - const resource = await db - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, current.endpoint.companyId), - eq(chatEndpointResources.endpointId, current.endpoint.id), - eq(chatEndpointResources.id, input.conversation.resourceId!), - ), - ) - .then((rows) => rows[0]); - if ( - resource?.availability !== "available" || - (!input.conversation.isDirectMessage && !resource.enabled) || - (input.conversation.isDirectMessage && - !current.endpoint.allowDirectMessages) - ) - throw new PhotonError( - "rejected", - "Photon conversation is no longer enabled", - ); - }; - const retry = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.kind, "photon_publication_retry"), - eq( - chatActions.providerActionId, - `photon-retry:${input.publication.id}:${input.publication.attempts + 1}`, - ), - eq(chatActions.status, "processed"), - ), - ) - .then((rows) => rows[0]); - const retryUnknown = Boolean(retry); - const nextQuestion = - /^photon-question:([a-zA-Z0-9_-]{8,24}):(\d{1,2})$/.exec( - input.publication.idempotencyKey, - ); - if ( - input.payload.interactionId && - (input.publication.idempotencyKey === - `interaction:${input.payload.interactionId}:${input.endpoint.id}` || - nextQuestion) - ) { - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.kind, "photon_interaction"), - nextQuestion - ? eq(chatActions.providerActionId, `photon:${nextQuestion[1]}`) - : eq( - sql`${chatActions.payload}->>'publicationId'`, - input.publication.id, - ), - eq(chatActions.status, "issued"), - ), - ) - .then((rows) => rows[0]); - const interaction = ( - await issueThreadInteractionService(db).listForIssue( - input.publication.issueId, - ) - ).find((candidate) => candidate.id === input.payload.interactionId); - if ( - action && - interaction?.status === "pending" && - nativePhotonInteraction(interaction) - ) { - const promptGuard = async () => { - await assertCurrent(); - const current = await db - .select({ status: issueThreadInteractions.status }) - .from(issueThreadInteractions) - .where( - and( - eq( - issueThreadInteractions.companyId, - input.endpoint.companyId, - ), - eq(issueThreadInteractions.id, interaction.id), - ), - ) - .then((rows) => rows[0]); - if (current?.status !== "pending") - throw new PhotonError( - "rejected", - "This interaction has already been resolved", - ); - }; - const receipt = await publishPhotonPrompt({ - adapter, - threadId: thread.id, - binding: action.payload as unknown as PhotonInteractionBinding, - interaction, - questionIndex: nextQuestion ? Number(nextQuestion[2]) : 0, - taskUrl: safeChatTaskUrl( - getTaskBaseUrl(), - input.publication.issueId, - ), - assertCurrent: promptGuard, - retryUnknown, - }); - return { id: receipt.promptMessageGuid }; - } - if (nextQuestion) - throw new PhotonError( - "rejected", - "This question is no longer available", - ); - } - const reply = await adapter.state.read<{ guid: string | null }>( - `publication-reply:${input.publication.id}`, - ); - let replyTo = reply?.guid ?? null; - if (!reply) { - const sourceRunId = await receiptReactionCompletionRunId( - db, - input.publication, - input.payload, - ); - if (sourceRunId) { - const source = await db - .select({ guid: chatMessageLinks.providerMessageId }) - .from(heartbeatRuns) - .innerJoin( - chatMessageLinks, - and( - eq(chatMessageLinks.companyId, input.endpoint.companyId), - eq(chatMessageLinks.endpointId, input.endpoint.id), - eq(chatMessageLinks.conversationId, input.conversation.id), - eq(chatMessageLinks.direction, "inbound"), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, - ), - ), - ) - .where( - and( - eq(heartbeatRuns.id, sourceRunId), - eq(heartbeatRuns.companyId, input.endpoint.companyId), - ), - ) - .orderBy(desc(chatMessageLinks.createdAt)) - .limit(1) - .then((rows) => rows[0]); - replyTo = source?.guid ?? null; - } - replyTo = ( - await adapter.state.update<{ guid: string | null }>( - `publication-reply:${input.publication.id}`, - (current) => current ?? { guid: replyTo }, - ) - ).guid; - } - const sent = await adapter.publish( - thread.id, - input.publication.id, - { markdown: text, files }, - { assertCurrent, retryUnknown, replyTo: replyTo ?? undefined }, - ); - if (input.publication.idempotencyKey.startsWith("photon-response:")) { - const notice = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.kind, "photon_response_notice"), - eq( - chatActions.providerActionId, - `photon-notice:${input.publication.idempotencyKey}`, - ), - ), - ) - .then((rows) => rows[0]); - if ( - notice && - typeof notice.payload.reference === "string" && - typeof notice.payload.questionIndex === "number" && - typeof notice.payload.publicationId === "string" - ) { - const receipt: PhotonPromptReceipt = { - schema: 1, - reference: notice.payload.reference, - questionIndex: notice.payload.questionIndex, - publicationId: notice.payload.publicationId, - promptMessageGuid: sent.id, - promptMessageGuids: sent.messageIds, - options: {}, - }; - for (const guid of sent.messageIds) - await adapter.state.update( - `prompt-message:${guid}`, - (current) => current ?? receipt, - ); - } - } - return sent; - } - if ( - input.endpoint.provider === "discord" && - input.payload.transportPart?.mode === "discord_markdown_attachment" - ) { - files.push(discordMarkdownAttachment(text)); - text = - input.payload.transportPart.count > 1 - ? "Complete response attached." - : "Paperclip attached the complete response because it exceeds Discord’s message limit."; - } - if ( - input.endpoint.provider === "telegram" && - input.payload.transportPart?.mode === "telegram_markdown_attachment" - ) { - attachments.push(telegramMarkdownAttachment(text)); - text = - input.payload.transportPart.count > 1 - ? "Complete response attached." - : "Paperclip attached the complete response to preserve its Markdown formatting."; - } - if (card && CAPABILITIES[input.endpoint.provider].cards) { - return await attemptProviderPublication(async () => - input.replaceProviderMessageId - ? await editOrPostProviderPublication( - () => - thread.adapter.editMessage( - thread.id, - input.replaceProviderMessageId!, - { - card, - fallbackText: text, - ...(files.length ? { files } : {}), - }, - ), - () => - thread.post({ - card, - fallbackText: text, - ...(files.length ? { files } : {}), - }), - ) - : await thread.post({ - card, - fallbackText: text, - ...(files.length ? { files } : {}), - }), - ); - } - if (attachments.length) { - const attachmentMessage: AdapterPostableMessage = { - attachments, - markdown: text, - }; - return await attemptProviderPublication(async () => - input.replaceProviderMessageId - ? await editOrPostProviderPublication( - () => - thread.adapter.editMessage( - thread.id, - input.replaceProviderMessageId!, - attachmentMessage, - ), - () => thread.post(attachmentMessage), - ) - : await thread.post(attachmentMessage), - ); - } - if (files.length) { - // The pinned Slack adapter performs file upload and text publication as - // two provider calls when both fields are present. A rate limit or - // network failure on the second call would make an automatic retry - // duplicate the already-shared file. File publications have their own - // ordered outbox row, so Slack receives a single native upload here. - const fileMessage: AdapterPostableMessage = { - // Empty Slack markdown keeps the SDK call type-safe while its pinned - // adapter treats this as a file-only upload and skips chat.postMessage. - markdown: input.endpoint.provider === "slack" ? "" : text, - files, - }; - if ( - input.endpoint.provider === "slack" && - !input.replaceProviderMessageId && - !input.payload.card && - !input.payload.interactionId && - !input.publication.idempotencyKey.startsWith("control:") && - input.payload.attachmentIds?.length && - input.onSlackFileUploadAccepted - ) { - return await attemptProviderPublication( - async () => - await endpointRuntime.postSlackFilePublication( - thread.id, - fileMessage, - input.onSlackFileUploadAccepted!, - ), - ); - } - return await attemptProviderPublication(async () => - input.replaceProviderMessageId - ? await editOrPostProviderPublication( - () => - thread.adapter.editMessage( - thread.id, - input.replaceProviderMessageId!, - fileMessage, - ), - () => thread.post(fileMessage), - ) - : await thread.post(fileMessage), - ); - } - if ( - !input.replaceProviderMessageId && - !input.payload.transportPart && - CAPABILITIES[input.endpoint.provider].nativeStreaming && - (input.endpoint.provider !== "telegram" || - !input.conversation.isDirectMessage || - input.telegramDraftControl !== undefined) && - shouldStreamSafePublicationText(text) - ) { - return await attemptProviderPublication(async () => - input.telegramDraftControl - ? endpointRuntime.streamTelegramDraft( - input.conversation.externalThreadId, - streamSafePublicationText(text), - input.telegramDraftControl, - ) - : thread.post(streamSafePublicationText(text)), - ); - } - return await attemptProviderPublication(async () => - input.replaceProviderMessageId - ? await editOrPostProviderPublication( - () => - thread.adapter.editMessage( - thread.id, - input.replaceProviderMessageId!, - { markdown: text }, - ), - () => thread.post({ markdown: text }), - ) - : await thread.post({ markdown: text }), - ); + async function stageTerminalReceiptReactionRemovals(...args: Parameters["stageTerminalReceiptReactionRemovals"]>) { + return publicationWorker.stageTerminalReceiptReactionRemovals(...args); } - async function claimPublicationTransportAuthorization(input: { - credentialLease: CredentialMutationLeaseGuard; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - runtimeContext: LifecycleRuntimeFence; - }): Promise<{ - authorizationActionId: string | null; - conversation: ConversationRow; - endpoint: EndpointRow; - preparedText: string | null; - } | null> { - return db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const currentPublication = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, input.publication.attempts + 1), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!currentPublication) return null; - try { - if ( - !(await authorizeRetainedChatSourcePublication(tx, input.publication)) - ) - return null; - } catch (error) { - if (error instanceof NativeChatReviewPresentationContentionError) - throw error; - throw new FailedChatRetryPublicationReadError(); - } - const attachmentFailureFence = attachmentFailureNoticeFence( - input.publication.idempotencyKey, - ); - if ( - attachmentFailureFence && - (attachmentFailureFence.generation !== - input.runtimeContext.generation || - attachmentFailureFence.credentialFingerprint !== - input.runtimeContext.credentialFingerprint) - ) { - return null; - } - const endpoint = await runtimeCallbackEndpoint( - tx, - input.publication.endpointId, - input.runtimeContext, - ["verifying", "active"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, input.publication.conversationId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId && !conversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const attachmentFailureNoticeSuperseded = - attachmentFailureFence && conversation - ? await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, conversation.companyId), - eq(chatConversations.endpointId, conversation.endpointId), - eq( - chatConversations.externalThreadId, - conversation.externalThreadId, - ), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1) - .then((rows) => rows.length > 0) - : false; - const isTaskControl = - input.publication.idempotencyKey.startsWith("control:"); - const providerVisibleCompletion = - conversation?.state === "completed" && - !isExplicitOperatorPublication(input.publication) && - (await hasCommittedTaskControlCompletion(conversation.id, tx)); - const authorizationAction = - endpoint && isTaskControl - ? await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.conversationId, conversation?.id ?? ""), - eq(chatActions.kind, "task_control_authorization"), - eq( - chatActions.providerActionId, - `task-control-authorization:${input.publication.id}`, - ), - eq(chatActions.status, "issued"), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const destinationAllowed = - endpoint !== null && - conversation !== null && - (endpoint.provider !== "slack" || await slackPublicationAllowed(tx, endpoint.companyId, endpoint.id, input.publication.issueId, conversation.externalConversationId, null)) && - (conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)); - if ( - !endpoint || - !conversation || - (attachmentFailureFence && - ((conversation.state !== "active" && - conversation.state !== "waiting") || - attachmentFailureNoticeSuperseded)) || - providerVisibleCompletion || - (isTaskControl && !authorizationAction?.principalId) || - !destinationAllowed - ) { - return null; - } - let authorizationActionId: string | null = null; - if (endpoint.provider === "slack") { - const credentials = await resolveCredentials(endpoint, tx); - if (!await authorizeSlackBoardPublication(tx, endpoint, conversation, input.publication, credentials.botToken ?? "", fetchImpl)) return null; - } - if ( - input.publication.idempotencyKey.startsWith("wake:") && - !(await authorizeInboundWakePublication(tx, input.publication)) - ) - return null; - // A native response alongside an older completion review has its own - // narrow authorization. Recheck that exact proof, review and original - // requester at the transport claim, including file publications whose - // comment is the run's earlier internal attachment-selection comment. - if ( - input.publication.commentId && - !isExplicitOperatorPublication(input.publication) - ) { - const [origin] = await tx - .select({ - runId: heartbeatRuns.id, - resultJson: heartbeatRuns.resultJson, - }) - .from(issueComments) - .innerJoin( - heartbeatRuns, - and( - eq(heartbeatRuns.id, issueComments.createdByRunId), - eq(heartbeatRuns.companyId, issueComments.companyId), - ), - ) - .where( - and( - eq(issueComments.id, input.publication.commentId), - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, input.publication.issueId), - ), - ) - .limit(1); - if ( - origin?.resultJson?.finalizationReasonCode === - "governed_response_waiting" - ) { - try { - if ( - !(await authorizeNativeChatReviewPresentation( - tx as unknown as Db, - { - companyId: endpoint.companyId, - issueId: input.publication.issueId, - runId: origin.runId, - resultJson: origin.resultJson, - destination: { - endpointId: endpoint.id, - conversationId: conversation.id, - }, - }, - "nonblocking", - )) - ) - return null; - } catch (error) { - if (isExternalChatWaitAuthorizationContention(error)) - throw new NativeChatReviewPresentationContentionError(); - throw error; - } - } - } - const preparedText = await githubCurrentOmissionPreparedText( - tx, - input.publication, - endpoint, - ); - if (preparedText === false) return null; - if (authorizationAction?.principalId) { - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - authorizationAction.principalId, - ); - if (!authorization.allowed) return null; - const [claimedAuthorization] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: input.publication.attempts + 1, - authorizedUserId: authorization.userId, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - runtimeGeneration: input.runtimeContext.generation, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, authorizationAction.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimedAuthorization) return null; - authorizationActionId = claimedAuthorization.id; - } - if (preparedText !== null) { - // Persist the exact provider text before I/O. A retry must reuse this - // body, not accumulate links or regenerate it from changed settings. - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - payload: { ...input.publication.payload, text: preparedText }, - }) - .where(eq(chatPublications.id, input.publication.id)); - } - await input.credentialLease.assertOwned(tx); - // The publication's streaming row plus the optional authorization action - // are the durable transport claim. Their database locks are released - // before provider I/O. The renewable credential lease intentionally - // remains held so reconnect and reach mutations linearize after this - // committed authorization snapshot and transport attempt. - return { - authorizationActionId, - conversation, - endpoint, - preparedText, - }; - }); + async function processPendingPublications(...args: Parameters["processPendingPublications"]>) { + return publicationQueue.processPendingPublications(...args); } - async function stageTerminalReceiptReactionRemovals( - tx: Db, - input: { - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - payload: SafeChatPublicationPayload; - runtimeContext: RuntimeContext; - closedProgressRunId?: string; - }, - ): Promise { - if ( - !["discord", "slack", "telegram", "github"].includes(input.endpoint.provider) - ) return []; - const runId = input.closedProgressRunId ?? - (await receiptReactionCompletionRunId(tx, input.publication, input.payload)); - if (!runId) return []; - return stageReceiptReactionRemovals(tx, { - endpoint: input.endpoint, - binding: input.publication, - runId, - runtimeContext: input.runtimeContext, - }); - } - - async function currentTaskControlPayload( - publication: typeof chatPublications.$inferSelect, - ): Promise { - const persisted = publication.payload as SafeChatPublicationPayload; - if (!publication.idempotencyKey.startsWith("control:status:")) { - return persisted; - } - const issue = await db - .select({ - identifier: issues.identifier, - status: issues.status, - title: issues.title, - }) - .from(issues) - .where( - and( - eq(issues.companyId, publication.companyId), - eq(issues.id, publication.issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!issue) return persisted; - return projectSafeChatPublication({ - classification: "external", - source: "task_control", - text: `${issue.identifier}: ${issue.title} — ${issue.status}`, - }); - } - - function taskControlCompletesConversation( - publication: Pick, - ): boolean { - return /^control:(?:new|close):/.test(publication.idempotencyKey); - } - - function isExplicitOperatorPublication( - publication: Pick, - ): boolean { - return /^(?:explicit:|explicit-board:)/.test(publication.idempotencyKey); - } - - async function hasCommittedTaskControlCompletion( - conversationId: string, - database: DbOrTransaction = db, - ): Promise { - return database - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.conversationId, conversationId), - eq(chatPublications.state, "published"), - or( - like(chatPublications.idempotencyKey, "control:new:%"), - like(chatPublications.idempotencyKey, "control:close:%"), - ), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - } - - async function commitTaskControlCompletion( - tx: Db, - publication: Pick< - typeof chatPublications.$inferSelect, - | "companyId" - | "conversationId" - | "createdAt" - | "endpointId" - | "id" - | "idempotencyKey" - | "payload" - >, - committedAt: Date, - ): Promise { - if (!taskControlCompletesConversation(publication)) return; - await tx - .update(chatConversations) - .set({ state: "completed", updatedAt: committedAt }) - .where( - and( - eq(chatConversations.companyId, publication.companyId), - eq(chatConversations.endpointId, publication.endpointId), - eq(chatConversations.id, publication.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - // The provider-visible completion is the external boundary. Anything - // enqueued later for this old generation stays internal even if its run - // finishes after /new or /close. A racing insert that commits after this - // transaction is rejected by the completed-conversation send guard. - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - state: "cancelled", - nextAttemptAt: null, - redactedError: - "Conversation was completed by a task control before delivery", - updatedAt: committedAt, - }) - .where( - and( - eq(chatPublications.companyId, publication.companyId), - eq(chatPublications.endpointId, publication.endpointId), - eq(chatPublications.conversationId, publication.conversationId), - or( - gt(chatPublications.createdAt, publication.createdAt), - and( - eq(chatPublications.createdAt, publication.createdAt), - gt( - publicationTransportOrderKey(chatPublications), - publication.payload.transportPart?.orderKey ?? publication.id, - ), - ), - ), - inArray(chatPublications.state, ["pending", "retry", "streaming"]), - sql`not (${chatPublications.idempotencyKey} like 'explicit:%' or ${chatPublications.idempotencyKey} like 'explicit-board:%')`, - ), - ); - } - - async function finalizePublicationFailure( - publication: typeof chatPublications.$inferSelect, - error: unknown, - providerAccepted = false, - runtimeContext?: LifecycleRuntimeFence | null, - credentialLease?: CredentialMutationLeaseGuard, - ): Promise { - const attempts = publication.attempts + 1; - const disposition = providerAccepted - ? { - kind: "delivery_unknown" as const, - reason: - "Provider accepted the publication, but Paperclip could not confirm its durable result", - } - : error instanceof NativeChatReviewPresentationContentionError - ? { kind: "retry" as const, retryAfterMs: 250, reason: error.message } - : error instanceof FailedChatRetryPublicationReadError - ? { - kind: "retry" as const, - retryAfterMs: 1_000, - reason: error.message, - } - : isOutboundAttachmentValidationError(error) - ? { - kind: "failed" as const, - reason: error.message, - } - : isOutboundAttachmentHydrationError(error) - ? { - kind: "retry" as const, - retryAfterMs: Math.min( - 60_000, - 2 ** Math.max(0, attempts) * 1_000, - ), - reason: error.message, - } - : classifyChatPublicationError(error, attempts); - const failure = redactSensitiveText(disposition.reason).slice( - 0, - MAX_ERROR_TEXT, - ); - // Provider-directed rate limits are not delivery failures. Keep the - // durable publication retryable for the provider's full interval instead - // of exhausting the five-attempt transport budget while flood control is - // still active. - const terminalRetry = - disposition.kind === "retry" && - disposition.providerRateLimit !== true && - attempts >= 5; - const attachmentFailure = attachmentFailureKind(publication.payload); - const attachmentFailureRuntimeContext = - !providerAccepted && - disposition.kind === "failed" && - runtimeContext && - attachmentFailure && - definiteAttachmentProviderRejection(error) - ? runtimeContext - : null; - let invalidateCurrentRuntime = false; - const finalized = await db.transaction(async (tx) => { - await credentialLease?.assertOwned(tx); - const state = - disposition.kind === "retry" && !terminalRetry - ? "retry" - : disposition.kind === "delivery_unknown" - ? "delivery_unknown" - : disposition.kind === "resource_unavailable" - ? "cancelled" - : "failed"; - await notifyChatPublicationWork(tx); - const [ownedPublication] = await tx - .update(chatPublications) - .set({ - state, - nextAttemptAt: - disposition.kind === "retry" && !terminalRetry - ? new Date(Date.now() + disposition.retryAfterMs) - : null, - redactedError: failure, - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, attempts), - ), - ) - .returning({ id: chatPublications.id }); - if (!ownedPublication) return false; - - if (publication.idempotencyKey.startsWith("control:")) { - await tx - .update(chatActions) - .set({ - status: - state === "delivery_unknown" - ? "delivery_unknown" - : state === "cancelled" - ? "cancelled" - : "issued", - result: { - code: - state === "delivery_unknown" - ? "task_control_delivery_unknown" - : state === "cancelled" - ? "task_control_destination_unavailable" - : "task_control_transport_failed", - attempts, - retryable: state === "retry", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempts}`, - ), - ); - } - - const currentEndpoint = - runtimeContext && - (disposition.kind === "endpoint_attention" || - disposition.kind === "resource_unavailable") - ? await runtimeCallbackEndpoint( - tx, - publication.endpointId, - runtimeContext, - ["verifying", "active"], - ) - : null; - - if (disposition.kind === "endpoint_attention" && currentEndpoint) { - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: "Provider credentials or permissions need attention", - lastError: failure, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: "Provider credentials or permissions need attention", - lastError: failure, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, currentEndpoint.connectionId)); - invalidateCurrentRuntime = true; - } else if ( - disposition.kind === "resource_unavailable" && - currentEndpoint - ) { - const binding = await tx - .select({ resourceId: chatConversations.resourceId }) - .from(chatConversations) - .where(eq(chatConversations.id, publication.conversationId)) - .then((result) => result[0] ?? null); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: new Date() }) - .where( - and( - eq(chatConversations.id, publication.conversationId), - eq(chatConversations.endpointId, currentEndpoint.id), - ), - ); - if (binding?.resourceId) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: new Date() }) - .where( - and( - eq(chatEndpointResources.id, binding.resourceId), - eq(chatEndpointResources.endpointId, currentEndpoint.id), - ), - ); - } - } - - if (attachmentFailureRuntimeContext && attachmentFailure) { - const noticeEndpoint = await runtimeCallbackEndpoint( - tx, - publication.endpointId, - attachmentFailureRuntimeContext, - ["verifying", "active"], - ); - const noticeConversation = noticeEndpoint - ? await tx - .select({ - id: chatConversations.id, - state: chatConversations.state, - }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, publication.companyId), - eq(chatConversations.endpointId, publication.endpointId), - eq(chatConversations.id, publication.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - const providerCanSendTextNotice = - noticeEndpoint && - ["slack", "discord", "telegram"].includes(noticeEndpoint.provider) && - (attachmentFailure.provider === null || - noticeEndpoint.provider === attachmentFailure.provider); - if (providerCanSendTextNotice && noticeConversation) { - const taskUrl = safeChatTaskUrl( - getTaskBaseUrl(), - publication.issueId, - ); - const noticeText = - attachmentFailure.kind === "generated_response" - ? "Paperclip could not send the response attachment. The complete response remains on its Paperclip task for an operator to retry." - : "Paperclip could not send an attachment. The file remains on its Paperclip task for an operator to retry."; - const idempotencyKey = `${ATTACHMENT_FAILURE_NOTICE_PREFIX}${publication.id}:${attachmentFailureRuntimeContext.generation}:${attachmentFailureRuntimeContext.credentialFingerprint}`; - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: publication.companyId, - endpointId: publication.endpointId, - conversationId: publication.conversationId, - issueId: publication.issueId, - commentId: null, - idempotencyKey, - payload: projectSafeChatPublication({ - classification: "external", - source: "safe_milestone", - text: taskUrl - ? `${noticeText} Open task: ${taskUrl}` - : noticeText, - }), - state: "pending", - }) - .onConflictDoNothing(); - } - } - - await credentialLease?.assertOwned(tx); - return true; - }); - if (finalized && invalidateCurrentRuntime) { - await credentialLease?.assertOwned(); - await invalidateRuntime(publication.endpointId).catch(() => undefined); - } - if (finalized) { - logger.warn( - { - endpointId: publication.endpointId, - publicationId: publication.id, - error: failure, - disposition: disposition.kind, - }, - "chat publication failed", - ); - } - return finalized; - } - - async function ensureDurablePublicationTransport( - publication: typeof chatPublications.$inferSelect, - ): Promise { - const persisted = publication.payload as SafeChatPublicationPayload; - if ( - !persisted || - typeof persisted !== "object" || - typeof persisted.text !== "string" - ) { - throw Object.assign( - new Error("Durable chat publication payload is malformed"), - { code: "VALIDATION_ERROR", name: "ValidationError" }, - ); - } - // Validate closed persisted wrappers even on retries that are already split. - renderPublicationTransportText(persisted); - await options.publicationTransportPreparationBarrier?.({ - publicationId: publication.id, - }); - const provider = await db - .select({ provider: chatEndpoints.provider }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, publication.endpointId)) - .then((rows) => rows[0]?.provider ?? null); - if ( - provider === "slack" || - provider === "github" || - provider === "microsoft-teams" - ) { - if ( - persisted.transportPart || - persisted.card || - persisted.interactionId || - persisted.attachmentIds?.length - ) - return publication; - const parts = splitNativePublicationText(provider, persisted.text); - if (parts.length === 1) return publication; - return db.transaction(async (tx) => { - const current = await tx - .select() - .from(chatPublications) - .where(eq(chatPublications.id, publication.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) return publication; - const currentPayload = current.payload as SafeChatPublicationPayload; - renderPublicationTransportText(currentPayload); - if ( - currentPayload.transportPart || - currentPayload.card || - currentPayload.interactionId || - currentPayload.attachmentIds?.length || - !["pending", "retry"].includes(current.state) - ) - return current; - const currentParts = - currentPayload.text === persisted.text - ? parts - : splitNativePublicationText(provider, currentPayload.text); - if (currentParts.length === 1) return current; - const batchId = current.id; - const updatedAt = new Date(); - const payloadFor = (index: number): SafeChatPublicationPayload => { - const part = currentParts[index]!; - const payload = { - ...currentPayload, - text: part.text, - transportPart: { - batchId, - index, - count: currentParts.length, - mode: "inline" as const, - orderKey: `${batchId}:${String(index).padStart(4, "0")}`, - ...(part.prefix ? { prefix: part.prefix } : {}), - ...(part.suffix ? { suffix: part.suffix } : {}), - }, - }; - if (index > 0) delete payload.progressState; - return payload; - }; - const rootPayload = payloadFor(0); - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: rootPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - await notifyChatPublicationWork(tx); - await tx.insert(chatPublications).values( - currentParts.slice(1).map((_part, offset) => ({ - companyId: current.companyId, - endpointId: current.endpointId, - conversationId: current.conversationId, - issueId: current.issueId, - commentId: current.commentId, - idempotencyKey: `${current.idempotencyKey}:${provider}-part:${offset + 1}`, - payload: payloadFor(offset + 1), - state: "pending" as const, - createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, - updatedAt, - })), - ); - return { ...current, payload: rootPayload, updatedAt }; - }); - } - if (provider === "discord") { - if ( - persisted.transportPart || - persisted.card || - persisted.interactionId || - !discordMarkdownRequiresAttachment(persisted.text) - ) { - return publication; - } - - // A normal response can replace its run's working placeholder. Discord's - // adapter cannot add a file while editing, so first turn that placeholder - // into a durable hand-off and then publish the complete Markdown as its - // own ordered attachment row. Publications without a replacement lane - // remain one provider call: a short caption and the lossless file. - const replacesProgressMessage = Boolean( - await runPublicationToReplace(publication, persisted), - ); - return db.transaction(async (tx) => { - const current = await tx - .select() - .from(chatPublications) - .where(eq(chatPublications.id, publication.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) return publication; - const currentPayload = current.payload as SafeChatPublicationPayload; - if ( - currentPayload.transportPart || - currentPayload.card || - currentPayload.interactionId || - !["pending", "retry"].includes(current.state) || - !discordMarkdownRequiresAttachment(currentPayload.text) - ) { - return current; - } - - const batchId = current.id; - const updatedAt = new Date(); - const transportPart = ( - index: number, - count: number, - mode: "inline" | "discord_markdown_attachment", - ) => ({ - batchId, - count, - index, - mode, - orderKey: `${batchId}:${String(index).padStart(4, "0")}`, - }); - if (!replacesProgressMessage) { - const attachmentPayload: SafeChatPublicationPayload = { - ...currentPayload, - transportPart: transportPart(0, 1, "discord_markdown_attachment"), - }; - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: attachmentPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - return { ...current, payload: attachmentPayload, updatedAt }; - } - - const handoffPayload: SafeChatPublicationPayload = { - ...currentPayload, - text: "This response needs a separate attachment because it exceeds the message limit.", - transportPart: transportPart(0, 2, "inline"), - }; - delete handoffPayload.attachmentIds; - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: handoffPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - - const attachmentPayload: SafeChatPublicationPayload = { - ...currentPayload, - transportPart: transportPart(1, 2, "discord_markdown_attachment"), - }; - delete attachmentPayload.progressState; - await notifyChatPublicationWork(tx); - await tx.insert(chatPublications).values({ - companyId: current.companyId, - endpointId: current.endpointId, - conversationId: current.conversationId, - issueId: current.issueId, - commentId: current.commentId, - idempotencyKey: `discord-markdown-attachment:${current.id}`, - payload: attachmentPayload, - state: "pending", - createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, - updatedAt, - }); - return { ...current, payload: handoffPayload, updatedAt }; - }); - } - - if (provider !== "telegram") return publication; - if ( - persisted.transportPart || - persisted.card || - persisted.interactionId || - persisted.attachmentIds?.length - ) { - return publication; - } - if (telegramMarkdownRequiresAttachment(persisted.text)) { - // Telegram parses each message independently. A fixed-size split can - // turn the second half of a code fence, link, or list into unrelated - // plain text even though concatenating the source parts is lossless. - // Preserve structured long Markdown as one document. When this is a run - // final, first complete its existing progress placeholder, then send the - // file through a separate ordered outbox row because Telegram cannot add - // an attachment while editing that placeholder. - const replacesProgressMessage = Boolean( - await runPublicationToReplace(publication, persisted), - ); - return db.transaction(async (tx) => { - const current = await tx - .select() - .from(chatPublications) - .where(eq(chatPublications.id, publication.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) return publication; - const currentPayload = current.payload as SafeChatPublicationPayload; - if ( - currentPayload.transportPart || - currentPayload.card || - currentPayload.interactionId || - currentPayload.attachmentIds?.length || - !["pending", "retry"].includes(current.state) || - !telegramMarkdownRequiresAttachment(currentPayload.text) - ) { - return current; - } - - const batchId = current.id; - const updatedAt = new Date(); - const transportPart = ( - index: number, - count: number, - mode: "inline" | "telegram_markdown_attachment", - ) => ({ - batchId, - count, - index, - mode, - orderKey: `${batchId}:${String(index).padStart(4, "0")}`, - }); - if (!replacesProgressMessage) { - const attachmentPayload: SafeChatPublicationPayload = { - ...currentPayload, - transportPart: transportPart(0, 1, "telegram_markdown_attachment"), - }; - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: attachmentPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - return { ...current, payload: attachmentPayload, updatedAt }; - } - - const handoffPayload: SafeChatPublicationPayload = { - ...currentPayload, - text: "This response needs a separate attachment because it exceeds the message limit.", - transportPart: transportPart(0, 2, "inline"), - }; - delete handoffPayload.attachmentIds; - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: handoffPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - - const attachmentPayload: SafeChatPublicationPayload = { - ...currentPayload, - transportPart: transportPart(1, 2, "telegram_markdown_attachment"), - }; - delete attachmentPayload.progressState; - await notifyChatPublicationWork(tx); - await tx.insert(chatPublications).values({ - companyId: current.companyId, - endpointId: current.endpointId, - conversationId: current.conversationId, - issueId: current.issueId, - commentId: current.commentId, - idempotencyKey: `telegram-markdown-attachment:${current.id}`, - payload: attachmentPayload, - state: "pending", - createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, - updatedAt, - }); - return { ...current, payload: handoffPayload, updatedAt }; - }); - } - const parts = splitTelegramPublicationText(persisted.text); - if (parts.length === 1) return publication; - - return db.transaction(async (tx) => { - const current = await tx - .select() - .from(chatPublications) - .where(eq(chatPublications.id, publication.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) return publication; - const currentPayload = current.payload as SafeChatPublicationPayload; - if ( - currentPayload.transportPart || - currentPayload.card || - currentPayload.interactionId || - currentPayload.attachmentIds?.length || - !["pending", "retry"].includes(current.state) - ) { - return current; - } - const currentParts = splitTelegramPublicationText(currentPayload.text); - if (currentParts.length === 1) return current; - - const batchId = current.id; - const updatedAt = new Date(); - const transportPart = (index: number) => ({ - batchId, - count: currentParts.length, - index, - orderKey: `${batchId}:${String(index).padStart(4, "0")}`, - }); - const rootPayload: SafeChatPublicationPayload = { - ...currentPayload, - text: currentParts[0]!, - transportPart: transportPart(0), - }; - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ payload: rootPayload, updatedAt }) - .where(eq(chatPublications.id, current.id)); - - const { progressState: _progressState, ...nonProgressPayload } = - currentPayload; - await notifyChatPublicationWork(tx); - await tx.insert(chatPublications).values( - currentParts.slice(1).map((text, offset) => { - const index = offset + 1; - return { - companyId: current.companyId, - endpointId: current.endpointId, - conversationId: current.conversationId, - issueId: current.issueId, - commentId: current.commentId, - idempotencyKey: `${current.idempotencyKey}:telegram-part:${index}`, - payload: { - ...nonProgressPayload, - text, - transportPart: transportPart(index), - }, - state: "pending" as const, - createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, - updatedAt, - }; - }), - ); - return { ...current, payload: rootPayload, updatedAt }; - }); - } - - async function settlePublicationPreparationFailure( - publication: typeof chatPublications.$inferSelect, - error: unknown, - ): Promise { - const attempts = publication.attempts + 1; - const classified = classifyChatPublicationError(error, attempts); - // No provider I/O has started at this boundary. Unknown transport or - // database failures are therefore safe to retry; only a definite local - // validation rejection (or repeated exhaustion) is terminal. - const terminal = classified.kind === "failed" || attempts >= 5; - const retryAfterMs = - classified.kind === "retry" - ? classified.retryAfterMs - : Math.min(60_000, 2 ** Math.max(0, attempts) * 1_000); - const result = await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - const [settled] = await tx - .update(chatPublications) - .set({ - state: terminal ? "failed" : "retry", - attempts, - nextAttemptAt: terminal ? null : new Date(Date.now() + retryAfterMs), - redactedError: terminal - ? "Publication payload could not be prepared for provider delivery" - : "Publication preparation failed before provider delivery", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, publication.state), - eq(chatPublications.attempts, publication.attempts), - ), - ) - .returning({ id: chatPublications.id }); - if (!settled) return false; - if (terminal && publication.idempotencyKey.startsWith("control:")) { - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: "task_control_publication_invalid", - attempts, - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "issued"), - ), - ); - } - return true; - }); - if (result) { - logger.warn( - { - endpointId: publication.endpointId, - publicationId: publication.id, - error: redactError(error), - terminal, - }, - "chat publication preparation failed", - ); - } - return result; - } - - async function withPublicationAttemptLease( - publication: typeof chatPublications.$inferSelect, - operation: (lease: CredentialMutationLeaseGuard) => Promise, - ): Promise { - // A scheduled sweep may overlap a slow provider call (also on a standby - // server). Claim and exact-attempt ownership must become visible together: - // endpoint-wide activity is not evidence that this publication is alive. - const token = randomUUID(); - const leaseKey = `publication:${publication.id}:${publication.attempts + 1}`; - const now = new Date(); - const owned = and( - eq(chatEndpointLeases.companyId, publication.companyId), - eq(chatEndpointLeases.endpointId, publication.endpointId), - eq(chatEndpointLeases.leaseKey, leaseKey), - eq(chatEndpointLeases.token, token), - ); - const claimed = await db.transaction(async (tx) => { - // Transfer issuance locks this same publication. Check after acquiring - // that lock, not through a pre-lock snapshot that could miss a new lane. - const current = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, publication.companyId), - eq(chatPublications.id, publication.id), - eq(chatPublications.state, publication.state), - eq(chatPublications.attempts, publication.attempts), - ), - ) - .for("update") - .then((rows) => rows[0]); - if (!current) return false; - const transfer = await tx - .select({ id: chatTeamsFileTransfers.id }) - .from(chatTeamsFileTransfers) - .where( - and( - eq(chatTeamsFileTransfers.companyId, publication.companyId), - eq(chatTeamsFileTransfers.publicationId, current.id), - ), - ) - .then((rows) => rows[0]); - if (transfer) return false; - await notifyChatPublicationWork(tx); - const [row] = await tx - .update(chatPublications) - .set({ - state: "streaming", - attempts: publication.attempts + 1, - updatedAt: now, - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, publication.state), - eq(chatPublications.attempts, publication.attempts), - ), - ) - .returning({ id: chatPublications.id }); - if (!row) return false; - await tx.insert(chatEndpointLeases).values({ - companyId: publication.companyId, - endpointId: publication.endpointId, - leaseKey, - token, - expiresAt: new Date(now.getTime() + CREDENTIAL_MUTATION_LEASE_TTL_MS), - }); - return true; - }); - if (!claimed) return; - let leaseLoss: Error | null = null; - const assertOwned: CredentialMutationLeaseGuard["assertOwned"] = async ( - database = db, - ) => { - if (leaseLoss) throw leaseLoss; - try { - // Use a savepoint when the caller already owns a transaction. The - // decision clock is sampled only after the lease row lock resolves; - // an UPDATE predicate using a pre-lock clock can revive expired work. - await database.transaction(async (tx) => { - const current = await tx - .select({ expiresAt: chatEndpointLeases.expiresAt }) - .from(chatEndpointLeases) - .where(owned) - .for("update") - .then((rows) => rows[0] ?? null); - const checkedAt = new Date(); - if ( - leaseLoss || - !current || - current.expiresAt.getTime() <= checkedAt.getTime() - ) { - throw new Error("Publication attempt lease is no longer owned"); - } - await tx - .update(chatEndpointLeases) - .set({ - expiresAt: new Date( - checkedAt.getTime() + CREDENTIAL_MUTATION_LEASE_TTL_MS, - ), - updatedAt: checkedAt, - }) - .where(owned); - }); - } catch (error) { - leaseLoss = Object.assign( - new Error( - "Chat publication attempt ownership was lost before completion", - { cause: error }, - ), - { code: "CHAT_PUBLICATION_LEASE_LOST" }, - ); - throw leaseLoss; - } - }; - let renewal: Promise | null = null; - const timer = setInterval(() => { - if (renewal || leaseLoss) return; - renewal = assertOwned() - .catch(() => undefined) - .finally(() => { - renewal = null; - }); - }, CREDENTIAL_MUTATION_LEASE_TTL_MS / 3); - timer.unref?.(); - try { - await operation({ assertOwned }); - } finally { - clearInterval(timer); - await renewal; - await db.delete(chatEndpointLeases).where(owned); - } - } - - async function processSelectedPublication( - selectedPublication: typeof chatPublications.$inferSelect, - ): Promise { - if (await settleGitHubAutomaticPublication(selectedPublication)) return; - let publication: typeof chatPublications.$inferSelect; - try { - publication = - await ensureDurablePublicationTransport(selectedPublication); - } catch (error) { - try { - await settlePublicationPreparationFailure(selectedPublication, error); - } catch (settlementError) { - logger.error( - { - endpointId: selectedPublication.endpointId, - publicationId: selectedPublication.id, - error: redactError(settlementError), - }, - "could not durably settle chat publication preparation failure", - ); - } - return; - } - const explicitSlackDelivery = await slackExplicitPublicationDuplicate(db, publication); - if (explicitSlackDelivery === "unresolved") { - // Keep the durable publication pending until the explicit send is settled; - // a missing provider receipt does not authorize a duplicate. - return; - } - if (explicitSlackDelivery === "delivered") { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx.update(chatPublications).set({ state: "cancelled", redactedError: "Identical response already delivered by Slack tool", updatedAt: new Date() }).where(and(eq(chatPublications.id, publication.id), inArray(chatPublications.state, ["pending", "retry"]))) -; - }); - return; - } - const earlierOpenPublication = await db - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.conversationId, publication.conversationId), - or( - lt(chatPublications.createdAt, publication.createdAt), - and( - eq(chatPublications.createdAt, publication.createdAt), - lt( - publicationTransportOrderKey(chatPublications), - publication.payload.transportPart?.orderKey ?? publication.id, - ), - ), - ), - inArray(chatPublications.state, [ - "pending", - "retry", - "streaming", - "delivery_unknown", - ]), - ), - ) - .limit(1) - .then((result) => result[0] ?? null); - if (earlierOpenPublication) return; - if (await tryProcessTeamsFilePublication(publication)) return; - await withPublicationAttemptLease(publication, async (publicationLease) => { - let providerAccepted = false; - let publicationRuntimeContext: RuntimeContext | null = null; - let failureHandledWithinCredentialLease = false; - let slackFileReceiptActionId: string | null = null; - const settlePublicationFailure = async ( - error: unknown, - credentialLease?: CredentialMutationLeaseGuard, - ) => { - try { - await finalizePublicationFailure( - publication, - error, - providerAccepted, - publicationRuntimeContext, - credentialLease ?? publicationLease, - ); - } catch (finalizationError) { - // If the provider accepted the message, a failed receipt write - // must remain streaming until the stale scan quarantines it. A - // later lifecycle mutation must not turn this into an implicit - // replay or allow a stale failure to overwrite the new runtime. - logger.error( - { - endpointId: publication.endpointId, - publicationId: publication.id, - error: redactError(finalizationError), - }, - "could not durably finalize chat publication failure", - ); - } - }; - try { - const recordForLease = await endpointRecord(publication.endpointId); - if (!recordForLease) - throw new Error("Chat publication binding is unavailable"); - const currentPublicationRuntimeContext = - runtimeContextForRecord(recordForLease); - publicationRuntimeContext = currentPublicationRuntimeContext; - const [endpoint, conversation] = await Promise.all([ - db - .select() - .from(chatEndpoints) - .where(eq(chatEndpoints.id, publication.endpointId)) - .then((result) => result[0] ?? null), - db - .select() - .from(chatConversations) - .where(eq(chatConversations.id, publication.conversationId)) - .then((result) => result[0] ?? null), - ]); - if (!endpoint || !conversation) - throw new Error("Chat publication binding is unavailable"); - const providerVisibleCompletion = - conversation.state === "completed" && - !isExplicitOperatorPublication(publication) && - (await hasCommittedTaskControlCompletion(conversation.id)); - if (endpoint.status === "paused" || endpoint.status === "attention") { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "pending", - attempts: publication.attempts, - // Pause can race the eligibility read. The query excludes - // inactive endpoints on the next pass, so retain only the - // original provider deadline rather than delaying Resume. - nextAttemptAt: publication.nextAttemptAt, - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - // The setup conversation is a real end-to-end test: once provider - // credentials are verified, its safe agent response must be able to - // reach the provider before the operator confirms the final wizard - // step. Draft, paused, revoked, and archived endpoints remain closed. - if ( - !["active", "verifying"].includes(endpoint.status) || - ["unavailable", "endpoint_removed"].includes(conversation.state) || - providerVisibleCompletion - ) { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "cancelled", - redactedError: providerVisibleCompletion - ? "Conversation was completed before delivery" - : "External destination is no longer active", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - if (conversation.isDirectMessage) { - if (!endpoint.allowDirectMessages) { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "cancelled", - redactedError: "Direct messages are disabled in Paperclip", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - } else { - const resource = conversation.resourceId - ? await db - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.companyId, endpoint.companyId), - ), - ) - .then((result) => result[0] ?? null) - : null; - if (!nonDirectDestinationAllowed(endpoint, resource)) { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "cancelled", - redactedError: "Destination is disabled in Paperclip", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - } - const interactionId = publication.payload.interactionId; - const isInteractionPrompt = - Boolean(interactionId) && - publication.idempotencyKey === - `interaction:${interactionId}:${publication.endpointId}`; - if (isInteractionPrompt && publication.issueId) { - const currentInteraction = await db - .select({ status: issueThreadInteractions.status }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.id, interactionId!), - eq(issueThreadInteractions.companyId, publication.companyId), - eq(issueThreadInteractions.issueId, publication.issueId), - ), - ) - .then((result) => result[0] ?? null); - if (!currentInteraction || currentInteraction.status !== "pending") { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "cancelled", - attempts: publication.attempts, - nextAttemptAt: null, - redactedError: "Interaction resolved before provider delivery", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - } - // Milestone selection and insertion can overlap across sweeps. An old - // snapshot inserted after newer attention must not overwrite it simply - // because it now occupies the next FIFO slot. - const progressSupersessionReason = - (await runOwnershipMilestoneSupersessionReason(publication)) ?? - ((await progressSupersededByFastResponse(publication)) ? "Acknowledged by contextual fast response" : null) ?? - ((await runProgressSupersededByPublishedInteraction(publication)) - ? "Run progress was superseded by its provider interaction" - : null); - if (progressSupersessionReason) { - await publicationLease.assertOwned(); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "cancelled", - attempts: publication.attempts, - nextAttemptAt: null, - redactedError: progressSupersessionReason, - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - }); - return; - } - // Status is sampled when it reaches the head of the provider lane, - // not when the command was admitted. If an already-streaming final - // publication won the race, this reply reflects Paperclip's latest - // authoritative task state after that earlier send commits. - let payload = await currentTaskControlPayload(publication); - await withCredentialMutationLease( - recordForLease.endpoint, - async (credentialGuard) => { - const credentialLease: CredentialMutationLeaseGuard = { - assertOwned: async (database = db) => { - await credentialGuard.assertOwned(database); - await publicationLease.assertOwned(database); - }, - }; - try { - if (await settleGitHubAutomaticPublication(publication, credentialLease)) return; - // A prior failure's provider ID may have been reused while this - // worker waited for the endpoint lane. Select against current - // outbound links only after owning that lane, before the final - // source authorization claim and provider I/O. - const closedProgress = await closeProgressToReplace( - publication, - endpoint, - ); - const replaceProviderMessageId = endpoint.provider !== "imessage-photon" && CAPABILITIES[endpoint.provider] - .messageEdits - ? (closedProgress?.providerMessageId ?? - (await interactionResolutionPublicationToReplace( - publication, - payload, - )) ?? - (await interactionPromptPublicationToReplace( - publication, - payload, - )) ?? - (await runPublicationToReplace(publication, payload)) ?? - (await inboundWakePublicationToReplace( - publication, - payload, - )) ?? - (await taskStatusPublicationToReplace(publication, payload))) - : null; - const authorizationClaim = - await claimPublicationTransportAuthorization({ - credentialLease, - endpoint: recordForLease.endpoint, - publication, - runtimeContext: currentPublicationRuntimeContext, - }); - if (!authorizationClaim) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - state: "cancelled", - nextAttemptAt: null, - redactedError: - "Task control requester or destination is no longer authorized", - updatedAt: new Date(), - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: "task_control_authorization_changed", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "issued"), - ), - ); - await credentialLease.assertOwned(tx); - }); - return; - } - // The durable claim above is the authorization linearization - // point. Provider I/O runs without a database transaction or - // row lock. The renewable lease preserves credential/runtime - // identity until conditional settlement. - if (authorizationClaim.preparedText !== null) - payload = { ...payload, text: authorizationClaim.preparedText }; - const telegramDraft = - authorizationClaim.endpoint.provider === "telegram" && - authorizationClaim.conversation.isDirectMessage && - !replaceProviderMessageId && - !payload.transportPart && - !payload.card && - !payload.attachmentIds?.length && - shouldStreamSafePublicationText( - renderPublicationTransportText(payload), - ) && - (await hasTelegramStopSubscription( - authorizationClaim.endpoint, - currentPublicationRuntimeContext, - )) - ? await reserveTelegramPublicationDraft({ - endpoint: authorizationClaim.endpoint, - conversation: authorizationClaim.conversation, - publication, - text: renderPublicationTransportText(payload), - credentialLease, - runtimeContext: currentPublicationRuntimeContext, - }) - : null; - const sent = await postSafePublication({ - endpoint: authorizationClaim.endpoint, - conversation: authorizationClaim.conversation, - publication, - payload, - replaceProviderMessageId, - telegramDraftControl: telegramDraft?.control, - beforePhotonWrite: () => db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - if (!(await authorizeRetainedChatSourcePublication(tx, publication))) throw new PhotonError("rejected", "Publication source authorization changed"); - }), - onSlackFileUploadAccepted: async (receipt) => { - // uploadV2 has completed at this point. Mark acceptance - // before the durable callback so a local write failure is - // still quarantined and can never trigger an implicit upload. - providerAccepted = true; - slackFileReceiptActionId = await recordSlackFileUploadReceipt( - { - credentialLease, - endpoint: authorizationClaim.endpoint, - conversation: authorizationClaim.conversation, - publication, - receipt, - runtimeContext: currentPublicationRuntimeContext, - }, - ); - }, - }); - if (isTelegramDraftStopped(sent)) { - // Only the durable stop transaction can produce this outcome. - // It settled this exact publication; never fabricate a provider - // receipt, complete its task, or enqueue a retry from this path. - if (!telegramDraft) - throw new Error("Unexpected Telegram draft stop result"); - const stopped = await db - .select({ id: chatActions.id }) - .from(chatActions) - .innerJoin( - chatPublications, - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "cancelled"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ) - .where( - and( - eq(chatActions.id, telegramDraft.actionId), - eq(chatActions.status, "cancelled"), - sql`${chatActions.result}->>'phase' = 'stopped'`, - ), - ); - if (stopped.length !== 1) - throw new Error( - "Telegram draft stop was not durably committed", - ); - return; - } - providerAccepted = true; - // A resolved SDK call is not a usable delivery receipt. Teams, - // for example, returns an empty ID for an empty HTTP success - // body. Preserve uncertainty instead of publishing a broken - // message link or automatically sending the content again. - if ( - typeof sent?.id !== "string" || - !sent.id || - sent.id.length > 2048 || - sent.id.trim() !== sent.id || - /[\u0000-\u001f\u007f]/.test(sent.id) - ) { - throw new Error( - "Provider did not return a usable message receipt", - ); - } - const { authorizationActionId } = authorizationClaim; - const receiptRemovalActionIds: string[] = []; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const committedAt = new Date(); - await notifyChatPublicationWork(tx); - const [completedPublication] = await tx - .update(chatPublications) - .set({ - state: "published", - payload, - providerMessageId: sent.id, - publishedAt: committedAt, - redactedError: null, - updatedAt: committedAt, - }) - .where( - and( - eq(chatPublications.id, publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publication.attempts + 1), - ), - ) - .returning({ id: chatPublications.id }); - if (!completedPublication) { - throw new Error( - "Chat publication ownership changed before commit", - ); - } - if (telegramDraft) { - const completedDraft = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - phase: "published", - providerMessageId: sent.id, - }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, telegramDraft.actionId), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'phase' = 'final_sending'`, - ), - ) - .returning({ id: chatActions.id }); - if (completedDraft.length !== 1) - throw new Error( - "Telegram final draft ownership changed before commit", - ); - } - if (slackFileReceiptActionId) { - const [completedReceipt] = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - code: "slack_file_upload_identity_confirmed", - attempts: 1, - }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, slackFileReceiptActionId), - eq(chatActions.kind, "slack_file_upload_receipt"), - eq(chatActions.status, "received"), - sql`(${chatActions.payload}->>'publicationId')::uuid = ${publication.id}`, - sql`(${chatActions.payload}->>'publicationAttempt')::int = ${publication.attempts + 1}`, - ), - ) - .returning({ id: chatActions.id }); - if (!completedReceipt) { - throw new Error( - "Slack file upload receipt ownership changed before commit", - ); - } - } - if (slackFileReceiptActionId) { - await stageSlackFilePublicationSuccess(tx, { - committedAt, - endpoint: authorizationClaim.endpoint, - publication, - providerMessageId: sent.id, - runtimeContext: currentPublicationRuntimeContext, - stageSessionSync: - !isExplicitOperatorPublication(publication) && - !publication.idempotencyKey.startsWith("control:status:"), - }); - } else { - // A queue notice references its causal inbound comment for - // authorization, but is not an authored copy of that comment. - // Reactions/readers must not attribute bot status to the user. - const linkedCommentId = parseInboundWakePublicationKey( - publication.idempotencyKey, - ) - ? null - : publication.commentId; - const messageLinkInsert = tx.insert(chatMessageLinks).values({ - companyId: publication.companyId, - endpointId: publication.endpointId, - conversationId: publication.conversationId, - publicationId: publication.id, - commentId: linkedCommentId, - providerMessageId: sent.id, - direction: "outbound", - }); - if (replaceProviderMessageId) { - await messageLinkInsert.onConflictDoUpdate({ - target: [ - chatMessageLinks.endpointId, - chatMessageLinks.conversationId, - chatMessageLinks.providerMessageId, - ], - set: { - publicationId: publication.id, - commentId: linkedCommentId, - }, - }); - } else { - await messageLinkInsert.onConflictDoNothing(); - } - await tx - .update(chatEndpoints) - .set({ - lastPublicationAt: committedAt, - updatedAt: committedAt, - }) - .where( - and( - eq(chatEndpoints.id, authorizationClaim.endpoint.id), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${currentPublicationRuntimeContext.generation}`, - ), - ); - } - if (authorizationActionId) { - const processedAuthorization = await tx - .update(chatActions) - .set({ - status: "processed", - result: { code: "task_control_authorized_and_sent" }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, authorizationActionId), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${publication.attempts + 1}`, - ), - ) - .returning({ id: chatActions.id }); - if (!processedAuthorization.length) { - throw new Error( - "Task-control authorization ownership changed before commit", - ); - } - } - await commitTaskControlCompletion( - tx as unknown as Db, - publication, - committedAt, - ); - if ( - !slackFileReceiptActionId && - authorizationClaim.endpoint.provider === "slack" && - !isExplicitOperatorPublication(publication) && - !parseInboundWakePublicationKey(publication.idempotencyKey) && - !publication.idempotencyKey.startsWith("control:status:") - ) { - await stageSlackSessionSync(tx, { - companyId: publication.companyId, - endpointId: publication.endpointId, - conversationId: publication.conversationId, - runtimeGeneration: - currentPublicationRuntimeContext.generation, - credentialFingerprint: - currentPublicationRuntimeContext.credentialFingerprint, - }); - } - receiptRemovalActionIds.push( - ...(await stageTerminalReceiptReactionRemovals( - tx as unknown as Db, - { - endpoint: authorizationClaim.endpoint, - publication, - payload, - runtimeContext: currentPublicationRuntimeContext, - closedProgressRunId: closedProgress?.runId, - }, - )), - ); - await credentialLease.assertOwned(tx); - }); - // Receipt cleanup is non-critical provider I/O. Dispatch it - // after the terminal reply and durable action commit so a - // slow reaction endpoint cannot hold the publication lane. - // If this process stops first, the action outbox resumes the - // idempotent removal on a later reconciliation sweep. - for (const actionId of receiptRemovalActionIds) { - scheduleMessageProcessing(() => - processReceiptReaction(actionId), - ); - } - } catch (error) { - // Authentication, membership, and destination failures mutate - // endpoint/runtime state. Settle them before releasing the - // credential lease so a concurrent reconnect or provider - // recovery cannot be overwritten by this stale attempt. - failureHandledWithinCredentialLease = true; - // If ownership was already lost, leave the publication in its - // conservative streaming state for quarantine. Mutating health - // from this obsolete runtime would overwrite the new owner. - await settlePublicationFailure(error, credentialLease); - } - }, - ); - } catch (error) { - if (!failureHandledWithinCredentialLease) - await settlePublicationFailure(error); - } - }); - } - - function publicationEligibility({ now, busyEndpointIds = [], coolingTeamsPublicationIds = [], attemptedIds = [] }: { now?: Date; busyEndpointIds?: string[]; coolingTeamsPublicationIds?: string[]; attemptedIds?: string[] } = {}) { - const earlierPublication = alias(chatPublications, "earlier_chat_publications"); - return and( - sql`not (${unresolvedSlackPublicationCondition()})`, - // An explicit Board send promises both delivery and work. Keep - // its text/files pending until the durable wake has been accepted. - sql`not exists (select 1 from chat_actions a where a.company_id = ${chatPublications.companyId} and a.endpoint_id = ${chatPublications.endpointId} and a.conversation_id = ${chatPublications.conversationId} and a.kind = 'slack_board_message' and a.status = 'received' and a.payload->>'commentId' = ${chatPublications.commentId}::text)`, - or( - and( - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`, - inArray(chatPublications.state, ["pending", "retry"]), - notExists( - db - .select({ id: chatTeamsFileTransfers.id }) - .from(chatTeamsFileTransfers) - .where( - and( - eq( - chatTeamsFileTransfers.companyId, - chatPublications.companyId, - ), - eq( - chatTeamsFileTransfers.publicationId, - chatPublications.id, - ), - ), - ), - ), - ), - // Dedicated protocol work shares the endpoint concurrency cap, - // never the ordinary publication claim or resend implementation. - sql`exists (${db - .select({ id: chatTeamsFileTransfers.id }) - .from(chatTeamsFileTransfers) - .where( - and( - eq( - chatTeamsFileTransfers.companyId, - chatPublications.companyId, - ), - eq( - chatTeamsFileTransfers.endpointId, - chatPublications.endpointId, - ), - eq( - chatTeamsFileTransfers.conversationId, - chatPublications.conversationId, - ), - eq( - chatTeamsFileTransfers.publicationId, - chatPublications.id, - ), - sql`exists (${db - .select({ id: chatEndpoints.id }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.id, chatPublications.endpointId), - eq( - chatEndpoints.companyId, - chatPublications.companyId, - ), - eq(chatEndpoints.provider, "microsoft-teams"), - eq(chatEndpoints.status, "active"), - ), - )})`, - or( - inArray(chatTeamsFileTransfers.phase, [ - "consent_pending", - "upload_pending", - "file_info_pending", - ]), - and( - eq(chatTeamsFileTransfers.phase, "consent_unknown"), - isNotNull(chatTeamsFileTransfers.responseActivityId), - sql`${chatTeamsFileTransfers.privateState}->'response' is not null`, - ), - ), - ), - )})`, - ), - busyEndpointIds.length > 0 - ? notInArray(chatPublications.endpointId, busyEndpointIds) - : undefined, - coolingTeamsPublicationIds.length > 0 - ? notInArray(chatPublications.id, coolingTeamsPublicationIds) - : undefined, - // Inactive endpoints must not consume the eligibility page or - // acquire an artificial retry deadline. When an operator resumes - // them, due work is immediately eligible; genuine provider retry - // deadlines below remain unchanged. - notExists( - db - .select({ id: chatEndpoints.id }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.id, chatPublications.endpointId), - inArray(chatEndpoints.status, ["paused", "attention"]), - ), - ), - ), - now ? or(isNull(chatPublications.nextAttemptAt), lte(chatPublications.nextAttemptAt, now)) : undefined, - attemptedIds.length > 0 - ? notInArray(chatPublications.id, attemptedIds) - : undefined, - notExists( - db - .select({ id: earlierPublication.id }) - .from(earlierPublication) - .where( - and( - eq( - earlierPublication.conversationId, - chatPublications.conversationId, - ), - publicationPrecedes(earlierPublication, chatPublications), - inArray(earlierPublication.state, [ - "pending", - "retry", - "streaming", - "delivery_unknown", - ]), - ), - ), - ), - ); - } - - async function processPendingPublications( - limit = 25, - { waitForCompletion = true, maintenance = true }: { waitForCompletion?: boolean; maintenance?: boolean } = {}, - ) { - if (shuttingDown) return 0; - if (maintenance) { - await enqueueInboundWakeupPublications(); - await reconcileTerminalConfirmationActions(limit); - } - const now = new Date(); - const staleBefore = new Date(now.getTime() - 60_000); - // A process that disappears after the provider accepted a post but before - // Paperclip persisted its message id leaves an ambiguous delivery. Never - // resend it automatically; an operator can explicitly replay after - // checking the provider conversation. - const quarantinedPublications = await db.transaction(async tx => { - const condition = and( - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`, - eq(chatPublications.state, "streaming"), - lte(chatPublications.updatedAt, staleBefore), - // Teams owns separate staged I/O intents and a longer attempt lease. - // Only that protocol may recover/quarantine its in-flight stages. - notExists( - db - .select({ id: chatTeamsFileTransfers.id }) - .from(chatTeamsFileTransfers) - .where( - and( - eq( - chatTeamsFileTransfers.companyId, - chatPublications.companyId, - ), - eq(chatTeamsFileTransfers.publicationId, chatPublications.id), - ), - ), - ), - notExists( - db - .select({ id: chatEndpointLeases.id }) - .from(chatEndpointLeases) - .where( - and( - eq(chatEndpointLeases.companyId, chatPublications.companyId), - eq( - chatEndpointLeases.endpointId, - chatPublications.endpointId, - ), - sql`${chatEndpointLeases.leaseKey} = 'publication:' || ${chatPublications.id}::text || ':' || ${chatPublications.attempts}::text`, - gt(chatEndpointLeases.expiresAt, now), - ), - ), - ), - ); - const candidates = await tx.select({ id: chatPublications.id }).from(chatPublications).where(condition).for("update", { skipLocked: true }); - if (!candidates.length) return []; - await notifyChatPublicationWork(tx); - return tx - .update(chatPublications) - .set({ - state: "delivery_unknown", - nextAttemptAt: null, - redactedError: - "Provider delivery could not be confirmed after the worker stopped. Check the external conversation before replaying.", - updatedAt: now, - }) - .where( - and(condition, inArray(chatPublications.id, candidates.map(row => row.id))), - ) - .returning({ id: chatPublications.id }); - }); - if (quarantinedPublications.length > 0) { - await db - .update(chatActions) - .set({ - status: "delivery_unknown", - result: { - code: "task_control_delivery_unknown", - retryable: false, - }, - updatedAt: now, - }) - .where( - and( - eq(chatActions.kind, "task_control_authorization"), - eq(chatActions.status, "processing"), - inArray( - chatActions.providerActionId, - quarantinedPublications.map( - (publication) => `task-control-authorization:${publication.id}`, - ), - ), - ), - ); - } - const attemptedIds: string[] = []; - const ownedTasks = new Set>(); - let failed = false; - let firstError: unknown; - try { - while (attemptedIds.length < limit && !failed && !shuttingDown) { - if (publicationEndpointTasks.size >= PUBLICATION_ENDPOINT_CONCURRENCY) { - if (!waitForCompletion) break; - await Promise.race(publicationEndpointTasks.values()); - continue; - } - const busyEndpointIds = [...publicationEndpointTasks.keys()]; - for (const [id, retryAfter] of teamsFileRetryAfter) - if (retryAfter <= Date.now()) teamsFileRetryAfter.delete(id); - const coolingTeamsPublicationIds = [...teamsFileRetryAfter.keys()]; - // Select only each conversation's current head before applying the - // global limit. Re-query after every batch so one invocation can still - // drain a conversation's newly unblocked milestones in FIFO order. - const rows = await db - .select() - .from(chatPublications) - .where( - publicationEligibility({ now, busyEndpointIds, coolingTeamsPublicationIds, attemptedIds }), - ) - .orderBy( - asc(chatPublications.createdAt), - asc(publicationTransportOrderKey(chatPublications)), - ) - .limit(limit - attemptedIds.length); - if (rows.length === 0) { - // The query excluded the busy-endpoint snapshot above. A worker - // may have completed while PostgreSQL selected this empty page, - // exposing a new head that must be queried before declaring idle. - if (busyEndpointIds.some((id) => !publicationEndpointTasks.has(id))) - continue; - if (!waitForCompletion || publicationEndpointTasks.size === 0) break; - await Promise.race(publicationEndpointTasks.values()); - continue; - } - let started = false; - for (const selectedPublication of rows) { - if ( - publicationEndpointTasks.size >= PUBLICATION_ENDPOINT_CONCURRENCY || - attemptedIds.length >= limit || - failed || - shuttingDown - ) - break; - if (publicationEndpointTasks.has(selectedPublication.endpointId)) - continue; - attemptedIds.push(selectedPublication.id); - started = true; - // Reserve synchronously before provider work; overlapping drains share - // this cap. Different conversations for one bot keep the existing - // endpoint-exclusive credential fence instead of timing out behind it. - const task = Promise.resolve() - .then(async () => { - await processSelectedPublication(selectedPublication); - await settleExternalConversation(db, selectedPublication.companyId, selectedPublication.issueId).catch((err) => { - logger.warn({ err, publicationId: selectedPublication.id }, "Slack conversation settlement deferred to reconciliation"); - }); - }) - .catch((error: unknown) => { - if (!failed) { - failed = true; - firstError = error; - } - if (!waitForCompletion) { - logger.error( - { - endpointId: selectedPublication.endpointId, - publicationId: selectedPublication.id, - error: redactError(error), - }, - "scheduled chat publication worker failed", - ); - } - }) - .finally(() => { - if ( - publicationEndpointTasks.get(selectedPublication.endpointId) === - task - ) { - publicationEndpointTasks.delete(selectedPublication.endpointId); - } - ownedTasks.delete(task); - wakePublications(); - }); - publicationEndpointTasks.set(selectedPublication.endpointId, task); - ownedTasks.add(task); - } - // Re-query while slots remain: one busy endpoint must not fill the - // candidate page and hide later eligible endpoints behind its backlog. - if (!started && publicationEndpointTasks.size > 0) { - if (!waitForCompletion) break; - await Promise.race(publicationEndpointTasks.values()); - } - } - } catch (error) { - if (!failed) { - failed = true; - firstError = error; - } - } finally { - // Observe rejection immediately above and join every started worker - // before this invocation returns or propagates its original failure. - if (waitForCompletion) await Promise.all(ownedTasks); - } - if (failed) throw firstError; - if (maintenance) await processPendingInteractionWakeups(limit); - return attemptedIds.length; - } let githubMaintenancePending = false; function scheduleGitHubMaintenance() { @@ -18879,60 +7582,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { let wakePublications = () => {}; - async function nextInboundDeliveryAt(): Promise { - const rows = await db.select({ delivery: { - id: chatDeliveries.id, endpointId: chatDeliveries.endpointId, providerEventId: chatDeliveries.providerEventId, - state: chatDeliveries.state, eventKind: chatDeliveries.eventKind, - // Retain scheduling metadata only, not message bodies or attachment data. - normalizedEvent: sql`jsonb_build_object( - 'conversation', ${chatDeliveries.normalizedEvent}->'conversation', - 'lifecycle', ${chatDeliveries.normalizedEvent}->'lifecycle', - 'message', jsonb_build_object( - 'targetProviderEventId', ${chatDeliveries.normalizedEvent}->'message'->'targetProviderEventId', - 'admissionDependencyEventId', ${chatDeliveries.normalizedEvent}->'message'->'admissionDependencyEventId'))`, - }, at: sql`case - when ${chatDeliveries.state} = 'processing' then ${chatDeliveries.updatedAt} + ${DELIVERY_PROCESSING_STALE_MS} * interval '1 millisecond' - when ${chatDeliveries.state} = 'processed' then ( - select min(case when a.status = 'processing' then a.updated_at + ${DELIVERY_PROCESSING_STALE_MS} * interval '1 millisecond' - else coalesce((a.result->>'retryAt')::timestamptz, now()) end) - from chat_actions a where a.delivery_id = chat_deliveries.id - and a.company_id = chat_deliveries.company_id and a.endpoint_id = chat_deliveries.endpoint_id - and a.kind = 'inbound_wakeup' and a.status in ('issued', 'processing')) - else coalesce(${chatDeliveries.nextAttemptAt}, now()) end` }) - .from(chatDeliveries).where(and( - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, - // Provider lifecycle receipts can restore an unavailable endpoint. - // The ordinary conversation drain still waits for operator resume. - or(sql`${chatDeliveries.normalizedEvent}->'lifecycle' is not null`, - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.status in ('paused', 'attention'))`), - or(inArray(chatDeliveries.state, ["received", "retry", "processing"]), - and(eq(chatDeliveries.state, "processed"), pendingInboundWakeupCondition())), - )).orderBy(...conversationDeliveryOrder()); - // Only an ordered conversation head can run. Reaction and provider - // lifecycle receipts are independent; lifecycle edits may select their - // exact admission dependency before the chronologically earliest row. - const byEvent = new Map(rows.map(row => [`${row.delivery.endpointId}:${row.delivery.providerEventId}`, row])); - const conversations = new Set(); - let next: number | null = null; - for (let row of rows) { - const delivery = row.delivery; - const threadId = normalizedDeliveryThreadId(delivery); - if (threadId && !normalizedLifecycleEffect(delivery) && !["reaction_added", "reaction_removed"].includes(delivery.eventKind)) { - const key = conversationDrainKey(delivery.endpointId, threadId); - if (conversations.has(key)) continue; - conversations.add(key); - const dependency = normalizedLifecycleTargetEventId(delivery); - const target = dependency ? byEvent.get(`${delivery.endpointId}:${dependency}`) : undefined; - if (target && ["received", "retry", "processing"].includes(target.delivery.state)) row = target; - } - if (row.at !== null) { - const at = new Date(row.at).getTime(); - next = next === null ? at : Math.min(next, at); - } - } - return next; - } - async function nextPublicationAt(): Promise { const cooldowns = Object.fromEntries([...teamsFileRetryAfter].map(([id, at]) => [id, new Date(at).toISOString()])); const [queued, streaming] = await Promise.all([ @@ -18966,6 +7615,20 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { : null; } + const { configure, configureWithCredentialLease, runtimeConfig } = createChatConfiguration(db, { + endpointRecord, get, withCredentialMutationLease, getWebhookPublicBaseUrl, invalidateRuntime, + nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, + legacyGitHubLabelsMatchVerifiedCredentials, normalizedCredentials, verifyCredentials, + persistCredentials, clearCredentials, resolveCredentials, prepareProviderInventory, + reconcileProviderResourceRows, reconcileDiscordCommands, runtimeFor, runtimeContextForRecord, + stageTelegramMaintenance, processTelegramMaintenance, recordTelegramStopSubscription, + fetchImpl, redactError, runtimeGeneration, leasedChatProvider, PROVIDER_LABELS, SUPPLIED_CREDENTIAL_KEYS, + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, isSupportedTelegramWebhookBaseUrl, + get slackRegistration() { return slackRegistration; }, + get githubWizard() { return githubWizard; }, + get nativeBotIdentityClaimBarrier() { return options.nativeBotIdentityClaimBarrier; }, + get allowLocalVoiceBoard() { return options.allowLocalVoiceBoard; }, + }); const linkSlackAccount: SlackSetupOptions["linkInstaller"] = async (endpointId, user, actor, lease) => { const record = await endpointRecord(endpointId); if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); @@ -19058,6 +7721,79 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook, get githubWizardOrigin() { return options.githubWizardOrigin; }, }); + const { prepareInboundEnvelope } = createInboundEnvelope(db, { runtimeFor, sanitizeFilename, githubManualMessages, githubAutomaticMessages, githubIssueMessages }); + const { admitInboundMessage } = createInboundAdmission(db, { runtimeGeneration, credentialFingerprint, leasedChatProvider, renewDiscordGatewayOwnershipForMessageAdmission, ensureResource, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS }); + const { admitMessageResource } = createMessageResourceAdmission(db, { ensureResource }); + const messageLifecycle = createChatMessageLifecycle(db, { + DELIVERY_PROCESSING_STALE_MS, ORPHAN_FOLLOW_UP_GRACE_MS, ORPHAN_FOLLOW_UP_MAX_ATTEMPTS, + INGRESS_REORDER_WINDOW_MS, issuesSvc, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, + redactError, runtimeCallbackEndpoint, sameTeamsThreadIdentity, durableExternalThreadIdentity, + recordCurrentMicrosoftTeamsRoute, conversationDrainKey, scheduleConversationDrain, scheduledConversationDrains, + drainConversationDeliveries, ensureDiscordGatewayRuntimeIsCurrent, + get deferWebhookProcessing() { return options.deferWebhookProcessing; }, + }); + const { recordLifecycleDelivery, processLifecycleDelivery, conversationForThread } = messageLifecycle; + const conversationDeliveryQueue = createChatConversationDeliveryQueue(db, { + DELIVERY_DRAIN_LIMIT, DELIVERY_LEASE_TTL_MS, discordRootThreadPending, reconcileDiscordRootThread, + durableExternalThreadIdentity, liveInboundMessages, processInboundWakeup, processLifecycleDelivery, processMessage, + redactError, runtimeContexts, runtimeFor, settleRejectedInboundWakeups, sanitizeFilename, + githubManualMessages, githubAutomaticMessages, githubIssueMessages, conversationDrainKey, + get conversationLeaseRenewalIntervalMs() { return options.conversationLeaseRenewalIntervalMs; }, + get renewConversationDeliveryLease() { return options.renewConversationDeliveryLease; }, + }); + const { nextInboundDeliveryAt } = conversationDeliveryQueue; + const { currentTaskControlPayload, hasCommittedTaskControlCompletion, commitTaskControlCompletion } = createChatPublicationTaskControl(db); + const publicationTransport = createChatPublicationTransport(db, { + endpointRecord, fetchImpl, getTaskBaseUrl, publicationFiles, receiptReactionCompletionRunId, + runIdFromMilestonePublication, runtime, runtimeFor, runtimeGeneration, CAPABILITIES, + voice, voiceStore, scheduleMessageProcessing, + }); + const { postSafePublication } = publicationTransport; + const { claimPublicationTransportAuthorization } = createChatPublicationAuthorization(db, { + authorizeInboundWakePublication, authorizeRetainedChatSourcePublication, fetchImpl, + githubCurrentOmissionPreparedText, hasCommittedTaskControlCompletion, lockCurrentPrincipalAuthorization, + nonDirectDestinationAllowed, resolveCredentials, runtimeCallbackEndpoint, FailedChatRetryPublicationReadError, + }); + const { finalizePublicationFailure } = createChatPublicationFailures(db, { + FailedChatRetryPublicationReadError, MAX_ERROR_TEXT, getTaskBaseUrl, invalidateRuntime, runtimeCallbackEndpoint, + }); + const { ensureDurablePublicationTransport } = createChatPublicationPreparation(db, { + runPublicationToReplace, + get publicationTransportPreparationBarrier() { return options.publicationTransportPreparationBarrier; }, + }); + const publicationWorker = createChatPublicationWorker(db, { + CAPABILITIES, authorizeRetainedChatSourcePublication, claimPublicationTransportAuthorization, + closeProgressToReplace, commitTaskControlCompletion, currentTaskControlPayload, endpointRecord, + ensureDurablePublicationTransport, finalizePublicationFailure, hasCommittedTaskControlCompletion, + hasTelegramStopSubscription, inboundWakePublicationToReplace, interactionPromptPublicationToReplace, + interactionResolutionPublicationToReplace, nonDirectDestinationAllowed, postSafePublication, + processReceiptReaction, progressSupersededByFastResponse, recordSlackFileUploadReceipt, redactError, + reserveTelegramPublicationDraft, runOwnershipMilestoneSupersessionReason, runProgressSupersededByPublishedInteraction, + runPublicationToReplace, runtimeContextForRecord, scheduleMessageProcessing, settleGitHubAutomaticPublication, + stageSlackFilePublicationSuccess, taskStatusPublicationToReplace, tryProcessTeamsFilePublication, + withCredentialMutationLease, receiptReactionCompletionRunId, + }); + const { processSelectedPublication } = publicationWorker; + const { processPendingInteractionWakeups } = createChatInteractionContinuations(db, { + redactError, CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, get heartbeat() { return options.heartbeat; }, + }); + const { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions } = createChatInteractionCallbacks(db, { + CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity, + ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint, + requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, + recordCurrentMicrosoftTeamsRoute, lockTelegramCallbackNoticeAuthority, stageProviderEffect, + scheduleProviderEffect, invalidateRuntime, redactError, persistence, questionResponses, + scheduleMessageProcessing, processPendingPublications, + get confirmationResolutionPersistBarrier() { return options.confirmationResolutionPersistBarrier; }, + get questionFormOpenAuthorizationBarrier() { return options.questionFormOpenAuthorizationBarrier; }, + get questionResolutionPersistBarrier() { return options.questionResolutionPersistBarrier; }, + }); + const publicationQueue = createChatPublicationQueue(db, { + PUBLICATION_ENDPOINT_CONCURRENCY, enqueueInboundWakeupPublications, processPendingInteractionWakeups, + reconcileTerminalConfirmationActions, processSelectedPublication, publicationEndpointTasks, + teamsFileRetryAfter, redactError, isShuttingDown: () => shuttingDown, wakePublications: () => wakePublications(), + }); + const { publicationEligibility } = publicationQueue; const { unregisterSlackTaskAuthority } = createSlackTaskAuthorityRegistration(db, { authorizeInboundWakeup, resolveCredentials }); return { diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts new file mode 100644 index 00000000000..b7cd9e4c22e --- /dev/null +++ b/server/src/services/chat-channels/configuration.ts @@ -0,0 +1,968 @@ +import { preparePhotonConfiguration, createPhotonConfiguration } from "./imessage-photon/setup.js"; +import { slackRuntimeConfig } from "./slack/setup.js"; +import { createTelegramSetup } from "./telegram/setup.js"; +import { createGitHubSetup } from "./github/setup.js"; +import { createSpekoSetup } from "./speko/setup.js"; +import { createGitHubAppSetup } from "./github/app-setup.js"; + +import type { VerifiedProviderIdentity } from "./runtime-types.js"; + +import { isProvisionedSlackSetup } from "@paperclipai/shared"; + +import { slackCallbackMatchesPublicUrl } from "./slack/callback-verification.js"; + +import { createDiscordCommands } from "./discord/commands.js"; + +import { createTelegramMaintenance } from "./telegram/maintenance.js"; +import type { EndpointRow, RuntimeContext, InternalSetupState } from "./runtime-types.js"; + +import { createChatResourceReconciliation } from "./resource-reconciliation.js"; + +import { notifyChatDeliveryWork, notifyChatEndpointWork } from "../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js"; + +import { slackChatRegistrationService } from "../chat-slack-registration.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + isNull, + ne, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpoints, + toolApplications, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpoint, + ChatEndpointSetupState, + ChatProvider, + ConfigureChatEndpointInput, +} from "@paperclipai/shared"; + +import { + MAX_ATTACHMENT_BYTES, +} from "../../attachment-types.js"; + +import { + badRequest, + conflict, + notFound, + unprocessable, +} from "../../errors.js"; + +import { + type ResolvedChatSdkProviderConfig, +} from "../chat-sdk-runtime.js"; +import { + logActivity, +} from "../activity-log.js"; + +import type { EndpointRecord } from "./runtime-types.js"; +import type { createChatCredentials } from "./credentials.js"; +type ChatConfigurationOptions = Pick, "normalizedCredentials" | "verifyCredentials" | "nativeBotIdentityMatches" | "legacyGitHubLabelsMatchVerifiedCredentials" | "assertNativeBotIdentityAvailable" | "isNativeBotIdentityUniqueViolation" | "nativeBotIdentityConflict" | "persistCredentials" | "clearCredentials" | "resolveCredentials" | "prepareProviderInventory"> & { + endpointRecord: (endpointId: string) => Promise; + get: (endpointId: string) => Promise; + withCredentialMutationLease: ReturnType; + getWebhookPublicBaseUrl: () => string | null; + invalidateRuntime: (endpointId: string) => Promise; + reconcileProviderResourceRows: ReturnType["reconcileProviderResourceRows"]; + reconcileDiscordCommands: ReturnType["reconcileDiscordCommands"]; + runtimeFor: (endpoint: EndpointRow, input?: { requireDiscordOwnership?: boolean; waitForDiscordOwnership?: boolean }) => Promise; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + stageTelegramMaintenance: ReturnType["stageTelegramMaintenance"]; + processTelegramMaintenance: ReturnType["processTelegramMaintenance"]; + recordTelegramStopSubscription: ReturnType["recordTelegramStopSubscription"]; + fetchImpl: typeof globalThis.fetch; + redactError: (error: unknown) => string; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + leasedChatProvider: (provider: string) => boolean; + PROVIDER_LABELS: Record; + SUPPLIED_CREDENTIAL_KEYS: Record; + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number; + isSupportedTelegramWebhookBaseUrl: (url: string | null) => boolean; + slackRegistration: Pick, "cleanup">; + githubWizard: Pick["githubWizard"], "repairCloud">; + nativeBotIdentityClaimBarrier?: () => Promise; + allowLocalVoiceBoard?: boolean; +}; +export function createChatConfiguration(db: Db, options: ChatConfigurationOptions) { + const { endpointRecord, get, withCredentialMutationLease, getWebhookPublicBaseUrl, invalidateRuntime, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, legacyGitHubLabelsMatchVerifiedCredentials, normalizedCredentials, verifyCredentials, persistCredentials, clearCredentials, resolveCredentials, prepareProviderInventory, reconcileProviderResourceRows, reconcileDiscordCommands, runtimeFor, runtimeContextForRecord, stageTelegramMaintenance, processTelegramMaintenance, recordTelegramStopSubscription, fetchImpl, redactError, runtimeGeneration, leasedChatProvider, PROVIDER_LABELS, SUPPLIED_CREDENTIAL_KEYS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, isSupportedTelegramWebhookBaseUrl } = options; + const { persistPhotonConfiguration } = createPhotonConfiguration(db, { invalidateRuntime }); + const { registerTelegramWebhook } = createTelegramSetup(db, { fetchImpl, getWebhookPublicBaseUrl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, runtimeContextForRecord, recordTelegramStopSubscription, stageTelegramMaintenance, processTelegramMaintenance }); + const { repairGitHubCloudSetup, repairGitHubWebhookSetup } = createGitHubSetup(db, { fetchImpl, getWebhookPublicBaseUrl, get githubWizard() { return options.githubWizard; } }); + const { installSpekoVoiceTools, configureSpekoTools } = createSpekoSetup(db, { fetchImpl, getWebhookPublicBaseUrl, get allowLocalVoiceBoard() { return options.allowLocalVoiceBoard; } }); + + + + async function configure( + endpointId: string, + input: ConfigureChatEndpointInput, + actorUserId?: string | null, + ) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + if (record.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail"); + if (input.photon && record.endpoint.provider !== "imessage-photon") throw badRequest("Photon configuration is only valid for iMessage Photon"); + const suppliedCredentialKeys = Object.keys(input.credentials ?? {}); + if (suppliedCredentialKeys.length > 0) { + const credentialAction = + input.action === "configure" || input.action === "reconnect"; + const allowedKeys = new Set( + credentialAction + ? SUPPLIED_CREDENTIAL_KEYS[record.endpoint.provider] + : [], + ); + const unsupportedKeys = suppliedCredentialKeys.filter( + (key) => !allowedKeys.has(key), + ); + if (unsupportedKeys.length > 0) { + throw unprocessable( + `Unsupported ${PROVIDER_LABELS[record.endpoint.provider]} credential fields for ${input.action}: ${unsupportedKeys.join(", ")}`, + { + code: "chat_endpoint_credentials_invalid", + provider: record.endpoint.provider, + action: input.action, + unsupportedKeys, + }, + ); + } + } + return withCredentialMutationLease(record.endpoint, (credentialLease) => + configureWithCredentialLease( + endpointId, + input, + credentialLease, + actorUserId, + ), + ); + } + + async function configureWithCredentialLease( + endpointId: string, + input: ConfigureChatEndpointInput, + credentialLease: CredentialMutationLeaseGuard, + actorUserId?: string | null, + ) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const endpoint = record.endpoint; + if (input.action === "pause") { + if (endpoint.status !== "active") { + throw conflict("Only an active chat connection can be paused", { + code: "chat_endpoint_not_active", + }); + } + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const pausedAt = new Date(); + const current = await tx + .select({ setup: chatEndpoints.setup, status: chatEndpoints.status }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current || current.status !== "active") { + throw conflict("Only an active chat connection can be paused", { + code: "chat_endpoint_not_active", + }); + } + await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status: "paused", + setup: { + ...current.setup, + runtimeGeneration: runtimeGeneration(current.setup) + (endpoint.provider === "imessage-photon" ? 0 : 1), + } as InternalSetupState, + updatedAt: pausedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ status: "disabled", enabled: false, updatedAt: new Date() }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + redactedError: "Connection was paused before processing", + processedAt: pausedAt, + updatedAt: pausedAt, + }) + .where( + and( + endpoint.provider === "imessage-photon" ? sql`false` : undefined, + eq(chatDeliveries.endpointId, endpoint.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ); + await credentialLease.assertOwned(tx); + }); + await invalidateRuntime(endpoint.id); + await logActivity(db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.paused", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: { endpointId: endpoint.id }, + }); + return get(endpoint.id); + } + if (input.action === "resume") { + if (endpoint.status !== "paused" || endpoint.setup.step !== "complete") { + throw conflict( + "Only a previously active chat connection can be resumed", + { + code: "chat_endpoint_not_resumable", + }, + ); + } + let credentials = await resolveCredentials(endpoint).catch(() => { + throw conflict("Reconnect the provider credentials before resuming", { + code: "chat_endpoint_credentials_missing", + }); + }); + const identity = await verifyCredentials(endpoint.provider, credentials); + const upgradingLegacyGitHubIdentity = + endpoint.provider === "github" && + !endpoint.botExternalId && + legacyGitHubLabelsMatchVerifiedCredentials(endpoint, identity); + if ( + !upgradingLegacyGitHubIdentity && + !nativeBotIdentityMatches(endpoint.provider, endpoint, identity) + ) { + throw conflict( + "The provider credentials now identify a different bot; reconnect this connection instead", + { + code: "chat_bot_identity_changed", + }, + ); + } + await assertNativeBotIdentityAvailable(endpoint, identity); + const prepared = await prepareProviderInventory(endpoint, credentials); + if ( + endpoint.provider === "github" && + prepared.credentials.installationId !== credentials.installationId + ) { + await persistCredentials( + endpoint, + prepared.credentials, + credentialLease, + actorUserId, + ); + } + credentials = prepared.credentials; + if (prepared.inventory) + await reconcileProviderResourceRows( + endpoint, + prepared.inventory, + credentialLease, + ); + let activatedEndpoint!: EndpointRow; + try { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const resumedAt = new Date(); + const current = await tx + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current || current.status !== "paused") { + throw conflict( + "Only a previously active chat connection can be resumed", + { + code: "chat_endpoint_not_resumable", + }, + ); + } + // Fail closed for any legacy or racing delivery that remained open + // while this endpoint was paused. Resume must never execute traffic + // that Paperclip acknowledged during the inactive interval. + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + redactedError: "Connection was paused when this event arrived", + processedAt: resumedAt, + updatedAt: resumedAt, + }) + .where( + and( + endpoint.provider === "imessage-photon" ? sql`false` : undefined, + eq(chatDeliveries.endpointId, endpoint.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ); + await notifyChatEndpointWork(tx); + [activatedEndpoint] = await tx + .update(chatEndpoints) + .set({ + status: "active", + ...(upgradingLegacyGitHubIdentity + ? { botExternalId: identity.botExternalId } + : {}), + healthMessage: "Connected", + lastError: null, + setup: { + ...current.setup, + ...(endpoint.provider === "imessage-photon" ? { photonIntakeAfter: resumedAt.toISOString() } : {}), + runtimeGeneration: runtimeGeneration(current.setup) + (endpoint.provider === "imessage-photon" ? 0 : 1), + } as InternalSetupState, + updatedAt: resumedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)) + .returning(); + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: "Connected", + lastError: null, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + await invalidateRuntime(endpoint.id); + if (activatedEndpoint.provider === "discord") + await reconcileDiscordCommands(endpoint.id, credentialLease, true); + await runtimeFor(activatedEndpoint, { + requireDiscordOwnership: leasedChatProvider(activatedEndpoint.provider), + waitForDiscordOwnership: leasedChatProvider(activatedEndpoint.provider), + }); + } catch (error) { + await invalidateRuntime(endpoint.id).catch(() => undefined); + if (activatedEndpoint) { + const failure = redactError(error); + const failedAt = new Date(); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: "Provider resume needs attention", + lastError: failure, + updatedAt: failedAt, + }) + .where( + and( + eq(chatEndpoints.id, endpoint.id), + eq(chatEndpoints.status, "active"), + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${runtimeGeneration(activatedEndpoint.setup)}`, + ), + ); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: "Provider resume failed", + lastError: failure, + healthCheckedAt: failedAt, + updatedAt: failedAt, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + redactedError: "Connection activation failed", + processedAt: failedAt, + updatedAt: failedAt, + }) + .where( + and( + eq(chatDeliveries.endpointId, endpoint.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ); + await credentialLease.assertOwned(tx); + }); + } + throw error; + } + await logActivity(db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.resumed", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: { endpointId: endpoint.id }, + }); + return get(endpoint.id); + } + if (input.action === "remove") { + if (endpoint.status === "archived") { + if (endpoint.provider === "slack") await options.slackRegistration.cleanup(endpoint.id, credentialLease); + // Archival is the durable ingress fence and intentionally commits + // before secret-store cleanup. If that cleanup failed, a repeated + // remove is the recovery operation; once refs are empty, retain the + // normal already-removed conflict contract. + if (record.credentialSecretRefs.length > 0) { + await invalidateRuntime(endpoint.id).catch(() => undefined); + if (endpoint.provider === "telegram") { + await credentialLease.assertOwned(); + const action = await stageTelegramMaintenance( + endpoint, + "remove_endpoint", + ); + if (action) + await processTelegramMaintenance( + action.id, + true, + credentialLease, + ); + } else { + await clearCredentials(endpoint, credentialLease); + } + return get(endpoint.id); + } + throw conflict("This chat connection has already been removed", { + code: "chat_endpoint_already_removed", + }); + } + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ setup: chatEndpoints.setup, status: chatEndpoints.status }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current || current.status === "archived") { + throw conflict("This chat connection has already been removed", { + code: "chat_endpoint_already_removed", + }); + } + await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status: "archived", + setup: { + ...current.setup, + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + archivedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(chatConversations) + .set({ state: "endpoint_removed", updatedAt: new Date() }) + .where(eq(chatConversations.endpointId, endpoint.id)); + await tx + .update(toolConnections) + .set({ status: "archived", enabled: false, updatedAt: new Date() }) + .where(eq(toolConnections.id, endpoint.connectionId)); + const otherConnection = await tx + .select({ id: toolConnections.id }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.applicationId, record.applicationId), + ne(toolConnections.id, endpoint.connectionId), + ne(toolConnections.status, "archived"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!otherConnection) { + await tx + .update(toolApplications) + .set({ + status: "archived", + archivedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolApplications.id, record.applicationId)); + } + await credentialLease.assertOwned(tx); + }); + if (endpoint.provider === "slack") await options.slackRegistration.cleanup(endpoint.id, credentialLease); + await invalidateRuntime(endpoint.id).catch(() => undefined); + if (endpoint.provider === "telegram") { + await credentialLease.assertOwned(); + const action = await stageTelegramMaintenance( + endpoint, + "remove_endpoint", + ); + if (action) { + await processTelegramMaintenance(action.id, true, credentialLease); + } + } else { + await clearCredentials(endpoint, credentialLease); + } + return get(endpoint.id); + } + if ( + input.action !== "configure" && + input.action !== "reconnect" && + input.action !== "verify" + ) { + throw unprocessable("Unsupported chat endpoint setup action"); + } + if (!getWebhookPublicBaseUrl() && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon" && !(endpoint.provider === "github" && endpoint.setup.github?.cloudRegistrationId)) { + throw unprocessable( + `A public HTTPS Paperclip URL is required before connecting ${PROVIDER_LABELS[endpoint.provider]}`, + ); + } + if ( + endpoint.provider === "telegram" && + (input.action === "configure" || input.action === "reconnect") && + !isSupportedTelegramWebhookBaseUrl(getWebhookPublicBaseUrl()) + ) { + throw unprocessable( + "Telegram webhooks require PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL to use HTTPS on port 443, 80, 88, or 8443", + { + code: "chat_telegram_webhook_url_unsupported", + provider: "telegram", + supportedPorts: [443, 80, 88, 8443], + }, + ); + } + if (input.action === "verify") { + if ( + endpoint.provider !== "slack" || + endpoint.status !== "verifying" || + endpoint.setup.step !== "provider_setup" + ) { + throw conflict( + "Provider verification is not available at this setup step", + { + code: "chat_endpoint_invalid_setup_step", + }, + ); + } + if (!endpoint.setup.webhookVerifiedAt) { + throw conflict("Paperclip has not received a verified Slack callback yet", { + code: "chat_webhook_not_verified", + }); + } + } else if ( + input.action === "configure" && + !["draft", "attention", "revoked"].includes(endpoint.status) && + // GitHub installation discovery can advance the status before the + // wizard has configured its runtime. Finish that exact setup stage; + // configured connections still require the explicit reconnect flow. + !( + endpoint.provider === "github" && + endpoint.status === "verifying" && + endpoint.setup.step === "provider_setup" + ) + ) { + throw conflict( + "This connection is already configured; use its reconnect flow if credentials need repair", + { + code: "chat_endpoint_already_configured", + }, + ); + } else if ( + input.action === "reconnect" && + !["verifying", "active", "attention", "revoked", "paused"].includes( + endpoint.status, + ) + ) { + throw conflict( + "This chat connection does not currently need reconnecting", + { + code: "chat_endpoint_not_reconnectable", + }, + ); + } + if ( + endpoint.provider === "github" && + (input.action === "configure" || input.action === "reconnect") && + !endpoint.setup.webhookVerifiedAt + ) { + throw conflict( + "GitHub has not delivered a signed webhook ping for this secret yet", + { + code: "chat_webhook_not_verified", + }, + ); + } + + let verifiedCurrentIdentity: VerifiedProviderIdentity = endpoint; + if ( + input.action === "reconnect" && + endpoint.provider === "github" && + !endpoint.botExternalId + ) { + const storedCredentials = await resolveCredentials(endpoint).catch(() => { + throw conflict( + "Reconnect the existing GitHub App credentials before changing them", + { + code: "chat_bot_identity_unverifiable", + }, + ); + }); + const storedIdentity = await verifyCredentials( + "github", + storedCredentials, + ); + if ( + !legacyGitHubLabelsMatchVerifiedCredentials(endpoint, storedIdentity) + ) { + throw conflict( + "The stored credentials no longer verify the GitHub App that owns this connection", + { + code: "chat_bot_identity_unverifiable", + }, + ); + } + verifiedCurrentIdentity = storedIdentity; + } + + const previousSpekoSigningSecret = endpoint.provider === "speko" + ? (await resolveCredentials(endpoint).catch(() => ({} as Record))).signingSecret : undefined; + let credentials = + input.credentials && Object.keys(input.credentials).length > 0 + ? await normalizedCredentials(endpoint, input.credentials) + : await resolveCredentials(endpoint).catch(() => { + throw unprocessable( + `${PROVIDER_LABELS[endpoint.provider]} credentials are required`, + ); + }); + if (endpoint.provider === "imessage-photon") { + credentials = preparePhotonConfiguration(endpoint, input, credentials); + } + const identity = await verifyCredentials(endpoint.provider, credentials); + // Once setup has claimed a provider bot identity, every credential repair + // must prove that same identity before secrets can be replaced. A process + // interruption can leave the endpoint in `attention` after the claim but + // before credential persistence; a repeated first-setup request must not + // turn that recovery state into an identity-swap escape hatch. + if (input.action === "reconnect" || Boolean(endpoint.botExternalId)) { + if ( + !nativeBotIdentityMatches( + endpoint.provider, + verifiedCurrentIdentity, + identity, + ) + ) { + throw conflict( + "The provider credentials identify a different bot; create a new connection for that bot instead", + { code: "chat_bot_identity_changed" }, + ); + } + } + await assertNativeBotIdentityAvailable(endpoint, identity); + const prepared = await prepareProviderInventory(endpoint, credentials); + const credentialsChangedByDiscovery = + endpoint.provider === "github" && + prepared.credentials.installationId !== credentials.installationId; + credentials = prepared.credentials; + await options.nativeBotIdentityClaimBarrier?.(); + // Claim a fresh provider identity before any newly supplied secrets are + // persisted. The provider-global unique indexes are the multi-process + // arbitration boundary when two setup requests both pass the optimistic + // preflight. A process that stops after this small claim leaves a + // fail-closed attention endpoint that the same operator can reconnect; + // it never leaves credentials bound to the losing endpoint. + if (!endpoint.botExternalId) { + try { + const claimed = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatEndpointWork(tx); + const rows = await tx + .update(chatEndpoints) + .set({ + status: "attention", + providerAccountId: identity.providerAccountId ?? null, + providerAccountLabel: identity.providerAccountLabel ?? null, + botExternalId: identity.botExternalId ?? null, + botUsername: identity.botUsername ?? null, + botDisplayName: + identity.botLabel ?? + endpoint.botDisplayName ?? + record.assignedAgentName, + healthMessage: "Provider setup must be completed", + lastError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatEndpoints.id, endpoint.id), + eq(chatEndpoints.status, endpoint.status), + isNull(chatEndpoints.botExternalId), + ), + ) + .returning({ id: chatEndpoints.id }); + await credentialLease.assertOwned(tx); + return rows; + }); + if (claimed.length !== 1) { + throw conflict("This chat connection changed during setup", { + code: "chat_endpoint_setup_changed", + }); + } + } catch (error) { + if (isNativeBotIdentityUniqueViolation(error)) { + throw nativeBotIdentityConflict(endpoint.provider); + } + throw error; + } + } + if (endpoint.provider === "imessage-photon") { + await persistPhotonConfiguration(endpoint, credentials, credentialLease); + } + if ( + (input.credentials && Object.keys(input.credentials).length > 0) || + credentialsChangedByDiscovery + ) + await persistCredentials( + endpoint, + credentials, + credentialLease, + actorUserId, + ); + if (prepared.inventory) + await reconcileProviderResourceRows( + endpoint, + prepared.inventory, + credentialLease, + ); + const updatedAt = new Date(); + const waitingForSlackConfiguration = + endpoint.provider === "slack" && + (input.action === "configure" || input.action === "reconnect"); + try { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ setup: chatEndpoints.setup }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) throw notFound("Chat endpoint not found"); + if (endpoint.provider === "speko") { + await installSpekoVoiceTools(tx, endpoint, actorUserId); + } + const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; + const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) + && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") + && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); + await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status: "verifying", + providerAccountId: identity.providerAccountId ?? null, + providerAccountLabel: identity.providerAccountLabel ?? null, + botExternalId: identity.botExternalId ?? null, + botUsername: identity.botUsername ?? null, + botDisplayName: + identity.botLabel ?? + endpoint.botDisplayName ?? + record.assignedAgentName, + healthMessage: waitingForSlackConfiguration + ? "Finish provider webhook configuration" + : "Waiting for a test conversation", + lastError: null, + lastEventAt: null, + setup: { + ...current.setup, + ...(endpoint.provider === "github" ? { github: { ...current.setup.github, stage: current.setup.github?.stage ?? "connect", appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin } } : {}), + step: waitingForSlackConfiguration ? "provider_setup" : "test", + ...(endpoint.provider === "imessage-photon" ? { photonIntakeAfter: (current.setup as InternalSetupState).photonIntakeAfter ?? updatedAt.toISOString() } : {}), + testStartedAt: waitingForSlackConfiguration + ? null + : updatedAt.toISOString(), + webhookVerifiedAt: waitingForSlackConfiguration + ? preserveSlackVerification ? (current.setup.webhookVerifiedAt ?? null) : null + : (current.setup.webhookVerifiedAt ?? null), + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + updatedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: "Provider credentials verified", + healthCheckedAt: updatedAt, + updatedAt, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await tx + .update(toolApplications) + .set({ status: "active", archivedAt: null, updatedAt }) + .where(eq(toolApplications.id, record.applicationId)); + await credentialLease.assertOwned(tx); + }); + } catch (error) { + if (isNativeBotIdentityUniqueViolation(error)) { + throw nativeBotIdentityConflict(endpoint.provider); + } + throw error; + } + try { + const next = await endpointRecord(endpoint.id); + if (!next) throw notFound("Chat endpoint not found"); + await invalidateRuntime(endpoint.id); + if (endpoint.provider === "github" && input.action === "reconnect" && endpoint.setup.github?.cloudRegistrationId) { + await repairGitHubCloudSetup(endpoint, credentialLease, actorUserId); + } + if (endpoint.provider === "github" && input.action === "reconnect" && !endpoint.setup.github?.cloudRegistrationId) { + await repairGitHubWebhookSetup(endpoint, credentials, credentialLease, actorUserId); + } + if (next.endpoint.provider === "speko") { + await configureSpekoTools(endpoint, credentials, credentialLease, previousSpekoSigningSecret); + } + if (next.endpoint.provider === "discord") + await reconcileDiscordCommands(endpoint.id, credentialLease, true); + await runtimeFor(next.endpoint, { + requireDiscordOwnership: next.endpoint.provider === "discord", + waitForDiscordOwnership: next.endpoint.provider === "discord", + }); + + if (endpoint.provider === "telegram" && getWebhookPublicBaseUrl()) { + await registerTelegramWebhook(endpoint, input, credentials, credentialLease, next); + } + } catch (error) { + const failure = redactError(error); + await invalidateRuntime(endpoint.id).catch(() => undefined); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: "Provider setup needs attention", + lastError: failure, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ + healthStatus: "degraded", + healthMessage: "Provider setup failed", + lastError: failure, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + throw unprocessable(failure, { code: "chat_provider_setup_failed" }); + } + + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: + input.action === "reconnect" + ? "chat_endpoint.reconnected" + : "chat_endpoint.credentials_verified", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: { endpointId: endpoint.id, provider: endpoint.provider }, + }); + await credentialLease.assertOwned(tx); + }); + return get(endpoint.id); + } + + function runtimeConfig( + endpoint: EndpointRow, + userName: string, + credentials: Record, + ): ResolvedChatSdkProviderConfig { + if (endpoint.provider === "speko") throw conflict("Speko uses the voice runtime"); + if (endpoint.provider === "imessage-photon") return { + provider: "imessage-photon", userName, + intakeAfter: Date.parse(String((endpoint.setup as InternalSetupState).photonIntakeAfter ?? endpoint.setup.testStartedAt ?? endpoint.createdAt.toISOString())), + credentials: { allocation: credentials.allocation === "shared" ? "shared" : "dedicated", projectId: credentials.projectId, lineId: credentials.lineId, projectSecret: credentials.projectSecret, phoneNumber: endpoint.botExternalId! }, + }; + if (endpoint.provider === "slack") + return slackRuntimeConfig(endpoint, userName, credentials); + if (endpoint.provider === "telegram") + return { + provider: "telegram", + userName, + maxDownloadBytes: MAX_ATTACHMENT_BYTES, + credentials: { + botToken: credentials.botToken, + secretToken: credentials.webhookSecret, + }, + }; + if (endpoint.provider === "discord") + return { + provider: "discord", + userName, + credentials: { + applicationId: credentials.applicationId, + botToken: credentials.botToken, + guildId: credentials.guildId, + }, + }; + if (endpoint.provider === "microsoft-teams") + return { + provider: "microsoft-teams", + userName, + credentials: { + appId: credentials.clientId, + appPassword: credentials.clientSecret, + appTenantId: credentials.tenantId, + appType: "SingleTenant", + }, + }; + return { + provider: "github", + userName, + credentials: { + appId: credentials.appId, + privateKey: credentials.privateKey, + installationId: credentials.installationId + ? Number(credentials.installationId) + : undefined, + webhookSecret: credentials.webhookSecret, + }, + }; + } + return { configure, configureWithCredentialLease, runtimeConfig }; +} diff --git a/server/src/services/chat-channels/credentials.ts b/server/src/services/chat-channels/credentials.ts new file mode 100644 index 00000000000..09f6c4525b3 --- /dev/null +++ b/server/src/services/chat-channels/credentials.ts @@ -0,0 +1,507 @@ +import { prepareSlackInventory } from "./slack/inventory.js"; +import { prepareGitHubInventory } from "./github/inventory.js"; +import { prepareDiscordInventory } from "./discord/inventory.js"; +import type { VerifiedProviderIdentity } from "./runtime-types.js"; +import { verifySlackCredentials } from "./slack/credentials.js"; +import { verifyDiscordCredentials } from "./discord/credentials.js"; +import { verifyTelegramCredentials } from "./telegram/credentials.js"; +import { verifyGitHubCredentials } from "./github/credentials.js"; +import { verifySpekoCredentials } from "./speko/credentials.js"; +import { verifyTeamsCredentials } from "./microsoft-teams/credentials.js"; +import { createPhotonCredentialChecks } from "./imessage-photon/credentials.js"; + +import type { EndpointRow, DbOrTransaction } from "./runtime-types.js"; + +import { type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js"; + +import { photonSharedScope } from "../photon/cloud.js"; + +import { photonChannelConfigurationSchema } from "@paperclipai/shared"; + +import { + randomBytes, + randomUUID, +} from "node:crypto"; +import { + and, + eq, + inArray, + ne, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpoints, + companySecretBindings, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatProvider, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { isUniqueViolation } from "../../db-errors.js"; + +import { + conflict, + notFound, + unprocessable, +} from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; + +import { type ChatProviderInventoryResult } from "../chat-provider-inventory.js"; + +import { + normalizeMicrosoftTeamsCredentialIds, +} from "../chat-teams-credentials.js"; + +import { secretService } from "../secrets.js"; + +type ChatCredentialsOptions = { + secrets: ReturnType; + PROVIDER_LABELS: Record; + REQUIRED_CREDENTIALS: Record, readonly string[]>; + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number; + fetchImpl: typeof globalThis.fetch; + redactError: (error: unknown) => string; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + verifyPhotonCredentials: ReturnType["verifyPhotonCredentials"]; +}; +export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { + const { secrets, PROVIDER_LABELS, REQUIRED_CREDENTIALS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, fetchImpl, redactError, credentialFingerprint, verifyPhotonCredentials } = options; + + + async function normalizedCredentials( + endpoint: EndpointRow, + supplied: Record | undefined, + ) { + const values = await resolveCredentials(endpoint).catch( + () => ({}) as Record, + ); + for (const [key, rawValue] of Object.entries(supplied ?? {})) { + if (typeof rawValue === "string" && rawValue.trim()) + values[key] = key === "privateKey" ? rawValue : rawValue.trim(); + } + if (endpoint.provider === "telegram" && !values.webhookSecret) { + values.webhookSecret = randomBytes(32).toString("hex"); + } + const required = + endpoint.provider === "github" + ? ["appId", "privateKey", "webhookSecret"] + : REQUIRED_CREDENTIALS[endpoint.provider]; + const missing = required.filter((key) => !values[key]); + if (missing.length > 0) { + throw unprocessable( + `Missing required ${PROVIDER_LABELS[endpoint.provider]} credentials: ${missing.join(", ")}`, + ); + } + return endpoint.provider === "microsoft-teams" + ? normalizeMicrosoftTeamsCredentialIds(values) + : values; + } + + async function verifyCredentials( + provider: ChatProvider, + credentials: Record, + ): Promise { + if (provider === "imessage-photon") return verifyPhotonCredentials(credentials); + if (provider === "slack") return verifySlackCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "discord") return verifyDiscordCredentials(credentials, fetchImpl, redactError); + if (provider === "telegram") return verifyTelegramCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "github") return verifyGitHubCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "speko") return verifySpekoCredentials(credentials, fetchImpl); + return verifyTeamsCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + } + + function nativeBotIdentityKey( + provider: ChatProvider, + identity: VerifiedProviderIdentity, + ): string | null { + const account = identity.providerAccountId?.trim().toLowerCase(); + const bot = identity.botExternalId?.trim().toLowerCase(); + if (provider === "github" || provider === "discord") { + if (bot) return `${provider}:app:${bot}`; + return null; + } + if (!account || !bot) return null; + return `${provider}:${account}:${bot}`; + } + + function providerUsesGlobalApplicationIdentity( + provider: ChatProvider, + ): boolean { + return ( + provider === "github" || + provider === "discord" || + provider === "microsoft-teams" || provider === "imessage-photon" + ); + } + + function nativeBotIdentityMatches( + provider: ChatProvider, + current: VerifiedProviderIdentity, + incoming: VerifiedProviderIdentity, + ): boolean { + const currentKey = nativeBotIdentityKey(provider, current); + return ( + currentKey !== null && + currentKey === nativeBotIdentityKey(provider, incoming) + ); + } + + function legacyGitHubLabelsMatchVerifiedCredentials( + current: VerifiedProviderIdentity, + verified: VerifiedProviderIdentity, + ): boolean { + return ( + !current.botExternalId && + Boolean(verified.botExternalId) && + current.providerAccountId?.trim().toLowerCase() === + verified.providerAccountId?.trim().toLowerCase() && + current.botUsername?.trim().toLowerCase() === + verified.botUsername?.trim().toLowerCase() + ); + } + + async function assertNativeBotIdentityAvailable( + endpoint: EndpointRow, + identity: VerifiedProviderIdentity, + ): Promise { + const key = nativeBotIdentityKey(endpoint.provider, identity); + if (!key) { + throw unprocessable( + `${PROVIDER_LABELS[endpoint.provider]} did not return a stable native bot identity`, + ); + } + const candidates = await db + .select({ + id: chatEndpoints.id, + companyId: chatEndpoints.companyId, + assignedAgentId: chatEndpoints.assignedAgentId, + providerAccountId: chatEndpoints.providerAccountId, + botExternalId: chatEndpoints.botExternalId, + botUsername: chatEndpoints.botUsername, + }) + .from(chatEndpoints) + .where( + and( + providerUsesGlobalApplicationIdentity(endpoint.provider) + ? undefined + : eq(chatEndpoints.companyId, endpoint.companyId), + eq(chatEndpoints.provider, endpoint.provider), + ne(chatEndpoints.id, endpoint.id), + endpoint.provider === "imessage-photon" ? ne(chatEndpoints.status, "archived") : inArray(chatEndpoints.status, [ + "verifying", + "active", + "paused", + "attention", + ]), + ), + ); + const conflictEndpoint = candidates.find((candidate) => { + if (!providerUsesGlobalApplicationIdentity(endpoint.provider)) { + return nativeBotIdentityKey(endpoint.provider, candidate) === key; + } + const candidateAppId = candidate.botExternalId?.trim().toLowerCase(); + const incomingAppId = identity.botExternalId?.trim().toLowerCase(); + if (candidateAppId && incomingAppId) + return candidateAppId === incomingAppId; + return ( + candidate.providerAccountId?.trim().toLowerCase() === + identity.providerAccountId?.trim().toLowerCase() && + candidate.botUsername?.trim().toLowerCase() === + identity.botUsername?.trim().toLowerCase() + ); + }); + if (conflictEndpoint) { + throw conflict( + `This ${PROVIDER_LABELS[endpoint.provider]} bot already represents another Paperclip agent connection`, + conflictEndpoint.companyId === endpoint.companyId + ? { + code: "chat_bot_identity_in_use", + endpointId: conflictEndpoint.id, + assignedAgentId: conflictEndpoint.assignedAgentId, + } + : { code: "chat_bot_identity_in_use" }, + ); + } + } + + function isNativeBotIdentityUniqueViolation(error: unknown): boolean { + return ( + isUniqueViolation(error, "chat_endpoints_photon_number_uq") || + isUniqueViolation(error, "chat_endpoints_live_bot_external_uq") || + isUniqueViolation(error, "chat_endpoints_live_discord_bot_external_uq") || + isUniqueViolation( + error, + "chat_endpoints_live_global_app_bot_external_uq", + ) || + isUniqueViolation(error, "chat_endpoints_live_bot_username_uq") + ); + } + + function nativeBotIdentityConflict(provider: ChatProvider) { + return conflict( + `This ${PROVIDER_LABELS[provider]} bot already represents another Paperclip agent connection`, + { code: "chat_bot_identity_in_use" }, + ); + } + + async function persistCredentials( + endpoint: EndpointRow, + credentials: Record, + credentialLease: CredentialMutationLeaseGuard, + actorUserId?: string | null, + ) { + await credentialLease.assertOwned(); + const previousRefs = await db + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .then((rows) => rows[0]?.refs ?? []); + const refs: ToolCredentialSecretRef[] = []; + try { + for (const [key, value] of Object.entries(credentials)) { + if (endpoint.provider === "imessage-photon" && key !== "projectSecret") continue; + await credentialLease.assertOwned(); + const suffix = randomUUID(); + const secret = await secrets.create( + endpoint.companyId, + { + name: `chat.${endpoint.provider}.${endpoint.id.slice(0, 8)}.${key}.${suffix.slice(0, 8)}`, + key: `CHAT_${endpoint.provider.replaceAll("-", "_")}_${endpoint.id.replaceAll("-", "_")}_${key}_${suffix}`.toUpperCase(), + provider: "local_encrypted", + managedMode: "paperclip_managed", + value, + description: `${PROVIDER_LABELS[endpoint.provider]} channel credential for endpoint ${endpoint.id}`, + }, + { userId: actorUserId ?? undefined }, + ); + refs.push({ + secretId: secret.id, + versionSelector: "latest", + configPath: `credentials.${key}`, + required: true, + label: key, + projectionClass: "unclassified", + }); + await credentialLease.assertOwned(); + } + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) throw notFound("Chat connection not found"); + if ( + credentialFingerprint(current.refs) !== + credentialFingerprint(previousRefs) + ) { + throw conflict("Chat credentials changed during rotation", { + code: "chat_endpoint_credentials_changed", + }); + } + await tx + .delete(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, endpoint.companyId), + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, endpoint.connectionId), + ), + ); + if (refs.length > 0) { + await tx.insert(companySecretBindings).values( + refs.map((ref) => ({ + companyId: endpoint.companyId, + secretId: ref.secretId, + targetType: "tool_connection", + targetId: endpoint.connectionId, + configPath: ref.configPath, + versionSelector: String(ref.versionSelector ?? "latest"), + required: ref.required, + label: ref.label ?? null, + projectionClass: ref.projectionClass ?? "unclassified", + projectionAllowlistKey: ref.projectionAllowlistKey ?? null, + })), + ); + } + await tx + .update(toolConnections) + .set({ credentialSecretRefs: refs, updatedAt: new Date() }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + } catch (error) { + // A lost database acknowledgement can be ambiguous even when the + // transaction committed. Delete only secrets proven unbound after the + // failure; when that proof cannot be read, leak safely for later cleanup + // instead of deleting a possibly-authoritative credential. + const boundSecretIds = + refs.length === 0 + ? new Set() + : await db + .select({ secretId: companySecretBindings.secretId }) + .from(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, endpoint.companyId), + inArray( + companySecretBindings.secretId, + refs.map((ref) => ref.secretId), + ), + ), + ) + .then((rows) => new Set(rows.map((row) => row.secretId))) + .catch(() => null); + if (boundSecretIds) { + await Promise.allSettled( + refs + .filter((ref) => !boundSecretIds.has(ref.secretId)) + .map((ref) => secrets.remove(ref.secretId)), + ); + } + throw error; + } + const currentIds = new Set(refs.map((ref) => ref.secretId)); + const retired = previousRefs.filter((ref) => !currentIds.has(ref.secretId)); + const results = await Promise.allSettled( + retired.map((ref) => secrets.remove(ref.secretId)), + ); + if (results.some((result) => result.status === "rejected")) { + logger.warn( + { endpointId: endpoint.id }, + "chat credential rotation left an unbound retired secret for later cleanup", + ); + } + return refs; + } + + async function clearCredentials( + endpoint: EndpointRow, + credentialLease: CredentialMutationLeaseGuard, + ) { + let refs: ToolCredentialSecretRef[] = []; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) throw notFound("Chat connection not found"); + refs = current.refs; + await tx + .delete(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, endpoint.companyId), + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, endpoint.connectionId), + ), + ); + await tx + .update(toolConnections) + .set({ credentialSecretRefs: [], updatedAt: new Date() }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + const results = await Promise.allSettled( + refs.map((ref) => secrets.remove(ref.secretId)), + ); + if (results.some((result) => result.status === "rejected")) { + logger.warn( + { endpointId: endpoint.id }, + "chat endpoint removal left an unbound retired secret for later cleanup", + ); + } + } + + async function resolveCredentialRefs( + endpoint: EndpointRow, + refs: ToolCredentialSecretRef[], + database: DbOrTransaction = db, + ): Promise> { + const values: Record = {}; + for (const ref of refs) { + const key = ref.configPath.replace(/^credentials\./, ""); + values[key] = await (database === db ? secrets : secretService(database as Db)).resolveSecretValue( + endpoint.companyId, + ref.secretId, + ref.versionSelector ?? "latest", + { + consumerType: "tool_connection", + consumerId: endpoint.connectionId, + configPath: ref.configPath, + actorType: "system", + actorId: null, + }, + ); + } + return values; + } + + async function resolveCredentials( + endpoint: EndpointRow, + database: DbOrTransaction = db, + ): Promise> { + const connection = await database + .select({ refs: toolConnections.credentialSecretRefs, config: toolConnections.config }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!connection) throw notFound("Chat connection not found"); + const values = await resolveCredentialRefs(endpoint, connection.refs, database); + if (endpoint.provider === "imessage-photon") { + const configuration = photonChannelConfigurationSchema.parse(connection.config.photon); + return { ...values, ...configuration, lineId: configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId }; + } + return values; + } + + async function prepareProviderInventory( + endpoint: EndpointRow, + credentials: Record, + ): Promise<{ + credentials: Record; + inventory: ChatProviderInventoryResult | null; + managementUrl?: string; + }> { + try { + if (endpoint.provider === "slack") return await prepareSlackInventory(credentials, fetchImpl); + if (endpoint.provider === "github") return await prepareGitHubInventory(credentials, fetchImpl); + if (endpoint.provider === "discord") return await prepareDiscordInventory(credentials, fetchImpl); + return { credentials, inventory: null }; + } catch (error) { + throw unprocessable(redactError(error), { + code: "chat_provider_inventory_failed", + }); + } + } + return { normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, legacyGitHubLabelsMatchVerifiedCredentials, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, clearCredentials, resolveCredentialRefs, resolveCredentials, prepareProviderInventory }; +} diff --git a/server/src/services/chat-channels/discord/publication-preparation.ts b/server/src/services/chat-channels/discord/publication-preparation.ts new file mode 100644 index 00000000000..6c9de395c89 --- /dev/null +++ b/server/src/services/chat-channels/discord/publication-preparation.ts @@ -0,0 +1,120 @@ +import { createChatPublicationReplacement } from "../publication-replacement.js"; +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; +import { + eq, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + chatPublications, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; +import { + discordMarkdownRequiresAttachment, +} from "../../chat-discord.js"; + +type PublicationPreparationOptions = { + runPublicationToReplace: ReturnType["runPublicationToReplace"]; +}; +export function createDiscordPublicationPreparation(db: Db, options: PublicationPreparationOptions) { + const { runPublicationToReplace } = options; + async function prepareDiscordPublication(publication: typeof chatPublications.$inferSelect, persisted: SafeChatPublicationPayload): Promise { + if ( + persisted.transportPart || + persisted.card || + persisted.interactionId || + !discordMarkdownRequiresAttachment(persisted.text) + ) { + return publication; + } + + // A normal response can replace its run's working placeholder. Discord's + // adapter cannot add a file while editing, so first turn that placeholder + // into a durable hand-off and then publish the complete Markdown as its + // own ordered attachment row. Publications without a replacement lane + // remain one provider call: a short caption and the lossless file. + const replacesProgressMessage = Boolean( + await runPublicationToReplace(publication, persisted), + ); + return db.transaction(async (tx) => { + const current = await tx + .select() + .from(chatPublications) + .where(eq(chatPublications.id, publication.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) return publication; + const currentPayload = current.payload as SafeChatPublicationPayload; + if ( + currentPayload.transportPart || + currentPayload.card || + currentPayload.interactionId || + !["pending", "retry"].includes(current.state) || + !discordMarkdownRequiresAttachment(currentPayload.text) + ) { + return current; + } + + const batchId = current.id; + const updatedAt = new Date(); + const transportPart = ( + index: number, + count: number, + mode: "inline" | "discord_markdown_attachment", + ) => ({ + batchId, + count, + index, + mode, + orderKey: `${batchId}:${String(index).padStart(4, "0")}`, + }); + if (!replacesProgressMessage) { + const attachmentPayload: SafeChatPublicationPayload = { + ...currentPayload, + transportPart: transportPart(0, 1, "discord_markdown_attachment"), + }; + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: attachmentPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + return { ...current, payload: attachmentPayload, updatedAt }; + } + + const handoffPayload: SafeChatPublicationPayload = { + ...currentPayload, + text: "This response needs a separate attachment because it exceeds the message limit.", + transportPart: transportPart(0, 2, "inline"), + }; + delete handoffPayload.attachmentIds; + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: handoffPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + + const attachmentPayload: SafeChatPublicationPayload = { + ...currentPayload, + transportPart: transportPart(1, 2, "discord_markdown_attachment"), + }; + delete attachmentPayload.progressState; + await notifyChatPublicationWork(tx); + await tx.insert(chatPublications).values({ + companyId: current.companyId, + endpointId: current.endpointId, + conversationId: current.conversationId, + issueId: current.issueId, + commentId: current.commentId, + idempotencyKey: `discord-markdown-attachment:${current.id}`, + payload: attachmentPayload, + state: "pending", + createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, + updatedAt, + }); + return { ...current, payload: handoffPayload, updatedAt }; + }); + } + return { prepareDiscordPublication }; +} diff --git a/server/src/services/chat-channels/discord/question-forms.ts b/server/src/services/chat-channels/discord/question-forms.ts new file mode 100644 index 00000000000..fdf2d08a35b --- /dev/null +++ b/server/src/services/chat-channels/discord/question-forms.ts @@ -0,0 +1,189 @@ +import { createChatSdkStatePersistence } from "../sdk-state.js"; + +import type { DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js"; + +import { + and, + eq, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + chatConversations, + chatExternalPrincipals, + chatPublications, + issueThreadInteractions, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, + AskUserQuestionsInteraction, +} from "@paperclipai/shared"; + +import { + forbidden, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { + type ChatSdkCallbackEvent, +} from "../../chat-sdk-runtime.js"; +import type { + ChatSdkStatePersistence, +} from "../../chat-sdk-state.js"; + +import { + deleteDiscordQuestionFormCorrection, + retainDiscordQuestionFormCorrection, +} from "../../chat-discord-question-forms.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; + +import { + isChatQuestionFormOpenActionId, + loadChatQuestionFormSubmissionToken, + resolveChatQuestionFormOpen, +} from "../../chat-question-forms.js"; +import type { + ModalResponse, + ModalSubmitEvent, +} from "chat"; +import type { EndpointRecord } from "../runtime-types.js"; + +import type { ChatQuestionFormValidationResult } from "../../chat-question-forms.js"; +type LoadedForm = NonNullable>>; +type DiscordFormActor = { principal: typeof chatExternalPrincipals.$inferSelect; userId: string }; +type DiscordFormCorrectionInput = { + record: EndpointRecord; event: ChatSdkCallbackEvent; runtimeContext: RuntimeContext; + conversation: typeof chatConversations.$inferSelect; principal: DiscordFormActor; loaded: LoadedForm; + originalPublication: typeof chatPublications.$inferSelect; providerMessageId: string; + interaction: AskUserQuestionsInteraction; + payload: SafeChatPublicationPayload; validation: Exclude; + deny: (code: string) => Promise; +}; +type DiscordQuestionFormsOptions = { + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + persistence: ChatSdkStatePersistence; +}; +export function createDiscordQuestionForms(db: Db, options: DiscordQuestionFormsOptions) { + const { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, persistence } = options; + const denialError = () => forbidden("This chat form is not a current Paperclip question"); + async function handleDiscordFormCorrection(input: DiscordFormCorrectionInput) { + const { record, event, runtimeContext, conversation, principal, loaded, originalPublication, providerMessageId, interaction, payload, validation, deny } = input; + + const openAction = payload.card?.actions?.find( + (action) => + action.type === "callback" && + isChatQuestionFormOpenActionId(action.actionId), + ); + const openActionId = + openAction?.type === "callback" ? openAction.actionId : null; + if (!openActionId) return deny("chat_modal_open_token_not_current"); + try { + return await db.transaction(async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + const pending = await tx + .select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where( + and( + eq( + issueThreadInteractions.companyId, + record.endpoint.companyId, + ), + eq(issueThreadInteractions.issueId, conversation.issueId), + eq(issueThreadInteractions.id, interaction.id), + eq(issueThreadInteractions.status, "pending"), + ), + ) + .for("update") + .then((rows) => rows[0]); + const resolved = pending + ? await resolveChatQuestionFormOpen(tx, { + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + conversationId: conversation.id, + interaction, + openActionId, + }) + : null; + if ( + !resolved || + resolved.submitActionId !== loaded.payload.formActionId || + resolved.publicationId !== originalPublication.id + ) + throw denialError(); + return retainDiscordQuestionFormCorrection( + createChatSdkStatePersistence(tx as unknown as Db), + { + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + }, + { + principalId: principal.principal.id, + userId: principal.userId!, + externalUserId: event.event.user.userId, + conversationId: conversation.id, + publicationId: originalPublication.id, + providerMessageId, + threadId: conversation.externalThreadId, + interactionId: interaction.id, + openActionId, + submitActionId: resolved.submitActionId, + parentExpiresAt: loaded.payload.expiresAt, + modal: resolved.modal, + values: event.event.values, + fieldErrors: validation.fieldErrors, + }, + ); + }); + } catch (error) { + if ( + isExternalActionAuthorizationChange(error) || + (error && + typeof error === "object" && + "status" in error && + error.status === 403) + ) + return deny("chat_modal_correction_not_authorized"); + throw error; + } + + } + async function acceptDiscordQuestionForm(input: { record: EndpointRecord; event: ChatSdkCallbackEvent; principal: DiscordFormActor; loaded: LoadedForm }): Promise { + const { record, event, principal, loaded } = input; + + try { + await deleteDiscordQuestionFormCorrection( + persistence, + { + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + }, + { + principalId: principal.principal.id, + userId: principal.userId!, + externalUserId: event.event.user.userId, + }, + loaded.payload.formActionId, + ); + } catch { + // The answer receipt is already committed. Cleanup is retryable on + // exact replay/expiry access and cannot relabel an accepted answer. + logger.warn( + { + endpointId: record.endpoint.id, + code: "discord_question_correction_cleanup_failed", + }, + "Discord correction draft cleanup will be retried", + ); + } + + return { action: "clear" }; + } + return { handleDiscordFormCorrection, acceptDiscordQuestionForm }; +} diff --git a/server/src/services/chat-channels/endpoints.ts b/server/src/services/chat-channels/endpoints.ts new file mode 100644 index 00000000000..842860e1036 --- /dev/null +++ b/server/src/services/chat-channels/endpoints.ts @@ -0,0 +1,476 @@ +import { slackSetupState } from "./slack/setup.js"; +import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js"; + +import type { EndpointRow } from "./runtime-types.js"; + +import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js"; + +import { slackChatRegistrationService, slackRegistrationProjection } from "../chat-slack-registration.js"; +import { chatSlackRegistrations } from "@paperclipai/db"; + +import { defaultSlackAppConfiguration } from "@paperclipai/shared"; +import { + randomBytes, + randomUUID, +} from "node:crypto"; +import { + and, + desc, + eq, + ne, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + agents, + chatEndpoints, + toolApplications, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatAdapterCapabilities, + ChatEndpoint, + ChatProvider, + CreateChatEndpointInput, + UpdateChatEndpointInput, +} from "@paperclipai/shared"; +import { + isAgentStatusInvokable, +} from "@paperclipai/shared"; + +import { + badRequest, + conflict, + notFound, + unprocessable, +} from "../../errors.js"; + +import { + logActivity, +} from "../activity-log.js"; + +import type { EndpointRecord } from "./runtime-types.js"; +type ChatEndpointsOptions = { + endpointRecord: (endpointId: string) => Promise; + CAPABILITIES: Record; + PROVIDER_LABELS: Record; + withCredentialMutationLease: ReturnType; + getPublicBaseUrl: () => string | null; + getWebhookPublicBaseUrl: () => string | null; + slackRegistration: Pick, "cleanup" | "managerGrants">; +}; +export function createChatEndpoints(db: Db, options: ChatEndpointsOptions) { + const { endpointRecord, CAPABILITIES, PROVIDER_LABELS, withCredentialMutationLease, getPublicBaseUrl, getWebhookPublicBaseUrl } = options; + + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function providerSetupState( + endpoint: Pick< + EndpointRow, + | "provider" + | "providerAccountId" + | "publicId" + | "status" + | "setup" + | "botUsername" + >, + publicBaseUrl: string | null, + assignedAgentName?: string | null, +) { + const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; + const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null; + const step = endpoint.status === "active" ? "complete" : endpoint.setup.step; + switch (endpoint.provider) { + case "speko": + return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const; + case "imessage-photon": return { step, providerUrl: "https://photon.codes/", testStartedAt: endpoint.setup.testStartedAt } as const; + case "agentmail": return endpoint.setup; + case "slack": { + return slackSetupState({ endpoint, webhookUrl, step, assignedAgentName }); + } + case "github": + return { + step, + github: endpoint.setup.github, + testStartedAt: endpoint.setup.testStartedAt, + testSkipped: endpoint.setup.testSkipped, + authorizationUrl: "https://github.com/settings/apps/new", + providerUrl: "https://github.com/settings/installations", + webhookUrl, + webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, + } as const; + case "discord": + return { + step, + authorizationUrl: "https://discord.com/developers/applications", + providerUrl: endpoint.providerAccountId + ? `https://discord.com/channels/${encodeURIComponent(endpoint.providerAccountId)}` + : "https://discord.com/channels/@me", + webhookUrl, + } as const; + case "microsoft-teams": + return { + step, + authorizationUrl: "https://dev.teams.microsoft.com/apps", + providerUrl: "https://teams.microsoft.com/", + messagingEndpoint: webhookUrl, + } as const; + case "telegram": + return { + step, + providerUrl: endpoint.botUsername + ? `https://t.me/${encodeURIComponent(endpoint.botUsername)}` + : "https://t.me/BotFather", + webhookUrl, + } as const; + } +} + + function serializeEndpoint( + row: Awaited>, + ): ChatEndpoint { + if (!row) throw notFound("Chat endpoint not found"); + const endpoint = row.endpoint; + return { + id: endpoint.id, + companyId: endpoint.companyId, + connectionId: endpoint.connectionId, + publicationMode: endpoint.publicationMode, + externalExecutionPolicy: endpoint.externalExecutionPolicy, + provider: endpoint.provider, + publicId: endpoint.publicId, + status: endpoint.status, + deploymentMode: endpoint.deploymentMode, + assignedAgentId: endpoint.assignedAgentId, + assignedAgentName: row.assignedAgentName, + sponsorUserId: endpoint.sponsorUserId, + providerAccountId: endpoint.providerAccountId, + providerAccountLabel: endpoint.providerAccountLabel, + botExternalId: endpoint.botExternalId, + botUsername: endpoint.botUsername, + botLabel: endpoint.botDisplayName ?? row.assignedAgentName, + botAvatarUrl: endpoint.botAvatarUrl, + communicationInstructions: endpoint.communicationInstructions, + executionDefaults: endpoint.executionDefaults, + ...(endpoint.provider === "imessage-photon" && endpoint.botExternalId ? { photonAllocation: endpoint.botExternalId.startsWith("photon-project:") ? "shared" as const : "dedicated" as const } : {}), + allowDirectMessages: endpoint.allowDirectMessages, + requireAtMention: endpoint.requireAtMention, + allowGroupChats: endpoint.allowGroupChats, + allowUnlinkedPeople: endpoint.allowUnlinkedPeople, + replyMode: "subscribed", + capabilities: endpoint.capabilities, + setup: { + ...providerSetupState( + endpoint, + getWebhookPublicBaseUrl(), + row.assignedAgentName, + ), + ...(endpoint.provider === "github" + ? { + webhookSecretConfigured: row.credentialSecretRefs.some( + (ref) => ref.configPath === "credentials.webhookSecret", + ), + } + : endpoint.provider === "speko" ? { + webhookSecretConfigured: row.credentialSecretRefs.some( + (ref) => ref.configPath === "credentials.signingSecret", + ), + } : {}), + }, + healthMessage: endpoint.healthMessage, + lastError: endpoint.lastError, + lastActivityAt: iso(endpoint.lastEventAt), + lastPublicationAt: iso(endpoint.lastPublicationAt), + activatedAt: iso(endpoint.activatedAt), + createdAt: endpoint.createdAt.toISOString(), + updatedAt: endpoint.updatedAt.toISOString(), + }; + } + + async function list(companyId: string) { + const rows = await db + .select({ + endpoint: chatEndpoints, + assignedAgentName: agents.name, + connectionName: toolConnections.name, + applicationId: toolConnections.applicationId, + credentialSecretRefs: toolConnections.credentialSecretRefs, + }) + .from(chatEndpoints) + .innerJoin( + agents, + and( + eq(agents.id, chatEndpoints.assignedAgentId), + eq(agents.companyId, chatEndpoints.companyId), + ), + ) + .innerJoin( + toolConnections, + and( + eq(toolConnections.id, chatEndpoints.connectionId), + eq(toolConnections.companyId, chatEndpoints.companyId), + ), + ) + .where( + and( + eq(chatEndpoints.companyId, companyId), + ne(chatEndpoints.status, "archived"), + ), + ) + .orderBy(desc(chatEndpoints.updatedAt)); + return rows.map((row) => serializeEndpoint(row)); + } + + async function get(endpointId: string) { + const result = serializeEndpoint(await endpointRecord(endpointId)); + if (result.provider === "slack") { + const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + result.setup.slackRegistration = registration ? slackRegistrationProjection(registration) : undefined; + const publicOrigin = getPublicBaseUrl(); + result.setup.slackOAuthCallbackUri = publicOrigin ? `${publicOrigin}/api/chat-slack/oauth/callback` : null; + } + return result; + } + + async function create( + companyId: string, + input: CreateChatEndpointInput, + actorUserId?: string | null, + ) { + if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); + if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if (input.slackSetupMethod === "managed" && options.slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); + const agent = await db + .select({ id: agents.id, name: agents.name, status: agents.status }) + .from(agents) + .where( + and( + eq(agents.companyId, companyId), + eq(agents.id, input.assignedAgentId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!agent) + throw unprocessable("The selected agent does not belong to this company"); + if (!isAgentStatusInvokable(agent.status)) { + throw unprocessable( + "The selected agent must be active before it can be connected to chat", + { + code: "chat_agent_not_invokable", + }, + ); + } + + const endpointId = randomUUID(); + const publicId = randomBytes(32).toString("base64url"); + const applicationId = input.applicationId ?? randomUUID(); + const connectionId = randomUUID(); + const suffix = endpointId.slice(0, 8); + const name = ( + input.name?.trim() || `${PROVIDER_LABELS[input.provider]} — ${agent.name}` + ).slice(0, 145); + await db.transaction(async (tx) => { + if (input.applicationId) { + const application = await tx + .select({ + id: toolApplications.id, + applicationKey: toolApplications.applicationKey, + metadata: toolApplications.metadata, + status: toolApplications.status, + }) + .from(toolApplications) + .where( + and( + eq(toolApplications.companyId, companyId), + eq(toolApplications.id, input.applicationId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!application) throw notFound("App not found"); + const sourceTemplateKey = + typeof application.metadata.sourceTemplateKey === "string" + ? application.metadata.sourceTemplateKey + : null; + if ( + application.status === "archived" || + (application.applicationKey !== input.provider && + sourceTemplateKey !== input.provider) + ) { + throw unprocessable( + `The selected App is not the ${PROVIDER_LABELS[input.provider]} connector`, + ); + } + } else { + await tx.insert(toolApplications).values({ + id: applicationId, + companyId, + applicationKey: `chat:${input.provider}:${endpointId}`, + name: `${name} ${suffix}`, + description: `People talk to ${agent.name} through ${PROVIDER_LABELS[input.provider]}.`, + type: "chat", + status: "draft", + metadata: { sourceTemplateKey: input.provider, purpose: "channel" }, + ownerUserId: actorUserId ?? null, + }); + } + await tx.insert(toolConnections).values({ + id: connectionId, + companyId, + applicationId, + name, + uid: `chat-${input.provider}-${endpointId}`, + connectionKind: "managed", + connectionPurpose: "channel", + ownership: "customer", + transport: input.provider === "speko" ? "voice" : "chat_sdk", + authKind: "api_key", + credentialPolicy: "shared", + status: "draft", + enabled: false, + config: { provider: input.provider }, + transportConfig: {}, + createdByUserId: actorUserId ?? null, + }); + await tx.insert(chatEndpoints).values({ + id: endpointId, + companyId, + connectionId, + provider: input.provider, + publicId, + assignedAgentId: agent.id, + sponsorUserId: actorUserId ?? null, + // Group chat is a broad Teams reach grant rather than a discovered + // destination toggle. Keep it closed until the operator explicitly + // enables that surface, even when this process is running against a + // database created before the column default was hardened. + requireAtMention: input.provider === "slack", + allowGroupChats: !["microsoft-teams", "speko"].includes(input.provider), + allowUnlinkedPeople: !["slack", "imessage-photon", "speko"].includes(input.provider), + capabilities: CAPABILITIES[input.provider], + setup: { + step: "provider_setup", + ...(input.provider === "slack" + ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), + command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? options.slackRegistration.managerGrants.defaultMethod() } + : {}), + }, + }); + }); + await logActivity(db, { + companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.created", + entityType: "tool_connection", + entityId: connectionId, + details: { + endpointId, + provider: input.provider, + assignedAgentId: agent.id, + }, + }); + return get(endpointId); + } + + async function update( + endpointId: string, + input: UpdateChatEndpointInput, + actorUserId?: string | null, + ) { + const initial = await endpointRecord(endpointId); + if (!initial) throw notFound("Chat endpoint not found"); + if (initial.endpoint.provider === "agentmail" && Object.keys(input).some((key) => key !== "executionDefaults")) throw badRequest("Use the email inbox API for AgentMail"); + await withCredentialMutationLease( + initial.endpoint, + async (credentialLease) => { + // Reach changes share the provider-transport lane with publications. + // Once a disable returns, no send that sampled the previous grant can + // still begin or finish behind it. + const existing = await endpointRecord(endpointId); + if (!existing) throw notFound("Chat endpoint not found"); + const values: Partial = { + updatedAt: new Date(), + }; + if (input.communicationInstructions !== undefined) { + if (existing.endpoint.provider !== "slack") { + throw unprocessable("Communication instructions are currently supported for Slack connections"); + } + values.communicationInstructions = input.communicationInstructions; + } + if (input.slackSetupMethod === "managed" && options.slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); + if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) { + throw conflict("Setup method can only change before connecting the app"); + } + if (input.slackApp || input.slackSetupMethod) { + const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + if (registration && (registration.managerGrantId || registration.status !== "failed")) { + if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); + // Manual recovery fences pending authorization under the same credential lease. + await options.slackRegistration.cleanup(endpointId, credentialLease); + } + } + if (input.slackSetupMethod) { + values.setup = { ...existing.endpoint.setup, slackSetupMethod: input.slackSetupMethod }; + } + if (input.slackApp) { + if (existing.endpoint.provider !== "slack") { + throw unprocessable("Slack app details only apply to Slack connections"); + } + if (existing.endpoint.status !== "draft" || existing.endpoint.botExternalId) { + throw conflict("Slack app details can only be edited before connecting the app"); + } + values.setup = { + ...(values.setup ?? existing.endpoint.setup), + slackApp: input.slackApp, + command: input.slackApp.command, + }; + } + if (input.allowDirectMessages !== undefined) + values.allowDirectMessages = input.allowDirectMessages; + if (input.requireAtMention !== undefined) { + if (existing.endpoint.provider !== "slack") + throw unprocessable("Require at-mention only applies to Slack connections"); + values.requireAtMention = input.requireAtMention; + } + if (input.allowGroupChats && existing.endpoint.provider === "imessage-photon" && (!existing.endpoint.botExternalId || existing.endpoint.botExternalId.startsWith("photon-project:"))) + throw unprocessable("Photon shared channels support direct messages only; groups require a dedicated channel"); + if (input.allowGroupChats !== undefined) + values.allowGroupChats = input.allowGroupChats; + if (input.allowUnlinkedPeople !== undefined) + values.allowUnlinkedPeople = input.allowUnlinkedPeople; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + if (input.executionDefaults !== undefined) { + await assertChatExecutionDefaultsAccess(db, { + companyId: existing.endpoint.companyId, + assigneeAgentId: values.assignedAgentId ?? existing.endpoint.assignedAgentId, + actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, + defaults: input.executionDefaults, + }, tx); + values.executionDefaults = input.executionDefaults; + } + await tx + .update(chatEndpoints) + .set(values) + .where(eq(chatEndpoints.id, endpointId)); + await logActivity(tx as unknown as Db, { + companyId: existing.endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.updated", + entityType: "tool_connection", + entityId: existing.endpoint.connectionId, + details: { endpointId, fields: Object.keys(input) }, + }); + await credentialLease.assertOwned(tx); + }); + }, + ); + return get(endpointId); + } + return { list, get, create, update }; +} diff --git a/server/src/services/chat-channels/github/message-provenance.ts b/server/src/services/chat-channels/github/message-provenance.ts new file mode 100644 index 00000000000..5ce5d4dcbdd --- /dev/null +++ b/server/src/services/chat-channels/github/message-provenance.ts @@ -0,0 +1,32 @@ +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; +import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; + +import { + chatDeliveries, +} from "@paperclipai/db"; + +import type { + Message, +} from "chat"; + +type GitHubMessageProvenanceOptions = { + githubManualMessages: WeakMap; + githubAutomaticMessages: WeakMap; + githubIssueMessages: WeakMap; +}; +export function createGitHubMessageProvenance(options: GitHubMessageProvenanceOptions) { + const { githubManualMessages, githubAutomaticMessages, githubIssueMessages } = options; + function restoreGitHubMessageProvenance(delivery: typeof chatDeliveries.$inferSelect, endpointRuntime: Pick, message: Message): void { +const githubManual = delivery.normalizedEvent.githubManual; + if (endpointRuntime.provider === "github" && githubManual && typeof githubManual === "object") githubManualMessages.set(message, githubManual as { policy: GitHubReviewPolicy; revision: number; event: "mention" | "comment" }); + const githubAutomatic = delivery.normalizedEvent.githubAutomatic; + if (endpointRuntime.provider === "github" && githubAutomatic && typeof githubAutomatic === "object") { + githubAutomaticMessages.set(message, githubAutomatic as { context: GitHubReviewEventContext; revision: number; policy: GitHubReviewPolicy }); + } + const githubIssue = delivery.normalizedEvent.githubIssue; + if (endpointRuntime.provider === "github" && githubIssue && typeof githubIssue === "object") { + githubIssueMessages.set(message, githubIssue as { context: GitHubIssueEventContext; revision: number; policy: GitHubReviewPolicy }); + } + } + return { restoreGitHubMessageProvenance }; +} diff --git a/server/src/services/chat-channels/github/setup.ts b/server/src/services/chat-channels/github/setup.ts new file mode 100644 index 00000000000..54f21322fd3 --- /dev/null +++ b/server/src/services/chat-channels/github/setup.ts @@ -0,0 +1,69 @@ +import { createGitHubAppSetup } from "./app-setup.js"; + +import type { EndpointRow } from "../runtime-types.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { githubAppJwt } from "../../chat-github-client.js"; + +import type { Db } from "@paperclipai/db"; + +import { + forbidden, +} from "../../../errors.js"; + +import { + logActivity, +} from "../../activity-log.js"; +import { + resyncGitHubAppWebhook, +} from "../../chat-github-webhook-config.js"; + +type GitHubSetupOptions = { + fetchImpl: typeof globalThis.fetch; + getWebhookPublicBaseUrl: () => string | null; + githubWizard: Pick["githubWizard"], "repairCloud">; +}; +export function createGitHubSetup(db: Db, options: GitHubSetupOptions) { + const { fetchImpl, getWebhookPublicBaseUrl } = options; + + async function repairGitHubCloudSetup(endpoint: EndpointRow, credentialLease: CredentialMutationLeaseGuard, actorUserId?: string | null) { + if (!actorUserId) throw forbidden("A configuring member is required to recover this App"); + await credentialLease.assertOwned(); + await options.githubWizard.repairCloud(endpoint.id, actorUserId); + await credentialLease.assertOwned(); + } + + async function repairGitHubWebhookSetup(endpoint: EndpointRow, credentials: Record, credentialLease: CredentialMutationLeaseGuard, actorUserId?: string | null) { + // The immutable App and its installation were verified above. Repair + // only its callback using the stored secret; never reinstall it or + // change repository access. Keep historical signed-ping evidence, but + // require the fresh test round trip established above before activation. + const auditWebhookSync = async (action: string) => { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action, + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: { endpointId: endpoint.id, provider: "github" }, + }); + await credentialLease.assertOwned(tx); + }); + }; + await auditWebhookSync("chat_endpoint.webhook_sync_started"); + await credentialLease.assertOwned(); + await resyncGitHubAppWebhook({ + fetch: fetchImpl, + appToken: githubAppJwt(credentials.appId, credentials.privateKey), + webhookUrl: `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/github`, + webhookSecret: credentials.webhookSecret, + }); + await auditWebhookSync("chat_endpoint.webhook_synced"); + } + + return { repairGitHubCloudSetup, repairGitHubWebhookSetup }; +} diff --git a/server/src/services/chat-channels/imessage-photon/destination-admission.ts b/server/src/services/chat-channels/imessage-photon/destination-admission.ts new file mode 100644 index 00000000000..46c2f25040d --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/destination-admission.ts @@ -0,0 +1,29 @@ +import type { Thread } from "chat"; + +import { chatEndpointResources } from "@paperclipai/db"; +import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js"; + +type DestinationAdmissionInput = { + endpoint: EndpointRow; + currentEndpoint: Pick; + thread: Thread; + +}; +type DestinationAdmissionOptions = { + ensureResource: (endpoint: EndpointRow, thread: Thread, enableForSetup: boolean, database?: DbOrTransaction) => Promise; +}; +export function createPhotonDestinationAdmission(options: DestinationAdmissionOptions) { + const { ensureResource } = options; + async function admitPhotonDestination(input: DestinationAdmissionInput, tx: DbTransaction) { + const { endpoint, currentEndpoint, thread } = input; + let destinationAccepting = true; + + const resource = await ensureResource(endpoint, thread, false, tx); + destinationAccepting = + resource.enabled && + resource.availability === "available" && + currentEndpoint.allowGroupChats; + return destinationAccepting; + } + return { admitPhotonDestination }; +} diff --git a/server/src/services/chat-channels/imessage-photon/publications.ts b/server/src/services/chat-channels/imessage-photon/publications.ts index d85b4410cb5..bef81fd99e7 100644 --- a/server/src/services/chat-channels/imessage-photon/publications.ts +++ b/server/src/services/chat-channels/imessage-photon/publications.ts @@ -1,4 +1,285 @@ -import type { SafeChatPublicationPayload } from "@paperclipai/shared"; +import { createChatPublicationReplacement } from "../publication-replacement.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "../../photon/interactions.js"; +import { PhotonError } from "../../photon/cloud.js"; +import { PhotonChatAdapter } from "../../photon/adapter.js"; +import { + and, + desc, + eq, + or, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + chatActions, + chatEndpointResources, + chatMessageLinks, + heartbeatRuns, + issueThreadInteractions, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; +import { safeChatTaskUrl } from "../../chat-task-url.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; + +import type { + FileUpload, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +import type { PublicationTransportInput } from "../publications/types.js"; +import type { Thread } from "chat"; +type PhotonPublicationDeliveryOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + runtime: Pick; + receiptReactionCompletionRunId: ReturnType["receiptReactionCompletionRunId"]; + getTaskBaseUrl: () => string | null | undefined; +}; +export function createPhotonPublicationDelivery(db: Db, options: PhotonPublicationDeliveryOptions) { + const { endpointRecord, runtimeGeneration, runtime, receiptReactionCompletionRunId, getTaskBaseUrl } = options; + async function deliverPhotonPublication(input: PublicationTransportInput, endpointRuntime: ChatEndpointRuntime, thread: Pick, text: string, files: FileUpload[]) { + const adapter = endpointRuntime.getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) + throw new Error("Photon publication runtime unavailable"); + const assertCurrent = async () => { + await input.beforePhotonWrite?.(); + const current = await endpointRecord(input.endpoint.id); + if ( + !current || + !["verifying", "active"].includes(current.endpoint.status) || + runtimeGeneration(current.endpoint.setup) !== + runtimeGeneration(input.endpoint.setup) || + runtime.get(input.endpoint.id) !== endpointRuntime + ) + throw new PhotonError( + "rejected", + "Photon publication authority changed", + ); + const resource = await db + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, current.endpoint.companyId), + eq(chatEndpointResources.endpointId, current.endpoint.id), + eq(chatEndpointResources.id, input.conversation.resourceId!), + ), + ) + .then((rows) => rows[0]); + if ( + resource?.availability !== "available" || + (!input.conversation.isDirectMessage && !resource.enabled) || + (input.conversation.isDirectMessage && + !current.endpoint.allowDirectMessages) + ) + throw new PhotonError( + "rejected", + "Photon conversation is no longer enabled", + ); + }; + const retry = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.kind, "photon_publication_retry"), + eq( + chatActions.providerActionId, + `photon-retry:${input.publication.id}:${input.publication.attempts + 1}`, + ), + eq(chatActions.status, "processed"), + ), + ) + .then((rows) => rows[0]); + const retryUnknown = Boolean(retry); + const nextQuestion = + /^photon-question:([a-zA-Z0-9_-]{8,24}):(\d{1,2})$/.exec( + input.publication.idempotencyKey, + ); + if ( + input.payload.interactionId && + (input.publication.idempotencyKey === + `interaction:${input.payload.interactionId}:${input.endpoint.id}` || + nextQuestion) + ) { + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.kind, "photon_interaction"), + nextQuestion + ? eq(chatActions.providerActionId, `photon:${nextQuestion[1]}`) + : eq( + sql`${chatActions.payload}->>'publicationId'`, + input.publication.id, + ), + eq(chatActions.status, "issued"), + ), + ) + .then((rows) => rows[0]); + const interaction = ( + await issueThreadInteractionService(db).listForIssue( + input.publication.issueId, + ) + ).find((candidate) => candidate.id === input.payload.interactionId); + if ( + action && + interaction?.status === "pending" && + nativePhotonInteraction(interaction) + ) { + const promptGuard = async () => { + await assertCurrent(); + const current = await db + .select({ status: issueThreadInteractions.status }) + .from(issueThreadInteractions) + .where( + and( + eq( + issueThreadInteractions.companyId, + input.endpoint.companyId, + ), + eq(issueThreadInteractions.id, interaction.id), + ), + ) + .then((rows) => rows[0]); + if (current?.status !== "pending") + throw new PhotonError( + "rejected", + "This interaction has already been resolved", + ); + }; + const receipt = await publishPhotonPrompt({ + adapter, + threadId: thread.id, + binding: action.payload as unknown as PhotonInteractionBinding, + interaction, + questionIndex: nextQuestion ? Number(nextQuestion[2]) : 0, + taskUrl: safeChatTaskUrl( + getTaskBaseUrl(), + input.publication.issueId, + ), + assertCurrent: promptGuard, + retryUnknown, + }); + return { id: receipt.promptMessageGuid }; + } + if (nextQuestion) + throw new PhotonError( + "rejected", + "This question is no longer available", + ); + } + const reply = await adapter.state.read<{ guid: string | null }>( + `publication-reply:${input.publication.id}`, + ); + let replyTo = reply?.guid ?? null; + if (!reply) { + const sourceRunId = await receiptReactionCompletionRunId( + db, + input.publication, + input.payload, + ); + if (sourceRunId) { + const source = await db + .select({ guid: chatMessageLinks.providerMessageId }) + .from(heartbeatRuns) + .innerJoin( + chatMessageLinks, + and( + eq(chatMessageLinks.companyId, input.endpoint.companyId), + eq(chatMessageLinks.endpointId, input.endpoint.id), + eq(chatMessageLinks.conversationId, input.conversation.id), + eq(chatMessageLinks.direction, "inbound"), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), + ), + ) + .where( + and( + eq(heartbeatRuns.id, sourceRunId), + eq(heartbeatRuns.companyId, input.endpoint.companyId), + ), + ) + .orderBy(desc(chatMessageLinks.createdAt)) + .limit(1) + .then((rows) => rows[0]); + replyTo = source?.guid ?? null; + } + replyTo = ( + await adapter.state.update<{ guid: string | null }>( + `publication-reply:${input.publication.id}`, + (current) => current ?? { guid: replyTo }, + ) + ).guid; + } + const sent = await adapter.publish( + thread.id, + input.publication.id, + { markdown: text, files }, + { assertCurrent, retryUnknown, replyTo: replyTo ?? undefined }, + ); + if (input.publication.idempotencyKey.startsWith("photon-response:")) { + const notice = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.kind, "photon_response_notice"), + eq( + chatActions.providerActionId, + `photon-notice:${input.publication.idempotencyKey}`, + ), + ), + ) + .then((rows) => rows[0]); + if ( + notice && + typeof notice.payload.reference === "string" && + typeof notice.payload.questionIndex === "number" && + typeof notice.payload.publicationId === "string" + ) { + const receipt: PhotonPromptReceipt = { + schema: 1, + reference: notice.payload.reference, + questionIndex: notice.payload.questionIndex, + publicationId: notice.payload.publicationId, + promptMessageGuid: sent.id, + promptMessageGuids: sent.messageIds, + options: {}, + }; + for (const guid of sent.messageIds) + await adapter.state.update( + `prompt-message:${guid}`, + (current) => current ?? receipt, + ); + } + } + return sent; + } + return { deliverPhotonPublication }; +} + export function imessageProgressSupersessionReason( progress: SafeChatPublicationPayload["progressState"], @@ -6,4 +287,4 @@ export function imessageProgressSupersessionReason( return progress && ["queued", "working"].includes(progress) ? "iMessage uses typing instead of progress bubbles" : null; -} +} \ No newline at end of file diff --git a/server/src/services/chat-channels/imessage-photon/setup.ts b/server/src/services/chat-channels/imessage-photon/setup.ts new file mode 100644 index 00000000000..106342e9b09 --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/setup.ts @@ -0,0 +1,88 @@ +import type { createPhotonCredentialChecks } from "./credentials.js"; + +import type { EndpointRow } from "../runtime-types.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { photonSharedScope, photonSharedIdentity } from "../../photon/cloud.js"; +import { photonChannelConfigurationSchema } from "@paperclipai/shared"; + +import { + and, + eq, + ne, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ConfigureChatEndpointInput, +} from "@paperclipai/shared"; + +import { + conflict, + notFound, +} from "../../../errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type PhotonSetupInspectionOptions = { + endpointRecord: (endpointId: string) => Promise; + inspectPhotonCredentials: ReturnType["inspectPhotonCredentials"]; +}; +export function createPhotonSetupInspection(db: Db, options: PhotonSetupInspectionOptions) { + const { endpointRecord, inspectPhotonCredentials } = options; + + + async function inspectPhoton( + endpointId: string, + input: { projectId: string; projectSecret: string }, + ) { + const record = await endpointRecord(endpointId); + if (!record || record.endpoint.provider !== "imessage-photon") + throw notFound("iMessage Photon endpoint not found"); + const inspection = structuredClone(await inspectPhotonCredentials( + input.projectId, + input.projectSecret, + )); + const reserved = await db + .select({ number: chatEndpoints.botExternalId }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.provider, "imessage-photon"), + ne(chatEndpoints.id, endpointId), + ne(chatEndpoints.status, "archived"), + ), + ); + for (const line of inspection.lines) { + if (reserved.some((row) => row.number === line.phoneNumber)) { + line.eligible = false; + line.unavailableReason = + "This number already belongs to another channel"; + } + } + inspection.eligible = inspection.allocation === "shared" ? inspection.eligible && !reserved.some((row) => row.number === photonSharedIdentity(inspection.projectId)) : inspection.lines.some((line) => line.eligible); + return inspection; + } + return { inspectPhoton }; +} +export function preparePhotonConfiguration(endpoint: EndpointRow, input: ConfigureChatEndpointInput, credentials: Record): Record { + const configuration = photonChannelConfigurationSchema.parse(input.photon ?? (credentials.allocation === "shared" ? { allocation: "shared", projectId: credentials.projectId } : { projectId: credentials.projectId, lineId: credentials.lineId })); + const lineId = configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId; + if (endpoint.botExternalId && (configuration.projectId !== endpoint.providerAccountId || credentials.lineId && lineId !== credentials.lineId)) throw conflict("A different Photon identity requires a new channel"); + return { ...credentials, ...configuration, lineId }; + } + +export function createPhotonConfiguration(db: Db, options: { invalidateRuntime: (endpointId: string) => Promise }) { + const { invalidateRuntime } = options; + async function persistPhotonConfiguration(endpoint: EndpointRow, credentials: Record, credentialLease: CredentialMutationLeaseGuard) { + await invalidateRuntime(endpoint.id); + await credentialLease.assertOwned(); + if (credentials.allocation === "shared") await db.update(chatEndpoints).set({ allowGroupChats: false }).where(eq(chatEndpoints.id, endpoint.id)); + await db.update(toolConnections).set({ config: { provider: endpoint.provider, photon: credentials.allocation === "shared" ? { allocation: "shared", projectId: credentials.projectId } : { allocation: "dedicated", projectId: credentials.projectId, lineId: credentials.lineId } } }).where(and(eq(toolConnections.companyId, endpoint.companyId), eq(toolConnections.id, endpoint.connectionId))); + } + return { persistPhotonConfiguration }; +} diff --git a/server/src/services/chat-channels/inbound/admission.ts b/server/src/services/chat-channels/inbound/admission.ts new file mode 100644 index 00000000000..13fd1c0ae61 --- /dev/null +++ b/server/src/services/chat-channels/inbound/admission.ts @@ -0,0 +1,399 @@ +import { createTeamsDestinationAdmission } from "../microsoft-teams/destination-admission.js"; +import { createTelegramDestinationAdmission } from "../telegram/destination-admission.js"; +import { createPhotonDestinationAdmission } from "../imessage-photon/destination-admission.js"; + +import { deliveryContentWasRedacted } from "./delivery-ledger.js"; + +import type { EndpointRow, DbTransaction, DbOrTransaction, InboundRuntimeContext } from "../runtime-types.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import { + githubMessageAddressesAnotherBot, +} from "../../chat-github-events.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatDeliveries, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import type { + Message, + Thread, +} from "chat"; + +import type { DiscordGatewayOwnership } from "../runtime-types.js"; +import type { InboundEnvelope } from "./envelope.js"; +import type { ChatSdkMessageCallbackEvent } from "../../chat-sdk-runtime.js"; + +type InboundAdmissionInput = { + endpoint: EndpointRow; + thread: Thread; + message: Message; + trigger: ChatSdkMessageCallbackEvent["trigger"]; + runtimeContext?: InboundRuntimeContext; + admittedDeliveryId: string | null; + deferDrainUntilFollowup: boolean; + scheduledAt: number | null; + envelope: Pick; +}; +type GatewayAdmissionState = { + lost?: DiscordGatewayOwnership; + renewed?: { expiresAt: Date; ownership: DiscordGatewayOwnership }; +}; +type InboundAdmissionOptions = { + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + leasedChatProvider: (provider: string) => boolean; + renewDiscordGatewayOwnershipForMessageAdmission: (tx: DbTransaction, endpointId: string, context: InboundRuntimeContext) => Promise<{ kind: "owned"; expiresAt: Date; ownership: DiscordGatewayOwnership } | { kind: "lost"; ownership: DiscordGatewayOwnership | null }>; + ensureResource: (endpoint: EndpointRow, thread: Thread, enableForSetup: boolean, database?: DbOrTransaction) => Promise; + DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number; +}; +export function createInboundAdmission(db: Db, options: InboundAdmissionOptions) { + const { runtimeGeneration, credentialFingerprint, leasedChatProvider, renewDiscordGatewayOwnershipForMessageAdmission, ensureResource, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options; + const { admitTeamsDestination } = createTeamsDestinationAdmission({ ensureResource }); + const { admitTelegramDestination } = createTelegramDestinationAdmission({ ensureResource }); + const { admitPhotonDestination } = createPhotonDestinationAdmission({ ensureResource }); + async function admitInboundMessage(input: InboundAdmissionInput, discordGatewayAdmissionState: GatewayAdmissionState) { + const { endpoint, thread, message, trigger, runtimeContext, admittedDeliveryId, deferDrainUntilFollowup, scheduledAt, envelope } = input; + const { providerEventId, addressed, eventKind, normalized, redactedDestinationNormalized, githubAutomatic, githubIssue } = envelope; + return await db.transaction(async (tx) => { + const currentEndpoint = await tx + .select({ + status: chatEndpoints.status, + setup: chatEndpoints.setup, + allowDirectMessages: chatEndpoints.allowDirectMessages, + requireAtMention: chatEndpoints.requireAtMention, + allowGroupChats: chatEndpoints.allowGroupChats, + botUsername: chatEndpoints.botUsername, + }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!currentEndpoint) return null; + // An SDK callback can finish parsing after a pause/resume cycle. Each + // runtime captures the durable endpoint generation at registration; + // resume advances it under the same row lock used by admission. This + // rejects stale callbacks without relying on provider/server clock sync. + const currentRuntimeGeneration = runtimeGeneration(currentEndpoint.setup); + const currentCredentialFingerprint = runtimeContext + ? await tx + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .then((rows) => credentialFingerprint(rows[0]?.refs ?? [])) + : null; + const staleActivation = + runtimeContext !== undefined && + (runtimeContext.generation !== currentRuntimeGeneration || + runtimeContext.credentialFingerprint !== + currentCredentialFingerprint); + const endpointAccepting = + ["verifying", "active"].includes(currentEndpoint.status) && + !staleActivation; + if ( + endpointAccepting && + leasedChatProvider(endpoint.provider) && + runtimeContext && + !admittedDeliveryId + ) { + // The first process-local ownership check can precede a host pause. + // Reclaim the same durable token while the endpoint admission row is + // locked so a standby takeover cannot be followed by an obsolete + // callback write. This renews only the exact token that authenticated + // the callback; generation and credential checks above remain intact. + const gatewayOwnership = + await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpoint.id, + runtimeContext, + ); + if (gatewayOwnership.kind === "lost") { + if (gatewayOwnership.ownership) { + discordGatewayAdmissionState.lost = gatewayOwnership.ownership; + } + return null; + } + discordGatewayAdmissionState.renewed = gatewayOwnership; + } + let provisionalTeamsSetupReply = false; + let destinationAccepting = true; + if (endpointAccepting && thread.isDM) { + destinationAccepting = currentEndpoint.allowDirectMessages; + } + if ( + endpointAccepting && + endpoint.provider === "microsoft-teams" && + !thread.isDM + ) { + ({ destinationAccepting, provisionalTeamsSetupReply } = await admitTeamsDestination({ endpoint, currentEndpoint, thread, message, addressed }, tx)); + } + if ( + endpointAccepting && + endpoint.provider === "telegram" && + !thread.isDM + ) { + destinationAccepting = await admitTelegramDestination({ endpoint, currentEndpoint, thread, addressed }, tx); + } + if ( + endpointAccepting && + endpoint.provider === "imessage-photon" && + !thread.isDM + ) { + destinationAccepting = await admitPhotonDestination({ endpoint, currentEndpoint, thread }, tx); + } + const mentionRequired = + endpoint.provider === "slack" && + currentEndpoint.requireAtMention && + trigger !== "mention" && + message.isMention !== true; + // GitHub sends the same comment to every installed App. Subscribed + // conversations are a fallback for unaddressed replies, not permission + // to wake every bot when the person names a different connected App. + // Recheck inside durable admission so recovered deliveries follow the + // same company-scoped routing decision before any task or reaction. + const githubAddressedElsewhere = + endpointAccepting && + endpoint.provider === "github" && + !githubAutomatic && + !githubIssue && + (await githubMessageAddressesAnotherBot(tx, { + id: endpoint.id, + companyId: endpoint.companyId, + botUsername: currentEndpoint.botUsername, + }, message.text)); + const accepting = endpointAccepting && destinationAccepting && !githubAddressedElsewhere && !mentionRequired; + const redactDestinationDelivery = + githubAddressedElsewhere || mentionRequired || (!accepting && thread.isDM) || + + (!thread.isDM && + (endpoint.provider === "microsoft-teams" || + endpoint.provider === "telegram" || + endpoint.provider === "imessage-photon") && + (!accepting || provisionalTeamsSetupReply)); + const ignoredAt = accepting ? null : new Date(); + const inactiveReason = !endpointAccepting + ? staleActivation + ? "Connection activation changed before admission" + : "Connection is not active" + : githubAddressedElsewhere + ? "GitHub message explicitly mentions a different connected bot" + : mentionRequired + ? "Message did not @mention the bot" + : endpoint.provider === "telegram" && !thread.isDM && !addressed + ? "Message did not address the agent" + : "Destination is not enabled in Paperclip"; + let candidate = admittedDeliveryId + ? await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, admittedDeliveryId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + if (!admittedDeliveryId) { + await notifyChatDeliveryWork(tx); + const [delivery] = await tx + .insert(chatDeliveries) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind, + normalizedEvent: redactDestinationDelivery + ? redactedDestinationNormalized + : deferDrainUntilFollowup && accepting + ? { + ...normalized, + providerThreadPending: true, + } + : normalized, + state: accepting + ? deferDrainUntilFollowup + ? "retry" + : "received" + : "filtered", + nextAttemptAt: + accepting && deferDrainUntilFollowup + ? new Date( + Date.now() + DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS, + ) + : accepting && scheduledAt + ? new Date(scheduledAt) + : null, + redactedError: + accepting && deferDrainUntilFollowup + ? "Waiting for Discord thread creation confirmation" + : accepting + ? null + : inactiveReason, + processedAt: ignoredAt, + }) + .onConflictDoNothing() + .returning(); + candidate = delivery ?? null; + } + if (!candidate && !admittedDeliveryId) { + const existingDelivery = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + if (existingDelivery) { + const hydrateTeamsDelivery = + endpoint.provider === "microsoft-teams" && + !thread.isDM && + accepting && + !provisionalTeamsSetupReply && + ["received", "retry", "processing"].includes( + existingDelivery.state, + ) && + deliveryContentWasRedacted(existingDelivery.normalizedEvent); + await notifyChatDeliveryWork(tx); + [candidate] = await tx + .update(chatDeliveries) + .set({ + // Increment under PostgreSQL's row lock so simultaneous handler + // fanout and provider retries cannot lose duplicate telemetry. + normalizedEvent: redactDestinationDelivery + ? sql`${JSON.stringify(redactedDestinationNormalized)}::jsonb + || jsonb_build_object( + 'deduplication', + jsonb_build_object( + 'duplicateCount', + coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, + 'lastDuplicateAt', + ${new Date().toISOString()}::text + ) + )` + : hydrateTeamsDelivery + ? sql`${JSON.stringify(normalized)}::jsonb + || jsonb_build_object( + 'deduplication', + coalesce(${chatDeliveries.normalizedEvent}->'deduplication', '{}'::jsonb) + || jsonb_build_object( + 'duplicateCount', + coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, + 'lastDuplicateAt', + ${new Date().toISOString()}::text + ) + )` + : sql`coalesce(${chatDeliveries.normalizedEvent}, '{}'::jsonb) + || jsonb_build_object( + 'deduplication', + coalesce(${chatDeliveries.normalizedEvent}->'deduplication', '{}'::jsonb) + || jsonb_build_object( + 'duplicateCount', + coalesce((${chatDeliveries.normalizedEvent}#>>'{deduplication,duplicateCount}')::integer, 0) + 1, + 'lastDuplicateAt', + ${new Date().toISOString()}::text + ) + )`, + ...(!accepting && + ["received", "retry"].includes(existingDelivery.state) + ? { + state: "filtered" as const, + nextAttemptAt: null, + redactedError: inactiveReason, + processedAt: ignoredAt, + } + : {}), + updatedAt: new Date(), + }) + .where(eq(chatDeliveries.id, existingDelivery.id)) + .returning(); + } + } + if ( + candidate && + admittedDeliveryId && + accepting && + !provisionalTeamsSetupReply && + endpoint.provider === "microsoft-teams" && + !thread.isDM && + ["received", "retry", "processing"].includes(candidate.state) && + deliveryContentWasRedacted(candidate.normalizedEvent) + ) { + [candidate] = await tx + .update(chatDeliveries) + .set({ normalizedEvent: normalized, updatedAt: new Date() }) + .where(eq(chatDeliveries.id, candidate.id)) + .returning(); + } + if ( + candidate && + admittedDeliveryId && + !accepting && + ["received", "retry", "processing"].includes(candidate.state) + ) { + await notifyChatDeliveryWork(tx); + [candidate] = await tx + .update(chatDeliveries) + .set({ + state: "filtered", + ...(redactDestinationDelivery + ? { + normalizedEvent: redactedDestinationNormalized, + principalId: null, + } + : {}), + nextAttemptAt: null, + redactedError: inactiveReason, + processedAt: ignoredAt, + updatedAt: new Date(), + }) + .where(eq(chatDeliveries.id, candidate.id)) + .returning(); + } + if (!accepting && ignoredAt) { + await tx + .update(chatEndpoints) + .set({ lastEventAt: ignoredAt, updatedAt: ignoredAt }) + .where(eq(chatEndpoints.id, endpoint.id)); + } + return { + accepting, + candidate: candidate ?? null, + provisionalTeamsSetupReply, + }; + }); + } + return { admitInboundMessage }; +} diff --git a/server/src/services/chat-channels/inbound/authorization.ts b/server/src/services/chat-channels/inbound/authorization.ts new file mode 100644 index 00000000000..cb3f330a3d3 --- /dev/null +++ b/server/src/services/chat-channels/inbound/authorization.ts @@ -0,0 +1,429 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../runtime-types.js"; + +import { + stableExternalPrincipalId, +} from "../message-lifecycle.js"; + +import { voiceSessionStore } from "../../voice/voice-session-store.js"; + +import { githubChatPrincipalAccess } from "../../chat-github-access.js"; + +import { + and, + eq, + inArray, + ne, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatEndpointResources, + chatExternalPrincipals, + chatIdentityLinks, + companyMemberships, +} from "@paperclipai/db"; + +import { + HttpError, + forbidden, +} from "../../../errors.js"; + +import type { + Author, + Thread, +} from "chat"; +import { canonicalProviderResourceId, chatSurfaceKind, providerResourceLabelFromThread, providerResourceType, nonDirectDestinationAllowed } from "./surfaces.js"; +type ResourceRow = typeof chatEndpointResources.$inferSelect; +type InboundAuthorityOptions = { + voiceStore: Pick, "authorizePrincipal">; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; +}; +export function createChatInboundAuthority(db: Db, options: InboundAuthorityOptions) { + const { voiceStore, runtimeCallbackEndpoint } = options; + + + async function findAuthorizedIdentityLink(endpoint: EndpointRow, userId: string) { + return db.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks) + .innerJoin(companyMemberships, and( + eq(companyMemberships.companyId, chatIdentityLinks.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"))) + .limit(1).then((rows) => rows[0] ?? null); + } + + async function ensurePrincipal( + endpoint: EndpointRow, + author: Author, + raw?: unknown, + ) { + const providerAccountId = endpoint.providerAccountId ?? "unknown"; + const externalId = stableExternalPrincipalId( + endpoint.provider, + author, + raw, + ); + const [principal] = await db + .insert(chatExternalPrincipals) + .values({ + companyId: endpoint.companyId, + provider: endpoint.provider, + providerAccountId, + externalId, + kind: + author.isBot === true ? "bot" : author.isSystem ? "system" : "user", + displayName: author.fullName, + handle: author.userName, + isBot: author.isBot === true || author.isMe, + lastSeenAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + chatExternalPrincipals.companyId, + chatExternalPrincipals.provider, + chatExternalPrincipals.providerAccountId, + chatExternalPrincipals.externalId, + ], + set: { + displayName: author.fullName, + handle: author.userName, + isBot: author.isBot === true || author.isMe, + lastSeenAt: new Date(), + updatedAt: new Date(), + }, + }) + .returning(); + if (endpoint.provider === "speko") { + try { + const authorization = await db.transaction((tx) => voiceStore.authorizePrincipal(tx, endpoint.companyId, endpoint.id, externalId)); + return { principal, userId: authorization.userId, linkedDenied: false }; + } catch (error) { + if (!(error instanceof HttpError && [403, 404].includes(error.status))) throw error; + return { principal, userId: null, linkedDenied: true }; + } + } + const link = await db + .select({ + userId: chatIdentityLinks.paperclipUserId, + status: chatIdentityLinks.status, + }) + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principal.id), + ), + ) + .then((rows) => rows[0] ?? null); + if (link?.status === "linked" && link.userId) { + const membership = await db + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, link.userId), + ), + ) + .then((rows) => rows[0] ?? null); + // External chat performs the same task mutations as the board. A linked + // viewer therefore remains read-only instead of gaining write authority + // merely by using a provider account. + if ( + membership?.status !== "active" || + membership.membershipRole === "viewer" + ) { + return { principal, userId: null, linkedDenied: true }; + } + return { principal, userId: link.userId, linkedDenied: false }; + } + return { principal, userId: null, linkedDenied: false }; + } + + async function sponsorAllowsGuest(endpoint: EndpointRow): Promise { + // A null sponsor is the local board/operator context used by self-hosted + // instances. Cloud endpoints retain the creating user and must revalidate + // that user's current company authority on every sponsored guest turn. + if (!endpoint.sponsorUserId) return true; + const membership = await db + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, endpoint.sponsorUserId), + ), + ) + .then((rows) => rows[0] ?? null); + return ( + membership?.status === "active" && membership.membershipRole !== "viewer" + ); + } + + async function lockCurrentPrincipalAuthorization( + tx: DbOrTransaction, + endpoint: EndpointRow, + principalId: string, + ): Promise<{ + allowed: boolean; + linkedDenied: boolean; + userId: string | null; + sponsorUserId?: string | null; + }> { + if (endpoint.provider === "speko") { + const principal = await tx.select({ externalId: chatExternalPrincipals.externalId }).from(chatExternalPrincipals) + .where(and(eq(chatExternalPrincipals.id, principalId), eq(chatExternalPrincipals.companyId, endpoint.companyId), eq(chatExternalPrincipals.provider, "speko"))).then((rows) => rows[0]); + if (!principal) return { allowed: false, linkedDenied: true, userId: null }; + try { return await voiceStore.authorizePrincipal(tx as import("../../voice/voice-session-store.js").VoiceTransaction, endpoint.companyId, endpoint.id, principal.externalId); } + catch (error) { + if (!(error instanceof HttpError && [403, 404].includes(error.status))) throw error; + return { allowed: false, linkedDenied: true, userId: null }; + } + } + const githubAccess = await githubChatPrincipalAccess(tx, endpoint, principalId); + if (githubAccess) return githubAccess; + // Link confirmation already uses this transaction-scoped identity key. + // Taking it at the final task mutation boundary prevents a newly confirmed + // identity from racing the authorization snapshot. Row locks below also + // serialize revocation and membership changes that do not use this key. + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0))`, + ); + const link = await tx + .select({ + status: chatIdentityLinks.status, + userId: chatIdentityLinks.paperclipUserId, + }) + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, endpoint.companyId), + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principalId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (link?.status === "linked") { + if (!link.userId) { + return { allowed: false, linkedDenied: true, userId: null }; + } + const membership = await tx + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, link.userId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const allowed = + membership?.status === "active" && + membership.membershipRole !== "viewer"; + return { + allowed, + linkedDenied: !allowed, + userId: allowed ? link.userId : null, + }; + } + if (!endpoint.allowUnlinkedPeople) { + return { allowed: false, linkedDenied: false, userId: null }; + } + if (!endpoint.sponsorUserId) { + return { allowed: true, linkedDenied: false, userId: null }; + } + const sponsorMembership = await tx + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, endpoint.sponsorUserId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + return { + allowed: + sponsorMembership?.status === "active" && + sponsorMembership.membershipRole !== "viewer", + linkedDenied: false, + userId: null, + }; + } + + async function requireCurrentExternalActionAuthorization( + tx: DbTransaction, + input: { + conversationId: string; + endpointId: string; + expectedUserId: string; + principalId: string; + runtimeContext: LifecycleRuntimeFence; + }, + ): Promise { + const endpoint = await runtimeCallbackEndpoint( + tx, + input.endpointId, + input.runtimeContext, + ["active", "verifying"], + ); + // Photon setup already admits linked senders and publishes agent prompts. + // Let those prompts resolve so a clarifying question cannot deadlock the + // actual-reply qualification. Other providers retain their active-only gate. + if (!endpoint || (endpoint.status !== "active" && + (endpoint.provider !== "imessage-photon" || endpoint.setup.step !== "test"))) { + throw forbidden("This chat action is no longer authorized", { + code: "chat_action_authorization_changed", + }); + } + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, input.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!conversation) { + throw forbidden("This chat action is no longer authorized", { + code: "chat_action_authorization_changed", + }); + } + const resource = conversation.resourceId + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const destinationAllowed = conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource); + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + input.principalId, + ); + // Governed actions never fall back to sponsored-guest authority. If the + // provider identity was relinked while this callback waited, reject this + // stale actor snapshot and let a fresh callback use the new identity. + if ( + !destinationAllowed || + !authorization.allowed || + authorization.userId !== input.expectedUserId + ) { + throw forbidden("This chat action is no longer authorized", { + code: "chat_action_authorization_changed", + }); + } + } + + function isExternalActionAuthorizationChange(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { details?: unknown }; + return ( + Boolean(value.details) && + typeof value.details === "object" && + (value.details as { code?: unknown }).code === + "chat_action_authorization_changed" + ); + } + + async function ensureResource( + endpoint: EndpointRow, + thread: Thread, + enabledBySetupActivation: boolean, + database: DbOrTransaction = db, + ) { + const type = providerResourceType( + endpoint.provider, + chatSurfaceKind(endpoint.provider, thread), + ); + const providerResourceId = canonicalProviderResourceId( + endpoint.provider, + thread, + ); + const resourceLabel = providerResourceLabelFromThread( + endpoint.provider, + thread, + ); + const [resource] = await database + .insert(chatEndpointResources) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + type, + providerResourceId, + label: resourceLabel.label, + availability: "available", + // Slack can deliver the invitation's app_mention before the membership + // callback. Give either arrival order the same new-channel default. + enabled: + thread.isDM || + enabledBySetupActivation || + (endpoint.provider === "slack" && type === "channel"), + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { + // Provider inventory and membership lifecycle own availability. + // A delayed message must not resurrect a destination after the bot + // was removed or an installation lost access. + // Slack callbacks are not guaranteed to carry a channel name. Keep + // an inventory- or conversations.info-derived label instead of + // downgrading it to C… / slack:C… when a delayed message arrives. + ...(resourceLabel.fallback + ? {} + : { + label: sql`case + when ${chatEndpointResources.availability} = 'available' then ${resourceLabel.label} + else ${chatEndpointResources.label} + end`, + }), + updatedAt: new Date(), + }, + }) + .returning(); + return resource; + } + return { findAuthorizedIdentityLink, ensurePrincipal, sponsorAllowsGuest, lockCurrentPrincipalAuthorization, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, ensureResource }; +} diff --git a/server/src/services/chat-channels/inbound/conversation-queue.ts b/server/src/services/chat-channels/inbound/conversation-queue.ts new file mode 100644 index 00000000000..6b0c4af9cfa --- /dev/null +++ b/server/src/services/chat-channels/inbound/conversation-queue.ts @@ -0,0 +1,767 @@ +import { createGitHubMessageProvenance } from "../github/message-provenance.js"; +import { createDiscordRootThreads } from "../discord/root-threads.js"; + +import type { EndpointRow, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js"; + +import { + telegramDeliveryHasZeroMessageId, +} from "../telegram/messages.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; + +import { + createHash, + randomUUID, +} from "node:crypto"; +import { + and, + eq, + inArray, + lte, + notInArray, + or, + sql, +} from "drizzle-orm"; + +import { + externalThreadIdentityCondition, +} from "../../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatDeliveries, + chatEndpointLeases, + chatEndpoints, +} from "@paperclipai/db"; + +import { + normalizeContentType, +} from "../../../attachment-types.js"; + +import { logger } from "../../../middleware/logger.js"; + +import { + type ChatSdkMessageCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { isExternalChatWaitAuthorizationContention } from "../../native-runtime/chat-attachment-reuse.js"; + +import { + restoreGitHubAttachmentLimitOmissions, +} from "../../chat-github-attachments.js"; + +import type { + Attachment, + Message, + Thread, +} from "chat"; +import { DELIVERY_PROCESSING_STALE_MS, deliveryContentWasRedacted, setupDestinationActivationSuppressed, normalizedDeliveryThreadId, normalizedLifecycleTargetEventId, normalizedLifecycleEffect, deliveryReady, conversationDeliveryOrder, pendingInboundWakeupCondition, lifecycleRuntimeFence } from "./delivery-ledger.js"; +import type { createChatMessageLifecycle } from "./lifecycle.js"; +import type { LiveInboundMessage } from "../runtime-types.js"; +type DeliveryRow = typeof chatDeliveries.$inferSelect; +type ConversationDeliveryQueueOptions = { + DELIVERY_DRAIN_LIMIT: number; + DELIVERY_LEASE_TTL_MS: number; + discordRootThreadPending: ReturnType["discordRootThreadPending"]; + reconcileDiscordRootThread: ReturnType["reconcileDiscordRootThread"]; + durableExternalThreadIdentity: (threadId: string) => string; + liveInboundMessages: Map; + processInboundWakeup: (deliveryId: string) => Promise; + processLifecycleDelivery: ReturnType["processLifecycleDelivery"]; + processMessage: (endpoint: EndpointRow, thread: Thread, message: Message, trigger: ChatSdkMessageCallbackEvent["trigger"], ingressOnly?: boolean, recoveredProviderUrl?: string | null, runtimeContext?: InboundRuntimeContext, providerUpdateId?: number, admittedDeliveryId?: string | null, receiptReactionSupported?: boolean, preauthorizedUserId?: string | null, deferDrainUntilFollowup?: boolean, suppressSetupDestinationActivation?: boolean, slackSlashControl?: boolean) => Promise; + redactError: (error: unknown) => string; + runtimeContexts: WeakMap; + runtimeFor: (endpoint: EndpointRow) => Promise; + settleRejectedInboundWakeups: (onlyDeliveryId?: string) => Promise; + sanitizeFilename: (value: string | undefined) => string | null; + githubManualMessages: WeakMap; + githubAutomaticMessages: WeakMap; + githubIssueMessages: WeakMap; + conversationDrainKey: (endpointId: string, threadId: string) => string; + conversationLeaseRenewalIntervalMs?: number; + renewConversationDeliveryLease?: (input: { endpointId: string; leaseKey: string; token: string; expiresAt: Date }) => Promise; +}; +export function createChatConversationDeliveryQueue(db: Db, options: ConversationDeliveryQueueOptions) { + const { DELIVERY_DRAIN_LIMIT, DELIVERY_LEASE_TTL_MS, discordRootThreadPending, reconcileDiscordRootThread, durableExternalThreadIdentity, liveInboundMessages, processInboundWakeup, processLifecycleDelivery, processMessage, redactError, runtimeContexts, runtimeFor, settleRejectedInboundWakeups, sanitizeFilename, githubManualMessages, githubAutomaticMessages, githubIssueMessages, conversationDrainKey } = options; + const { restoreGitHubMessageProvenance } = createGitHubMessageProvenance({ githubManualMessages, githubAutomaticMessages, githubIssueMessages }); + + + async function earliestOpenConversationDelivery( + endpointId: string, + threadId: string, + ): Promise { + const externalThreadId = sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId'`; + const candidate = await db + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, endpointId), + or( + inArray(chatDeliveries.state, ["received", "retry", "processing"]), + and( + eq(chatDeliveries.state, "processed"), + pendingInboundWakeupCondition(), + ), + ), + notInArray(chatDeliveries.eventKind, [ + "reaction_added", + "reaction_removed", + ]), + externalThreadIdentityCondition(externalThreadId, threadId), + ), + ) + .orderBy(...conversationDeliveryOrder()) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!candidate) return null; + + // A lifecycle callback can reach Paperclip before its create callback. If + // the root becomes durable while the lifecycle row is waiting, always + // return that exact dependency first even when a coarse or malformed + // provider timestamp would otherwise put the edit/delete at the head. + const targetProviderEventId = normalizedLifecycleTargetEventId(candidate); + if (!targetProviderEventId) return candidate; + return db + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, endpointId), + eq(chatDeliveries.providerEventId, targetProviderEventId), + inArray(chatDeliveries.state, ["received", "retry", "processing"]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? candidate); + } + + async function acquireConversationDeliveryLease(input: { + companyId: string; + endpointId: string; + threadId: string; + }): Promise<{ leaseKey: string; token: string } | null> { + const now = new Date(); + const token = randomUUID(); + const leaseKey = `inbound:${createHash("sha256").update(durableExternalThreadIdentity(input.threadId)).digest("hex")}`; + const expiresAt = new Date(now.getTime() + DELIVERY_LEASE_TTL_MS); + const inserted = await db + .insert(chatEndpointLeases) + .values({ + companyId: input.companyId, + endpointId: input.endpointId, + leaseKey, + token, + expiresAt, + }) + .onConflictDoNothing() + .returning({ id: chatEndpointLeases.id }); + if (inserted.length > 0) return { leaseKey, token }; + const reclaimed = await db + .update(chatEndpointLeases) + .set({ token, expiresAt, updatedAt: now }) + .where( + and( + eq(chatEndpointLeases.companyId, input.companyId), + eq(chatEndpointLeases.endpointId, input.endpointId), + eq(chatEndpointLeases.leaseKey, leaseKey), + lte(chatEndpointLeases.expiresAt, now), + ), + ) + .returning({ id: chatEndpointLeases.id }); + return reclaimed.length > 0 ? { leaseKey, token } : null; + } + + async function releaseConversationDeliveryLease(input: { + endpointId: string; + leaseKey: string; + token: string; + }): Promise { + await db + .delete(chatEndpointLeases) + .where( + and( + eq(chatEndpointLeases.endpointId, input.endpointId), + eq(chatEndpointLeases.leaseKey, input.leaseKey), + eq(chatEndpointLeases.token, input.token), + ), + ); + } + + function messageFromDelivery( + delivery: DeliveryRow, + thread: Thread, + endpointRuntime: ChatEndpointRuntime, + ): { + message: Message; + receiptReactionSupported: boolean; + suppressSetupDestinationActivation: boolean; + trigger: ChatSdkMessageCallbackEvent["trigger"]; + providerUrl: string | null; + } | null { + if (telegramDeliveryHasZeroMessageId(delivery, endpointRuntime.provider)) + return null; + const normalized = delivery.normalizedEvent as { + acknowledgement?: { receiptReactionSupported?: unknown }; + kind?: unknown; + trigger?: unknown; + principal?: { + externalId?: unknown; + displayName?: unknown; + handle?: unknown; + }; + message?: { + providerMessageId?: unknown; + sourceProviderMessageId?: unknown; + text?: unknown; + mentionedBot?: unknown; + attachments?: Array<{ + name?: unknown; + mimeType?: unknown; + size?: unknown; + recovery?: unknown; + }>; + attachmentLimitOmissions?: unknown; + photonReply?: { guid?: unknown; part?: unknown }; + }; + conversation?: { providerUrl?: unknown }; + }; + const providerMessageId = normalized.message?.providerMessageId; + const externalId = normalized.principal?.externalId; + if (typeof providerMessageId !== "string" || typeof externalId !== "string") + return null; + const isTeams = endpointRuntime.provider === "microsoft-teams"; + const referenceOnlyTeamsAttachments = isTeams && !thread.isDM; + const originFence = lifecycleRuntimeFence(delivery); + const storedAttachments = normalized.message?.attachments ?? []; + const attachments = ( + isTeams ? storedAttachments.slice(0, 20) : storedAttachments + ) + .map((attachment) => { + if (!attachment || typeof attachment !== "object") return null; + const rehydrated = + attachment.recovery && + (!referenceOnlyTeamsAttachments || + (typeof attachment.recovery === "object" && + attachment.recovery !== null && + "locator" in attachment.recovery && + typeof attachment.recovery.locator === "object" && + attachment.recovery.locator !== null && + "kind" in attachment.recovery.locator && + attachment.recovery.locator.kind === "teams_inline_image")) + ? endpointRuntime.rehydrateAttachment(attachment.recovery, { + threadId: thread.id, + messageId: typeof normalized.message?.sourceProviderMessageId === "string" ? normalized.message.sourceProviderMessageId : providerMessageId, + runtimeGeneration: originFence?.generation, + credentialFingerprint: originFence?.credentialFingerprint, + principalExternalId: externalId, + isDirectMessage: thread.isDM, + }) + : null; + if (rehydrated) return rehydrated; + if ( + isTeams || + endpointRuntime.provider === "imessage-photon" || + (endpointRuntime.provider === "telegram" && + typeof attachment.recovery === "object" && + attachment.recovery !== null && + "locator" in attachment.recovery && + typeof attachment.recovery.locator === "object" && + attachment.recovery.locator !== null && + "kind" in attachment.recovery.locator && + attachment.recovery.locator.kind === "telegram_media") + ) { + // Non-personal references and personal files whose safe download + // capability did not survive restart still belong to this input. + // Keep bounded metadata so ingestion records an explicit omission; + // never persist bearer URLs or fabricate a download capability. + // Even a legacy URL locator cannot enable non-personal downloads. + // A Telegram media locator which fails its exact source proof also + // remains an unavailable input, never an empty attachment-free turn. + return { + type: "file", + name: + sanitizeFilename( + typeof attachment.name === "string" + ? attachment.name + : undefined, + ) ?? undefined, + mimeType: normalizeContentType( + typeof attachment.mimeType === "string" + ? attachment.mimeType + : undefined, + ).slice(0, 255), + size: + typeof attachment.size === "number" && + Number.isSafeInteger(attachment.size) && + attachment.size >= 0 + ? attachment.size + : undefined, + } as Attachment; + } + return null; + }) + .filter((attachment): attachment is Attachment => Boolean(attachment)); + const message = { + id: providerMessageId, + threadId: thread.id, + text: + typeof normalized.message?.text === "string" + ? normalized.message.text + : "", + formatted: { type: "root", children: [] }, + raw: + endpointRuntime.provider === "imessage-photon" && + typeof normalized.message?.photonReply?.guid === "string" + ? { + replyTargetGuid: normalized.message.photonReply.guid, + threadOriginatorPart: normalized.message.photonReply.part, + } + : endpointRuntime.provider === "github" && typeof normalized.message?.sourceProviderMessageId === "string" + ? { githubSourceMessageId: normalized.message.sourceProviderMessageId } : {}, + author: { + userId: externalId, + userName: + typeof normalized.principal?.handle === "string" + ? normalized.principal.handle + : externalId, + fullName: + typeof normalized.principal?.displayName === "string" + ? normalized.principal.displayName + : externalId, + isBot: false, + isMe: false, + isSystem: false, + }, + metadata: { dateSent: delivery.receivedAt, edited: false }, + attachments, + links: [], + isMention: normalized.message?.mentionedBot === true, + } as unknown as Message; + restoreGitHubMessageProvenance(delivery, endpointRuntime, message); + if ( + endpointRuntime.provider === "github" || + endpointRuntime.provider === "imessage-photon" + ) { + restoreGitHubAttachmentLimitOmissions( + message, + normalized.message?.attachmentLimitOmissions, + ); + } + const normalizedTrigger = normalized.trigger; + const trigger: ChatSdkMessageCallbackEvent["trigger"] = + normalizedTrigger === "direct_message" || + normalizedTrigger === "mention" || + normalizedTrigger === "subscribed_message" || + normalizedTrigger === "unaddressed_message" + ? normalizedTrigger + : normalized.kind === "direct_message" + ? "direct_message" + : normalized.kind === "mention" + ? "mention" + : "subscribed_message"; + return { + message, + // Legacy deliveries all originated from native provider messages and + // therefore retain the historical default. + receiptReactionSupported: + normalized.acknowledgement?.receiptReactionSupported !== false, + suppressSetupDestinationActivation: setupDestinationActivationSuppressed( + delivery.normalizedEvent, + ), + trigger, + providerUrl: + typeof normalized.conversation?.providerUrl === "string" + ? normalized.conversation.providerUrl + : null, + }; + } + + /** + * Drain one external conversation in durable receipt order. The database + * lease is endpoint + thread scoped, so separate server processes can work + * on different conversations concurrently but can never mutate the same + * Paperclip task from two inbound turns at once. + */ + async function drainConversationDeliveries( + endpointId: string, + threadId: string, + ): Promise { + const endpoint = await db + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpointId)) + .then((rows) => rows[0] ?? null); + if (!endpoint) return false; + if (endpoint.status === "paused" || endpoint.status === "attention") + return false; + + const lease = await acquireConversationDeliveryLease({ + companyId: endpoint.companyId, + endpointId, + threadId, + }); + if (!lease) return false; + + let leaseOwned = true; + let renewal: Promise | null = null; + const renewTimer = setInterval( + () => { + if (!leaseOwned || renewal) return; + const expiresAt = new Date(Date.now() + DELIVERY_LEASE_TTL_MS); + renewal = Promise.resolve() + .then(async () => { + const renewed = options.renewConversationDeliveryLease + ? await options.renewConversationDeliveryLease({ + endpointId, + leaseKey: lease.leaseKey, + token: lease.token, + expiresAt, + }) + : await db + .update(chatEndpointLeases) + .set({ expiresAt, updatedAt: new Date() }) + .where( + and( + eq(chatEndpointLeases.endpointId, endpointId), + eq(chatEndpointLeases.leaseKey, lease.leaseKey), + eq(chatEndpointLeases.token, lease.token), + ), + ) + .returning({ id: chatEndpointLeases.id }) + .then((rows) => rows.length > 0); + if (!renewed) leaseOwned = false; + }) + .catch((error) => { + // A failed renewal has unknown durability. Stop this local drain at + // the next delivery boundary so another worker can never overlap + // later turns after the original lease expires or is reclaimed. + leaseOwned = false; + logger.warn( + { endpointId, error: redactError(error) }, + "could not renew external chat conversation lease", + ); + }) + .finally(() => { + renewal = null; + }); + }, + options.conversationLeaseRenewalIntervalMs ?? DELIVERY_LEASE_TTL_MS / 3, + ); + renewTimer.unref?.(); + + try { + for ( + let processed = 0; + processed < DELIVERY_DRAIN_LIMIT; + processed += 1 + ) { + if (!leaseOwned) break; + const delivery = await earliestOpenConversationDelivery( + endpointId, + threadId, + ); + if (!delivery || !deliveryReady(delivery, new Date())) break; + if (delivery.state === "processed") { + if (!(await processInboundWakeup(delivery.id))) break; + continue; + } + if (telegramDeliveryHasZeroMessageId(delivery, endpoint.provider)) { + const filtered = await db + .transaction(async (tx) => { + // Do not round-trip a PostgreSQL microsecond timestamp through + // JS Date for a CAS. Lock the exact receipt, then revalidate the + // current source and claim before changing it. A competing live + // claim is a retry boundary, not permission to settle its work. + const [current] = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, delivery.id), + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + ), + ) + .for("update", { noWait: true }); + const filteredAt = new Date(); + if ( + !current || + current.state !== delivery.state || + current.eventKind !== delivery.eventKind || + !["received", "retry", "processing"].includes(current.state) || + normalizedDeliveryThreadId(current) !== threadId || + !deliveryReady(current, filteredAt) || + !telegramDeliveryHasZeroMessageId(current, endpoint.provider) + ) + return false; + await notifyChatDeliveryWork(tx); + const changed = await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + processedAt: filteredAt, + updatedAt: filteredAt, + redactedError: + "Telegram message ID zero cannot authorize ordinary work", + }) + .where(eq(chatDeliveries.id, current.id)) + .returning({ id: chatDeliveries.id }); + return changed.length === 1; + }) + .catch((error: unknown) => { + if (isExternalChatWaitAuthorizationContention(error)) + return false; + throw error; + }); + if (!filtered) break; + // Retain original evidence/comments; filtering adds no user content + // and cannot hydrate attachments, invoke commands, or send feedback. + await settleRejectedInboundWakeups(delivery.id); + liveInboundMessages.delete(delivery.id); + continue; + } + if (endpoint.status === "archived" || endpoint.status === "revoked") { + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "filtered", + redactedError: "Connection was removed before processing", + processedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(chatDeliveries.id, delivery.id)); + }); + liveInboundMessages.delete(delivery.id); + continue; + } + + if ( + endpoint.provider === "discord" && + discordRootThreadPending(delivery) + ) { + // This is the only recovery path for the narrow crash window after + // pre-admission but before Chat's ordinary callback. A successful + // read proves the root thread exists; an explicit 404/410 proves it + // does not. Ambiguous transport and auth failures remain retryable. + if (await reconcileDiscordRootThread(endpoint, delivery, threadId)) { + continue; + } + break; + } + + const live = liveInboundMessages.get(delivery.id); + try { + if ( + delivery.eventKind === "message_updated" || + delivery.eventKind === "message_deleted" || + delivery.eventKind === "message_restored" + ) { + await processLifecycleDelivery(endpoint, delivery); + } else if (live) { + await processMessage( + endpoint, + live.thread, + live.message, + live.trigger, + false, + live.providerUrl, + live.runtimeContext, + undefined, + delivery.id, + live.receiptReactionSupported, + undefined, + false, + setupDestinationActivationSuppressed(delivery.normalizedEvent), + ); + } else { + if (deliveryContentWasRedacted(delivery.normalizedEvent)) { + const filteredAt = new Date(); + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "filtered", + principalId: null, + nextAttemptAt: null, + processedAt: filteredAt, + redactedError: + "Provisional Teams setup reply could not be hydrated from a current provider event", + updatedAt: filteredAt, + }) + .where( + and( + eq(chatDeliveries.id, delivery.id), + inArray(chatDeliveries.state, [ + "received", + "retry", + "processing", + ]), + ), + ); + }); + continue; + } + const endpointRuntime = await runtimeFor(endpoint); + const deliveryRuntimeFence = lifecycleRuntimeFence(delivery); + const currentRuntimeContext = runtimeContexts.get( + endpointRuntime as object, + ); + const thread = endpointRuntime.thread(threadId); + const reconstructed = messageFromDelivery( + delivery, + thread, + endpointRuntime, + ); + if (!reconstructed) { + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "failed", + redactedError: "Normalized delivery is incomplete", + updatedAt: new Date(), + }) + .where(eq(chatDeliveries.id, delivery.id)); + }); + continue; + } + await processMessage( + endpoint, + thread, + reconstructed.message, + reconstructed.trigger, + false, + reconstructed.providerUrl, + deliveryRuntimeFence + ? { + ...deliveryRuntimeFence, + endpointRuntime, + } + : currentRuntimeContext, + undefined, + delivery.id, + reconstructed.receiptReactionSupported, + undefined, + false, + reconstructed.suppressSetupDestinationActivation, + ); + } + } catch (error) { + logger.warn( + { + endpointId, + deliveryId: delivery.id, + error: redactError(error), + }, + "external chat delivery will retry in receipt order", + ); + } + + const state = await db + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where(eq(chatDeliveries.id, delivery.id)) + .then((rows) => rows[0]?.state ?? null); + if ( + state === "processed" || + state === "filtered" || + state === "failed" + ) { + if (state !== "processed") + await settleRejectedInboundWakeups(delivery.id); + liveInboundMessages.delete(delivery.id); + continue; + } + // A retry delay or another still-live processing claim is a strict + // head-of-line barrier; later replies may not overtake it. + break; + } + } finally { + clearInterval(renewTimer); + await renewal; + await releaseConversationDeliveryLease({ endpointId, ...lease }); + } + + const next = await earliestOpenConversationDelivery(endpointId, threadId); + if (next?.state === "processed") { + const [ready] = await db + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, next.id), + pendingInboundWakeupCondition(true), + ), + ) + .limit(1); + return Boolean(ready); + } + return Boolean(next && deliveryReady(next, new Date())); + } + + async function nextInboundDeliveryAt(): Promise { + const rows = await db.select({ delivery: { + id: chatDeliveries.id, endpointId: chatDeliveries.endpointId, providerEventId: chatDeliveries.providerEventId, + state: chatDeliveries.state, eventKind: chatDeliveries.eventKind, + // Retain scheduling metadata only, not message bodies or attachment data. + normalizedEvent: sql`jsonb_build_object( + 'conversation', ${chatDeliveries.normalizedEvent}->'conversation', + 'lifecycle', ${chatDeliveries.normalizedEvent}->'lifecycle', + 'message', jsonb_build_object( + 'targetProviderEventId', ${chatDeliveries.normalizedEvent}->'message'->'targetProviderEventId', + 'admissionDependencyEventId', ${chatDeliveries.normalizedEvent}->'message'->'admissionDependencyEventId'))`, + }, at: sql`case + when ${chatDeliveries.state} = 'processing' then ${chatDeliveries.updatedAt} + ${DELIVERY_PROCESSING_STALE_MS} * interval '1 millisecond' + when ${chatDeliveries.state} = 'processed' then ( + select min(case when a.status = 'processing' then a.updated_at + ${DELIVERY_PROCESSING_STALE_MS} * interval '1 millisecond' + else coalesce((a.result->>'retryAt')::timestamptz, now()) end) + from chat_actions a where a.delivery_id = chat_deliveries.id + and a.company_id = chat_deliveries.company_id and a.endpoint_id = chat_deliveries.endpoint_id + and a.kind = 'inbound_wakeup' and a.status in ('issued', 'processing')) + else coalesce(${chatDeliveries.nextAttemptAt}, now()) end` }) + .from(chatDeliveries).where(and( + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, + // Provider lifecycle receipts can restore an unavailable endpoint. + // The ordinary conversation drain still waits for operator resume. + or(sql`${chatDeliveries.normalizedEvent}->'lifecycle' is not null`, + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.status in ('paused', 'attention'))`), + or(inArray(chatDeliveries.state, ["received", "retry", "processing"]), + and(eq(chatDeliveries.state, "processed"), pendingInboundWakeupCondition())), + )).orderBy(...conversationDeliveryOrder()); + // Only an ordered conversation head can run. Reaction and provider + // lifecycle receipts are independent; lifecycle edits may select their + // exact admission dependency before the chronologically earliest row. + const byEvent = new Map(rows.map(row => [`${row.delivery.endpointId}:${row.delivery.providerEventId}`, row])); + const conversations = new Set(); + let next: number | null = null; + for (let row of rows) { + const delivery = row.delivery; + const threadId = normalizedDeliveryThreadId(delivery); + if (threadId && !normalizedLifecycleEffect(delivery) && !["reaction_added", "reaction_removed"].includes(delivery.eventKind)) { + const key = conversationDrainKey(delivery.endpointId, threadId); + if (conversations.has(key)) continue; + conversations.add(key); + const dependency = normalizedLifecycleTargetEventId(delivery); + const target = dependency ? byEvent.get(`${delivery.endpointId}:${dependency}`) : undefined; + if (target && ["received", "retry", "processing"].includes(target.delivery.state)) row = target; + } + if (row.at !== null) { + const at = new Date(row.at).getTime(); + next = next === null ? at : Math.min(next, at); + } + } + return next; + } + return { drainConversationDeliveries, nextInboundDeliveryAt }; +} diff --git a/server/src/services/chat-channels/inbound/delivery-ledger.ts b/server/src/services/chat-channels/inbound/delivery-ledger.ts new file mode 100644 index 00000000000..841378675ff --- /dev/null +++ b/server/src/services/chat-channels/inbound/delivery-ledger.ts @@ -0,0 +1,241 @@ +import type { LifecycleRuntimeFence } from "../runtime-types.js"; + +import { + asc, + sql, + type SQL, +} from "drizzle-orm"; + +import { + chatActions, + chatDeliveries, +} from "@paperclipai/db"; + +import { + type ChatProviderLifecycleEffect, +} from "../../chat-provider-lifecycle.js"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; + +const DELIVERY_PROCESSING_STALE_MS = 60_000; + + function deliveryContentWasRedacted(normalizedEvent: unknown): boolean { + if ( + !normalizedEvent || + typeof normalizedEvent !== "object" || + Array.isArray(normalizedEvent) + ) + return false; + const filtering = (normalizedEvent as { filtering?: unknown }).filtering; + return ( + Boolean(filtering) && + typeof filtering === "object" && + !Array.isArray(filtering) && + (filtering as { contentRetained?: unknown }).contentRetained === false + ); + } + + function setupDestinationActivationSuppressed( + normalizedEvent: unknown, + ): boolean { + if ( + !normalizedEvent || + typeof normalizedEvent !== "object" || + Array.isArray(normalizedEvent) + ) + return false; + const normalized = normalizedEvent as { + admission?: { origin?: unknown }; + admissionAuthorization?: { version?: unknown }; + }; + return ( + normalized.admission?.origin === "provider_confirmed_action" || + normalized.admissionAuthorization?.version === 1 + ); + } + + function normalizedDeliveryThreadId(delivery: Pick): string | null { + const normalized = delivery.normalizedEvent as { + conversation?: { externalThreadId?: unknown }; + }; + return typeof normalized.conversation?.externalThreadId === "string" + ? normalized.conversation.externalThreadId + : null; + } + + function normalizedLifecycleTargetEventId( + delivery: Pick, + ): string | null { + if ( + delivery.eventKind !== "message_updated" && + delivery.eventKind !== "message_deleted" && + delivery.eventKind !== "message_restored" + ) + return null; + const normalized = delivery.normalizedEvent as { + message?: { targetProviderEventId?: unknown; admissionDependencyEventId?: unknown }; + }; + // An edit can also introduce a new addressed request. Process that durable + // admission before waiting for the old, potentially unadmitted source. + if (typeof normalized.message?.admissionDependencyEventId === "string") + return normalized.message.admissionDependencyEventId; + return typeof normalized.message?.targetProviderEventId === "string" + ? normalized.message.targetProviderEventId + : null; + } + + function normalizedLifecycleEffect( + delivery: Pick, + ): ChatProviderLifecycleEffect | null { + const normalized = delivery.normalizedEvent as { lifecycle?: unknown }; + const value = normalized.lifecycle; + if (!value || typeof value !== "object" || Array.isArray(value)) + return null; + const effect = value as Record; + if ( + (effect.provider !== "slack" && + effect.provider !== "github" && + effect.provider !== "discord" && + effect.provider !== "microsoft-teams" && + effect.provider !== "telegram") || + typeof effect.providerEventId !== "string" + ) + return null; + if ( + effect.kind === "endpoint" && + (effect.availability === "available" || + effect.availability === "attention" || + effect.availability === "revoked") && + typeof effect.reason === "string" + ) + return effect as ChatProviderLifecycleEffect; + if ( + effect.kind === "resource" && + (effect.availability === "available" || + effect.availability === "unavailable" || + effect.availability === "removed") && + typeof effect.providerResourceId === "string" && + (effect.previousProviderResourceId === undefined || + typeof effect.previousProviderResourceId === "string") && + typeof effect.resourceType === "string" && + typeof effect.label === "string" + ) + return effect as ChatProviderLifecycleEffect; + return null; + } + + function deliveryReady(delivery: DeliveryRow, now: Date): boolean { + // Accepted input may still own the conversation head through its wake + // outbox. The action dispatcher checks its own retry/claim deadline. + if (delivery.state === "processed") return true; + if (delivery.state === "received") + return !delivery.nextAttemptAt || delivery.nextAttemptAt <= now; + if (delivery.state === "retry") { + return !delivery.nextAttemptAt || delivery.nextAttemptAt <= now; + } + return ( + delivery.state === "processing" && + delivery.updatedAt <= + new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS) + ); + } + + function conversationDeliveryOrder() { + return [ + asc( + sql`coalesce(nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')::timestamptz, ${chatDeliveries.receivedAt})`, + ), + // GitHub timestamps and Telegram message dates have one-second + // resolution. GitHub's numeric comment id and Telegram's message_id + // are monotonic within one conversation, so use them before receipt + // order. The Telegram update_id is a final provider-native tie-breaker + // for unusual payloads that lack a usable message_id. + asc(sql`coalesce( + case + when ${chatDeliveries.normalizedEvent}->'message'->>'providerMessageSequence' ~ '^[0-9]+$' + then (${chatDeliveries.normalizedEvent}->'message'->>'providerMessageSequence')::numeric + else null + end, + case + when ${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId' ~ '^[0-9]+$' + then (${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId')::numeric + else null + end + )`), + asc(sql`case + when ${chatDeliveries.normalizedEvent}->'message'->>'providerUpdateId' ~ '^[0-9]+$' + then (${chatDeliveries.normalizedEvent}->'message'->>'providerUpdateId')::numeric + else null + end`), + // GitHub exposes no sortable webhook sequence. When an edit and delete + // share its whole-second updated_at value, preserve the only valid + // lifecycle state transition: update before delete. Provider-native + // update ids (Telegram) remain the stronger preceding key. + asc(sql`case ${chatDeliveries.eventKind} + when 'message_updated' then 1 + when 'message_deleted' then 2 + when 'message_restored' then 3 + else 0 + end`), + asc(chatDeliveries.receivedAt), + asc(chatDeliveries.id), + ]; + } + + function pendingInboundWakeupCondition(readyOnly = false): SQL { + return sql`exists ( + select 1 from ${chatActions} + where ${chatActions.deliveryId} = ${chatDeliveries.id} + and ${chatActions.endpointId} = ${chatDeliveries.endpointId} + and ${chatActions.companyId} = ${chatDeliveries.companyId} + and ${chatActions.kind} = 'inbound_wakeup' + and ${chatActions.status} in ('issued', 'processing') + ${ + readyOnly + ? sql`and ( + (${chatActions.status} = 'issued' and (${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= now())) + or (${chatActions.status} = 'processing' and ${chatActions.updatedAt} <= ${new Date(Date.now() - DELIVERY_PROCESSING_STALE_MS).toISOString()}::timestamptz) + )` + : sql`` + } + )`; + } + + function lifecycleRuntimeFence( + delivery: DeliveryRow, + ): LifecycleRuntimeFence | null { + const normalized = delivery.normalizedEvent; + if ( + !normalized || + typeof normalized !== "object" || + Array.isArray(normalized) + ) + return null; + const value = (normalized as Record).runtimeContext; + if (!value || typeof value !== "object" || Array.isArray(value)) + return null; + const context = value as Record; + return typeof context.generation === "number" && + Number.isSafeInteger(context.generation) && + typeof context.credentialFingerprint === "string" + ? { + generation: context.generation as number, + credentialFingerprint: context.credentialFingerprint, + } + : null; + } + + function normalizedLifecycleWithRuntimeFence( + delivery: DeliveryRow, + runtimeContext: LifecycleRuntimeFence, + ): Record { + const normalized = + delivery.normalizedEvent && + typeof delivery.normalizedEvent === "object" && + !Array.isArray(delivery.normalizedEvent) + ? (delivery.normalizedEvent as Record) + : {}; + return { ...normalized, runtimeContext }; + } + +export { DELIVERY_PROCESSING_STALE_MS, deliveryContentWasRedacted, setupDestinationActivationSuppressed, normalizedDeliveryThreadId, normalizedLifecycleTargetEventId, normalizedLifecycleEffect, deliveryReady, conversationDeliveryOrder, pendingInboundWakeupCondition, lifecycleRuntimeFence, normalizedLifecycleWithRuntimeFence }; diff --git a/server/src/services/chat-channels/inbound/envelope.ts b/server/src/services/chat-channels/inbound/envelope.ts new file mode 100644 index 00000000000..8bc459e5c73 --- /dev/null +++ b/server/src/services/chat-channels/inbound/envelope.ts @@ -0,0 +1,360 @@ +import { providerResourceType, durableExternalThreadIdentity, canonicalProviderResourceId, chatSurfaceKind } from "./surfaces.js"; + +import type { EndpointRow, InboundRuntimeContext } from "../runtime-types.js"; + +import { + MAX_INBOUND_TEXT, + stableExternalPrincipalId, +} from "../message-lifecycle.js"; +import { + telegramMessageSequence, + telegramMessageSentAt, +} from "../telegram/messages.js"; +import { + slackMessageSentAt, +} from "../slack/messages.js"; +import { + teamsActivitySentAt, +} from "../microsoft-teams/messages.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatGitHubConfigurations } from "@paperclipai/db"; +import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; + +import { photonReplyReference } from "../../photon/adapter.js"; + +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpointResources, +} from "@paperclipai/db"; +import type { + ChatEventKind, +} from "@paperclipai/shared"; + +import { + normalizeContentType, +} from "../../../attachment-types.js"; +import { + deriveTeamsPersonalRecipient, +} from "../../chat-teams-personal-recipient.js"; + +import { + type ChatSdkMessageCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + githubAttachmentLimitOmissions, +} from "../../chat-github-attachments.js"; + +import { chatProviderConversationUrl } from "../../chat-provider-links.js"; + +import type { + Attachment, + Message, + Thread, +} from "chat"; + +export type InboundEnvelopeInput = { + endpoint: EndpointRow; + thread: Thread; + message: Message; + trigger: ChatSdkMessageCallbackEvent["trigger"]; + receiptReactionSupported: boolean; + recoveredProviderUrl: string | null; + runtimeContext?: InboundRuntimeContext; + providerUpdateId?: number; + admittedDeliveryId: string | null; + suppressSetupDestinationActivation: boolean; + slackSlashControl: boolean; +}; +type InboundEnvelopeOptions = { + runtimeFor: (endpoint: EndpointRow) => Promise; + sanitizeFilename: (value: string | undefined) => string | null; + githubManualMessages: WeakMap; + githubAutomaticMessages: WeakMap; + githubIssueMessages: WeakMap; +}; +export function createInboundEnvelope(db: Db, options: InboundEnvelopeOptions) { + const { runtimeFor, sanitizeFilename, githubManualMessages, githubAutomaticMessages, githubIssueMessages } = options; + async function prepareInboundEnvelope(input: InboundEnvelopeInput) { + const { endpoint, thread, message, trigger, receiptReactionSupported, recoveredProviderUrl, runtimeContext, providerUpdateId, admittedDeliveryId, suppressSetupDestinationActivation, slackSlashControl } = input; + const githubAutomatic = endpoint.provider === "github" ? githubAutomaticMessages.get(message) : undefined; + const githubIssue = endpoint.provider === "github" ? githubIssueMessages.get(message) : undefined; + let githubManual = githubManualMessages.get(message); + if (endpoint.provider === "github" && !githubAutomatic && !githubIssue && !githubManual) { + const [config] = await db.select().from(chatGitHubConfigurations).where(eq(chatGitHubConfigurations.endpointId, endpoint.id)); + if (config) { + const repository = thread.channelId.replace(/^github:/, "").toLowerCase(); + const [repo] = await db.select().from(chatEndpointResources).where(and(eq(chatEndpointResources.endpointId, endpoint.id), eq(chatEndpointResources.providerResourceId, repository))); + const repositoryId = String(repo?.metadata?.providerRepositoryId ?? ""); + githubManual = { policy: { ...config.configuration.defaults, ...config.configuration.repositories[repositoryId] }, revision: config.revision, event: trigger === "mention" || message.isMention ? "mention" : "comment" }; + } + } + const providerEventId = `${durableExternalThreadIdentity(thread.id)}:${message.id}`; + const surfaceKind = chatSurfaceKind(endpoint.provider, thread); + const addressed = + endpoint.provider === "imessage-photon" || + trigger === "mention" || + trigger === "direct_message" || + message.isMention === true; + const eventKind: ChatEventKind = + trigger === "direct_message" + ? "direct_message" + : trigger === "mention" + ? "mention" + : "message"; + // A callback remains tied to the runtime that authenticated and parsed it. + // In particular, do not resolve the registry again here: pause or rotation + // may already have removed that instance, and recreating a runtime before + // the durable admission fence would either throw or bind old payload data + // to new credentials. + const endpointRuntime = + runtimeContext?.endpointRuntime ?? (await runtimeFor(endpoint)); + // Non-personal generic files still require a separate Graph grant. Only + // exact inline image resources from the authenticated original activity + // receive a scope-bound, URL-free recovery locator. Reconstructed input + // can retain that proof, but cannot mint one from legacy fetch metadata. + const teamsNonPersonal = + endpoint.provider === "microsoft-teams" && !thread.isDM; + const attachmentSource = { + threadId: thread.id, + messageId: message.id, + runtimeGeneration: runtimeContext?.generation, + credentialFingerprint: runtimeContext?.credentialFingerprint, + principalExternalId: stableExternalPrincipalId( + endpoint.provider, + message.author, + message.raw, + ), + isDirectMessage: thread.isDM, + ...(runtimeContext?.endpointRuntime && !admittedDeliveryId + ? { message } + : {}), + }; + const inlineRecovery = new Map< + Attachment, + ReturnType + >(); + const nativeInboundAttachments = teamsNonPersonal + ? message.attachments.slice(0, 20).flatMap((attachment) => { + const descriptor = endpointRuntime.attachmentRecoveryDescriptor( + attachment, + attachmentSource, + ); + if (descriptor?.locator.kind !== "teams_inline_image") return []; + const restored = endpointRuntime.rehydrateAttachment( + descriptor, + attachmentSource, + ); + if (!restored) return []; + inlineRecovery.set(attachment, descriptor); + return [restored]; + }) + : message.attachments; + const unavailableTeamsReferences = teamsNonPersonal + ? Math.min(message.attachments.length, 20) - + nativeInboundAttachments.length + : 0; + const providerSentAt = + endpoint.provider === "microsoft-teams" + ? teamsActivitySentAt(message.raw) + : endpoint.provider === "telegram" + ? telegramMessageSentAt(message.raw) + : endpoint.provider === "slack" + ? slackMessageSentAt(message.raw, message.id) + : message.metadata.dateSent instanceof Date && + Number.isFinite(message.metadata.dateSent.getTime()) + ? message.metadata.dateSent + : null; + const providerSentAtSource = providerSentAt + ? endpoint.provider === "microsoft-teams" + ? "teams_activity_timestamp" + : endpoint.provider === "telegram" + ? "telegram_message_date" + : endpoint.provider === "slack" + ? "slack_message_ts" + : null + : null; + const providerUrl = + (githubAutomatic + ? `https://github.com/${githubAutomatic.context.repository}/pull/${githubAutomatic.context.pullNumber}` + : githubIssue ? `https://github.com/${githubIssue.context.repository}/issues/${githubIssue.context.issueNumber}` : null) ?? + // Description/review requests and edits have synthetic ledger IDs, not + // native comment IDs. Their verified source URL must not acquire a fake + // issuecomment anchor from that ledger identity. + (endpoint.provider === "github" && githubManual && !receiptReactionSupported ? recoveredProviderUrl : null) ?? + chatProviderConversationUrl({ + provider: endpoint.provider, + providerAccountId: endpoint.providerAccountId, + botUsername: endpoint.botUsername, + threadId: thread.id, + providerMessageId: message.id, + raw: message.raw, + }) ?? recoveredProviderUrl; + // Preserve only the closed personal-recipient proof from the runtime that + // authenticated this original activity. A reconstructed delivery's AAD-only + // author and SDK route caches cannot recreate its Bot Framework recipient. + const teamsPersonalRecipient = + endpoint.provider === "microsoft-teams" && + runtimeContext?.endpointRuntime && + !admittedDeliveryId && + endpoint.providerAccountId && + endpoint.botExternalId + ? deriveTeamsPersonalRecipient(message, { + companyId: endpoint.companyId, + endpointId: endpoint.id, + runtimeGeneration: runtimeContext.generation, + credentialFingerprint: runtimeContext.credentialFingerprint, + tenantId: endpoint.providerAccountId, + botAppId: endpoint.botExternalId, + providerEventId, + threadId: thread.id, + isDirectMessage: thread.isDM, + }) + : null; + const normalized = { + providerEventId, + kind: eventKind, + trigger, + ...(teamsPersonalRecipient ? { teamsPersonalRecipient } : {}), + ...(slackSlashControl + ? { admission: { origin: "slack_slash_control" } } + : suppressSetupDestinationActivation + ? // A slash-command root is provider-confirmed only after an enabled + // destination authorized its transport. Persist that closed origin so + // crash recovery can never reinterpret it as first-time setup traffic + // and undo a later operator reach revocation. + { admission: { origin: "provider_confirmed_action" } } + : {}), + ...(runtimeContext + ? { + runtimeContext: { + credentialFingerprint: runtimeContext.credentialFingerprint, + generation: runtimeContext.generation, + }, + } + : {}), + acknowledgement: { + // Some provider callbacks represent an auditable user command without + // a provider message that can receive a reaction. Persist the + // distinction so a crash/retry cannot mistake the synthetic ledger id + // for a native message id. + receiptReactionSupported, + }, + ...(githubAutomatic ? { githubAutomatic } : {}), + ...(githubIssue ? { githubIssue } : {}), + ...(githubManual ? { githubManual } : {}), + principal: { + // Bot/system authors have already been rejected at this intake boundary. + isBot: false, + externalId: stableExternalPrincipalId( + endpoint.provider, + message.author, + message.raw, + ), + displayName: message.author.fullName, + handle: message.author.userName, + }, + resource: { + type: providerResourceType(endpoint.provider, surfaceKind), + providerResourceId: canonicalProviderResourceId( + endpoint.provider, + thread, + ), + label: thread.channel.name ?? thread.channelId, + }, + conversation: { + externalConversationId: thread.channelId, + externalThreadId: thread.id, + label: thread.channel.name ?? thread.channelId, + isDirectMessage: thread.isDM, + providerUrl, + }, + message: { + providerMessageId: message.id, + ...(endpoint.provider === "github" && typeof (message.raw as { githubSourceMessageId?: unknown })?.githubSourceMessageId === "string" && /^[1-9][0-9]*$/.test((message.raw as { githubSourceMessageId: string }).githubSourceMessageId) + ? { sourceProviderMessageId: (message.raw as { githubSourceMessageId: string }).githubSourceMessageId } : {}), + ...(endpoint.provider === "imessage-photon" + ? { photonReply: photonReplyReference(message.raw) } + : {}), + providerMessageSequence: + endpoint.provider === "telegram" + ? telegramMessageSequence(message.raw) + : null, + providerUpdateId: + endpoint.provider === "telegram" ? (providerUpdateId ?? null) : null, + text: message.text.slice(0, MAX_INBOUND_TEXT), + mentionedBot: message.isMention === true, + providerSentAt: providerSentAt?.toISOString() ?? null, + ...(providerSentAtSource ? { providerSentAtSource } : {}), + ...((endpoint.provider === "github" || + endpoint.provider === "imessage-photon") && + (githubAttachmentLimitOmissions(message) || + Math.max(0, message.attachments.length - 20)) + ? { + attachmentLimitOmissions: + githubAttachmentLimitOmissions(message) || + Math.max(0, message.attachments.length - 20), + } + : {}), + attachments: message.attachments.slice(0, 20).map((attachment) => ({ + name: sanitizeFilename(attachment.name), + mimeType: normalizeContentType(attachment.mimeType), + size: attachment.size ?? null, + recovery: teamsNonPersonal + ? (inlineRecovery.get(attachment) ?? null) + : nativeInboundAttachments.includes(attachment) + ? endpointRuntime.attachmentRecoveryDescriptor( + attachment, + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? attachmentSource + : undefined, + ) + : null, + })), + }, + }; + // Teams RSC can deliver messages from every installed channel, and a + // Telegram bot can receive addressed traffic from a provider-available + // group that the operator has not enabled in Paperclip. Until the reach + // gate admits that destination, retain only identifiers required for + // deduplication and operator-visible filtering—not user text, attachment + // metadata, or an external principal profile. + const redactedDestinationNormalized = { + providerEventId, + kind: eventKind, + trigger, + ...(runtimeContext + ? { + runtimeContext: { + credentialFingerprint: runtimeContext.credentialFingerprint, + generation: runtimeContext.generation, + }, + } + : {}), + resource: { + type: providerResourceType(endpoint.provider, surfaceKind), + providerResourceId: canonicalProviderResourceId( + endpoint.provider, + thread, + ), + }, + conversation: { externalThreadId: thread.id }, + message: { + providerMessageId: message.id, + providerSentAt: providerSentAt?.toISOString() ?? null, + }, + filtering: { contentRetained: false }, + }; + return { githubAutomatic, githubIssue, githubManual, providerEventId, surfaceKind, addressed, eventKind, endpointRuntime, nativeInboundAttachments, unavailableTeamsReferences, providerSentAt, providerUrl, normalized, redactedDestinationNormalized }; + } + return { prepareInboundEnvelope }; +} +export type InboundEnvelope = Awaited["prepareInboundEnvelope"]>>; diff --git a/server/src/services/chat-channels/inbound/lifecycle.ts b/server/src/services/chat-channels/inbound/lifecycle.ts new file mode 100644 index 00000000000..30faad3ab63 --- /dev/null +++ b/server/src/services/chat-channels/inbound/lifecycle.ts @@ -0,0 +1,1086 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js"; + +import { + MAX_INBOUND_TEXT, + type LifecycleActor, + lifecycleActorFromAuthor, +} from "../message-lifecycle.js"; +import { + telegramMessageSequence, +} from "../telegram/messages.js"; +import { + slackLifecycleFilesDigest, +} from "../slack/messages.js"; +import { + discordLifecycleFilesDigest, +} from "../discord/messages.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + desc, + eq, + inArray, + lte, + ne, + sql, +} from "drizzle-orm"; + +import { + externalThreadIdentityCondition, + teamsConversationId, +} from "../../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatMessageLinks, +} from "@paperclipai/db"; +import type { + ChatProvider, +} from "@paperclipai/shared"; + +import { normalizeTelegramRichMessage } from "../../chat-telegram-rich-intake.js"; + +import { + notFound, +} from "../../../errors.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkMessageUpdatedCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { issueService } from "../../issues.js"; + +import type { + MessageDeletedEvent, +} from "chat"; +import { normalizedDeliveryThreadId, lifecycleRuntimeFence } from "./delivery-ledger.js"; +type DeliveryRow = typeof chatDeliveries.$inferSelect; +type MessageLifecycleOptions = { + DELIVERY_PROCESSING_STALE_MS: number; + ORPHAN_FOLLOW_UP_GRACE_MS: number; + ORPHAN_FOLLOW_UP_MAX_ATTEMPTS: number; + INGRESS_REORDER_WINDOW_MS: Partial>; + issuesSvc: ReturnType; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean; userId?: string | null }>; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + redactError: (error: unknown) => string; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + sameTeamsThreadIdentity: (left: string, right: string) => boolean; + durableExternalThreadIdentity: (threadId: string) => string; + recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: InboundRuntimeContext | undefined, threadId: string, raw: unknown, recordAcceptedActivity?: boolean) => Promise; + conversationDrainKey: (endpointId: string, threadId: string) => string; + scheduleConversationDrain: (endpointId: string, threadId: string, scheduledAt?: number) => void; + scheduledConversationDrains: Map; + drainConversationDeliveries: (endpointId: string, threadId: string) => Promise; + ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise; + deferWebhookProcessing?: boolean; +}; +export function createChatMessageLifecycle(db: Db, options: MessageLifecycleOptions) { + const { DELIVERY_PROCESSING_STALE_MS, ORPHAN_FOLLOW_UP_GRACE_MS, ORPHAN_FOLLOW_UP_MAX_ATTEMPTS, INGRESS_REORDER_WINDOW_MS, issuesSvc, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, redactError, runtimeCallbackEndpoint, sameTeamsThreadIdentity, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, conversationDrainKey, scheduleConversationDrain, scheduledConversationDrains, drainConversationDeliveries, ensureDiscordGatewayRuntimeIsCurrent } = options; + + + async function conversationForThread(endpointId: string, threadId: string) { + const exact = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.endpointId, endpointId), + eq(chatConversations.externalThreadId, threadId), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .then((rows) => rows[0] ?? null); + if (exact || !threadId.startsWith("teams:")) return exact; + + // Before Paperclip made Teams route state independent, the adapter + // embedded the mutable Bot Connector serviceUrl in every thread id. Read + // those rows by the immutable conversation id so a regional route change + // continues the same task. New rows use the canonical route-free id. + if (!teamsConversationId(threadId)) return null; + return db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.endpointId, endpointId), + externalThreadIdentityCondition( + sql`${chatConversations.externalThreadId}`, + threadId, + ), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .then( + (rows) => + rows.find((row) => + sameTeamsThreadIdentity(row.externalThreadId, threadId), + ) ?? null, + ); + } + + async function recordLifecycleDelivery( + input: { + actor?: LifecycleActor; + isBotMessage?: true; + endpointId: string; + threadId: string; + messageId: string; + eventKind: "message_updated" | "message_deleted" | "message_restored"; + text: string; + providerEventId?: string; + providerMessageSequence?: number | null; + providerSentAt?: string | null; + providerUpdateId?: number | null; + raw?: unknown; + revision?: string | null; + admissionDependencyEventId?: string; + }, + runtimeContext?: RuntimeContext, + githubSourceMapped = false, + ) { + if (!githubSourceMapped && input.threadId.startsWith("github:") && /^[1-9][0-9]*$/.test(input.messageId)) { + const requests = await db.select({ id: chatDeliveries.id, messageId: sql`${chatDeliveries.normalizedEvent}->'message'->>'providerMessageId'` }) + .from(chatDeliveries).innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatDeliveries.endpointId), eq(chatEndpoints.companyId, chatDeliveries.companyId))) + .where(and(eq(chatDeliveries.endpointId, input.endpointId), eq(chatEndpoints.provider, "github"), + inArray(chatDeliveries.eventKind, ["mention", "message", "direct_message"]), + input.admissionDependencyEventId ? ne(chatDeliveries.providerEventId, input.admissionDependencyEventId) : undefined, + sql`${chatDeliveries.normalizedEvent}->'message'->>'sourceProviderMessageId' = ${input.messageId}`, + sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId' = ${input.threadId}`)); + if (requests.length) { + for (const request of requests) await recordLifecycleDelivery({ ...input, messageId: request.messageId, + providerEventId: `${input.providerEventId ?? `${input.eventKind}:${input.threadId}:${input.messageId}:${input.revision ?? "once"}`}:${request.id}` }, runtimeContext, true); + return; + } + } + const durableThreadId = durableExternalThreadIdentity(input.threadId); + const providerEventId = + input.providerEventId ?? + `${input.eventKind}:${durableThreadId}:${input.messageId}:${input.revision ?? "once"}`; + const targetProviderEventId = `${durableThreadId}:${input.messageId}`; + const admission = await db.transaction(async (tx) => { + const endpoint = await tx + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, input.endpointId)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!endpoint) throw notFound("Chat endpoint not found"); + if ( + runtimeContext && + !(await runtimeCallbackEndpoint(tx, endpoint.id, runtimeContext, [ + "verifying", + "active", + ])) + ) + return null; + // A provider has already authenticated this callback. During setup the + // message can belong to the test conversation, so retain its correction. + // Paused or unhealthy connections acknowledge late callbacks without + // mutating the bound task or causing provider retry storms. + if (endpoint.status !== "verifying" && endpoint.status !== "active") + return null; + const reorderWindow = INGRESS_REORDER_WINDOW_MS[endpoint.provider]; + const scheduledAt = + options.deferWebhookProcessing === true && reorderWindow + ? (scheduledConversationDrains.get( + conversationDrainKey(input.endpointId, input.threadId), + ) ?? Date.now() + reorderWindow) + : null; + await notifyChatDeliveryWork(tx); + const [delivery] = await tx + .insert(chatDeliveries) + .values({ + companyId: endpoint.companyId, + endpointId: input.endpointId, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind: input.eventKind, + normalizedEvent: { + providerEventId, + kind: input.eventKind, + ...(runtimeContext + ? { + runtimeContext: { + credentialFingerprint: runtimeContext.credentialFingerprint, + generation: runtimeContext.generation, + }, + } + : {}), + ...(input.actor ? { principal: input.actor } : {}), + conversation: { externalThreadId: input.threadId }, + message: { + providerMessageId: input.messageId, + targetProviderEventId, + ...(input.admissionDependencyEventId ? { admissionDependencyEventId: input.admissionDependencyEventId } : {}), + ...(endpoint.provider === "github" && input.isBotMessage === true + ? { isBotMessage: true } + : {}), + providerMessageSequence: input.providerMessageSequence ?? null, + providerSentAt: input.providerSentAt ?? null, + providerUpdateId: input.providerUpdateId ?? null, + text: input.text, + }, + }, + state: "received", + nextAttemptAt: scheduledAt ? new Date(scheduledAt) : null, + }) + .onConflictDoNothing() + .returning(); + if (delivery) return { delivery, endpoint, scheduledAt }; + const existing = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, input.endpointId), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + return existing ? { delivery: existing, endpoint, scheduledAt } : null; + }); + if (admission) { + // Lifecycle callbacks can carry a new regional Bot Connector route. Keep + // that provider-authenticated route current even when processing is + // deferred, but deliberately omit the raw activity here. Lifecycle + // callbacks never populate the adapter's user cache; the admitted root + // message already established the principal metadata they must match. + await recordCurrentMicrosoftTeamsRoute( + admission.endpoint, + runtimeContext, + input.threadId, + input.raw, + false, + ); + } + if ( + !admission || + admission.delivery.state === "processed" || + admission.delivery.state === "filtered" || + admission.delivery.state === "failed" + ) + return; + if (options.deferWebhookProcessing === true) { + scheduleConversationDrain( + input.endpointId, + input.threadId, + admission.scheduledAt ?? Date.now(), + ); + return; + } + await drainConversationDeliveries(input.endpointId, input.threadId); + } + + function lifecycleMessageFromDelivery(delivery: DeliveryRow): { + actor: LifecycleActor | null; + isBotMessage: boolean; + messageId: string; + providerSentAt: string | null; + targetProviderEventId: string; + text: string; + threadId: string; + } | null { + if ( + delivery.eventKind !== "message_updated" && + delivery.eventKind !== "message_deleted" && + delivery.eventKind !== "message_restored" + ) + return null; + const normalized = delivery.normalizedEvent as { + principal?: { + displayName?: unknown; + externalId?: unknown; + handle?: unknown; + }; + message?: { + isBotMessage?: unknown; + providerMessageId?: unknown; + providerSentAt?: unknown; + targetProviderEventId?: unknown; + text?: unknown; + }; + }; + const messageId = normalized.message?.providerMessageId; + const targetProviderEventId = normalized.message?.targetProviderEventId; + const providerSentAt = normalized.message?.providerSentAt; + const text = normalized.message?.text; + const threadId = normalizedDeliveryThreadId(delivery); + const externalId = normalized.principal?.externalId; + const actor = + typeof externalId === "string" && externalId + ? { + externalId, + displayName: + typeof normalized.principal?.displayName === "string" + ? normalized.principal.displayName + : externalId, + handle: + typeof normalized.principal?.handle === "string" + ? normalized.principal.handle + : externalId, + } + : null; + return typeof messageId === "string" && + typeof targetProviderEventId === "string" && + typeof text === "string" && + threadId !== null + ? { + actor, + isBotMessage: normalized.message?.isBotMessage === true, + messageId, + providerSentAt: + typeof providerSentAt === "string" ? providerSentAt : null, + targetProviderEventId, + text, + threadId, + } + : null; + } + + async function processLifecycleDelivery( + endpoint: EndpointRow, + candidate: DeliveryRow, + ): Promise { + const lifecycle = lifecycleMessageFromDelivery(candidate); + if (!lifecycle) { + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "failed", + redactedError: "Normalized lifecycle delivery is incomplete", + updatedAt: new Date(), + }) + .where(eq(chatDeliveries.id, candidate.id)); + }); + return; + } + + const now = new Date(); + const staleBefore = new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS); + const claimConditions = [ + eq(chatDeliveries.id, candidate.id), + eq(chatDeliveries.state, candidate.state), + ]; + if (candidate.state === "processing") + claimConditions.push(lte(chatDeliveries.updatedAt, staleBefore)); + const [activeDelivery] = await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "processing", + attempts: candidate.attempts + 1, + nextAttemptAt: null, + redactedError: null, + updatedAt: now, + }) + .where(and(...claimConditions)) + .returning(); + }); + if (!activeDelivery) return; + + try { + await db.transaction(async (tx) => { + const admittedRuntimeContext = lifecycleRuntimeFence(activeDelivery); + const currentEndpoint = admittedRuntimeContext + ? await runtimeCallbackEndpoint( + tx, + endpoint.id, + admittedRuntimeContext, + ["verifying", "active"], + ) + : null; + if (!admittedRuntimeContext || !currentEndpoint) { + const filteredAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + processedAt: filteredAt, + redactedError: + "Message lifecycle callback belonged to a superseded runtime", + updatedAt: filteredAt, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + let githubBotSourceEdit = false; + if ( + currentEndpoint.provider === "github" && + activeDelivery.eventKind === "message_updated" + ) { + // Bot callbacks can arrive before the outbound publication commits + // its message link. The Boolean comes only from GitHub's verified + // sender/comment-author type, never from comment text. Legacy rows + // lack it, so also recognize exact already-published outbound links. + const linkedMessages = await tx + .select({ direction: chatMessageLinks.direction }) + .from(chatMessageLinks) + .innerJoin( + chatConversations, + and( + eq(chatConversations.id, chatMessageLinks.conversationId), + eq(chatConversations.companyId, chatMessageLinks.companyId), + eq(chatConversations.endpointId, chatMessageLinks.endpointId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, currentEndpoint.companyId), + eq(chatMessageLinks.endpointId, currentEndpoint.id), + eq(chatMessageLinks.providerMessageId, lifecycle.messageId), + eq(chatConversations.externalThreadId, lifecycle.threadId), + ), + ); + const outbound = linkedMessages.some( + (row) => row.direction === "outbound", + ); + // A bot editor can change an already-admitted human source. That + // authenticated change revokes the old source, but never admits the + // bot's new content. Unknown/self/outbound updates remain suppressed. + githubBotSourceEdit = + lifecycle.isBotMessage && + !outbound && + linkedMessages.some((row) => row.direction === "inbound"); + if (outbound || (lifecycle.isBotMessage && !githubBotSourceEdit)) { + const filteredAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + principalId: null, + nextAttemptAt: null, + processedAt: filteredAt, + updatedAt: filteredAt, + redactedError: + "Provider bot or outbound message updates do not create inbound work", + normalizedEvent: { + providerEventId: + activeDelivery.normalizedEvent.providerEventId ?? + activeDelivery.providerEventId, + kind: activeDelivery.eventKind, + conversation: { externalThreadId: lifecycle.threadId }, + message: { + providerMessageId: lifecycle.messageId, + targetProviderEventId: lifecycle.targetProviderEventId, + }, + filtering: { contentRetained: false }, + }, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + } + if ( + activeDelivery.eventKind === "message_updated" || + activeDelivery.eventKind === "message_deleted" || + activeDelivery.eventKind === "message_restored" + ) { + const latestDeleteTransition = await tx + .select({ + eventKind: chatDeliveries.eventKind, + providerSentAt: sql< + string | null + >`nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')`, + }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, activeDelivery.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + inArray(chatDeliveries.eventKind, [ + "message_deleted", + "message_restored", + ]), + eq(chatDeliveries.state, "processed"), + externalThreadIdentityCondition( + sql`${chatDeliveries.normalizedEvent}->'conversation'->>'externalThreadId'`, + lifecycle.threadId, + ), + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lifecycle.targetProviderEventId}`, + ), + ) + .orderBy( + desc( + sql`coalesce(nullif(${chatDeliveries.normalizedEvent}->'message'->>'providerSentAt', '')::timestamptz, ${chatDeliveries.processedAt})`, + ), + desc(sql`case ${chatDeliveries.eventKind} + when 'message_restored' then 2 + when 'message_deleted' then 1 + else 0 + end`), + desc(chatDeliveries.processedAt), + desc(chatDeliveries.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + const latestTransitionAt = latestDeleteTransition?.providerSentAt + ? Date.parse(latestDeleteTransition.providerSentAt) + : Number.NaN; + const incomingTransitionAt = lifecycle.providerSentAt + ? Date.parse(lifecycle.providerSentAt) + : Number.NaN; + const incomingTransitionIsStale = + latestDeleteTransition !== null && + Number.isFinite(latestTransitionAt) && + Number.isFinite(incomingTransitionAt) && + (incomingTransitionAt < latestTransitionAt || + (incomingTransitionAt === latestTransitionAt && + activeDelivery.eventKind === "message_deleted" && + latestDeleteTransition.eventKind === "message_restored")); + const editTargetsDeletedMessage = + activeDelivery.eventKind === "message_updated" && + latestDeleteTransition?.eventKind === "message_deleted"; + const editPredatesRestore = + activeDelivery.eventKind === "message_updated" && + latestDeleteTransition?.eventKind === "message_restored" && + Number.isFinite(latestTransitionAt) && + Number.isFinite(incomingTransitionAt) && + incomingTransitionAt < latestTransitionAt; + if ( + incomingTransitionIsStale || + editTargetsDeletedMessage || + editPredatesRestore + ) { + const filteredAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + normalizedEvent: { + providerEventId: + activeDelivery.normalizedEvent.providerEventId ?? + activeDelivery.providerEventId, + kind: activeDelivery.eventKind, + conversation: { externalThreadId: lifecycle.threadId }, + message: { + providerMessageId: lifecycle.messageId, + targetProviderEventId: lifecycle.targetProviderEventId, + }, + filtering: { contentRetained: false }, + }, + principalId: null, + nextAttemptAt: null, + processedAt: filteredAt, + redactedError: editTargetsDeletedMessage + ? "Message edit arrived after the provider message was deleted" + : "Message lifecycle callback was older than the current provider state", + updatedAt: filteredAt, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + } + const originalDelivery = await tx + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, endpoint.id), + eq( + chatDeliveries.providerEventId, + lifecycle.targetProviderEventId, + ), + ), + ) + .then((rows) => rows[0] ?? null); + const linkedTargets = await tx + .select({ + conversationId: chatConversations.id, + externalThreadId: chatConversations.externalThreadId, + issueId: chatConversations.issueId, + }) + .from(chatMessageLinks) + .innerJoin( + chatConversations, + and( + eq(chatConversations.companyId, chatMessageLinks.companyId), + eq(chatConversations.id, chatMessageLinks.conversationId), + ), + ) + .where( + and( + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.providerMessageId, lifecycle.messageId), + eq(chatMessageLinks.direction, "inbound"), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .then((rows) => rows); + const linkedTarget = + linkedTargets.find((candidate) => + currentEndpoint.provider === "microsoft-teams" + ? sameTeamsThreadIdentity( + candidate.externalThreadId, + lifecycle.threadId, + ) + : candidate.externalThreadId === lifecycle.threadId, + ) ?? null; + + if (!linkedTarget) { + const originalIsOpen = + originalDelivery !== null && + ["received", "processing", "retry"].includes( + originalDelivery.state, + ); + const waitForRoot = + originalIsOpen || + activeDelivery.attempts <= ORPHAN_FOLLOW_UP_MAX_ATTEMPTS; + const resolutionAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set( + waitForRoot + ? { + state: "retry", + nextAttemptAt: new Date( + resolutionAt.getTime() + ORPHAN_FOLLOW_UP_GRACE_MS, + ), + redactedError: originalIsOpen + ? "Waiting for the original message to finish processing" + : "Waiting briefly for the original message", + updatedAt: resolutionAt, + } + : { + state: "filtered", + nextAttemptAt: null, + redactedError: + "Original message was not admitted to this conversation", + processedAt: resolutionAt, + updatedAt: resolutionAt, + }, + ) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + + const currentConversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, currentEndpoint.companyId), + eq(chatConversations.endpointId, currentEndpoint.id), + eq(chatConversations.id, linkedTarget.conversationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const currentResource = + currentConversation?.resourceId && + !currentConversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq( + chatEndpointResources.companyId, + currentEndpoint.companyId, + ), + eq(chatEndpointResources.endpointId, currentEndpoint.id), + eq( + chatEndpointResources.id, + currentConversation.resourceId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const retainSourceInvalidation = async ( + principalId: string | null, + reason: string, + ): Promise => { + if ( + !currentConversation || + activeDelivery.eventKind !== "message_updated" + ) + return false; + const invalidatedAt = new Date(); + // Provider authentication proves the exact source changed. The + // editor's current Paperclip rights govern admitting new content, + // not whether a later regrant can resurrect the stale old source. + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "processed", + conversationId: currentConversation.id, + principalId, + normalizedEvent: { + providerEventId: + activeDelivery.normalizedEvent.providerEventId ?? + activeDelivery.providerEventId, + kind: activeDelivery.eventKind, + runtimeContext: { + generation: admittedRuntimeContext.generation, + credentialFingerprint: + admittedRuntimeContext.credentialFingerprint, + }, + conversation: { externalThreadId: lifecycle.threadId }, + message: { + providerMessageId: lifecycle.messageId, + targetProviderEventId: lifecycle.targetProviderEventId, + providerSentAt: lifecycle.providerSentAt, + }, + filtering: { contentRetained: false }, + }, + nextAttemptAt: null, + processedAt: invalidatedAt, + redactedError: reason, + updatedAt: invalidatedAt, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return true; + }; + if ( + githubBotSourceEdit && + (await retainSourceInvalidation( + null, + "Provider bot edit invalidated an existing source without admitting bot content", + )) + ) + return; + + let lifecyclePrincipalId: string | null = null; + const requiresLifecycleActorAuthorization = + (activeDelivery.eventKind === "message_updated" || + activeDelivery.eventKind === "message_restored") && + (lifecycle.actor !== null || + currentEndpoint.provider === "github" || + currentEndpoint.provider === "telegram" || + currentEndpoint.provider === "microsoft-teams"); + if (requiresLifecycleActorAuthorization) { + const lifecyclePrincipal = lifecycle.actor + ? await tx + .select({ + id: chatExternalPrincipals.id, + isBot: chatExternalPrincipals.isBot, + kind: chatExternalPrincipals.kind, + }) + .from(chatExternalPrincipals) + .where( + and( + eq( + chatExternalPrincipals.companyId, + currentEndpoint.companyId, + ), + eq( + chatExternalPrincipals.provider, + currentEndpoint.provider, + ), + eq( + chatExternalPrincipals.providerAccountId, + currentEndpoint.providerAccountId ?? "unknown", + ), + eq( + chatExternalPrincipals.externalId, + lifecycle.actor.externalId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const authorization = + lifecyclePrincipal && + lifecyclePrincipal.kind === "user" && + !lifecyclePrincipal.isBot + ? await lockCurrentPrincipalAuthorization( + tx, + currentEndpoint, + lifecyclePrincipal.id, + ) + : null; + const authorizedPrincipalId = authorization?.allowed + ? (lifecyclePrincipal?.id ?? null) + : null; + if (!authorizedPrincipalId) { + if ( + await retainSourceInvalidation( + lifecyclePrincipal?.id ?? null, + "Provider edit invalidation retained without admitting content from an unauthorized actor", + ) + ) + return; + const filteredAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + principalId: lifecyclePrincipal?.id ?? null, + state: "filtered", + normalizedEvent: { + providerEventId: + activeDelivery.normalizedEvent.providerEventId ?? + activeDelivery.providerEventId, + kind: activeDelivery.eventKind, + conversation: { externalThreadId: lifecycle.threadId }, + message: { + providerMessageId: lifecycle.messageId, + targetProviderEventId: lifecycle.targetProviderEventId, + }, + filtering: { contentRetained: false }, + }, + nextAttemptAt: null, + processedAt: filteredAt, + redactedError: + "External message actor is no longer authorized for lifecycle events", + updatedAt: filteredAt, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + lifecyclePrincipalId = authorizedPrincipalId; + } + + const destinationAllowed = + currentConversation !== null && + ["active", "waiting"].includes(currentConversation.state) && + (currentConversation.isDirectMessage + ? currentEndpoint.allowDirectMessages + : nonDirectDestinationAllowed(currentEndpoint, currentResource)); + if (!destinationAllowed) { + const filteredAt = new Date(); + // A verified deletion or authorized edit still revokes the exactly + // linked source while reach is disabled. Re-enabling access must not + // revive stale files. Keep only the invalidation, never the new + // content: no task comment, attachment import or wake is allowed. + const retainInvalidation = + currentConversation !== null && + (activeDelivery.eventKind === "message_deleted" || + (activeDelivery.eventKind === "message_updated" && + lifecyclePrincipalId !== null)); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: retainInvalidation ? "processed" : "filtered", + ...(retainInvalidation + ? { + conversationId: currentConversation.id, + principalId: lifecyclePrincipalId, + } + : {}), + normalizedEvent: { + providerEventId: + activeDelivery.normalizedEvent.providerEventId ?? + activeDelivery.providerEventId, + kind: activeDelivery.eventKind, + ...(retainInvalidation + ? { + runtimeContext: { + generation: admittedRuntimeContext.generation, + credentialFingerprint: + admittedRuntimeContext.credentialFingerprint, + }, + } + : {}), + conversation: { externalThreadId: lifecycle.threadId }, + message: { + providerMessageId: lifecycle.messageId, + targetProviderEventId: lifecycle.targetProviderEventId, + ...(retainInvalidation + ? { providerSentAt: lifecycle.providerSentAt } + : {}), + }, + filtering: { contentRetained: false }, + }, + nextAttemptAt: null, + processedAt: filteredAt, + redactedError: retainInvalidation + ? activeDelivery.eventKind === "message_deleted" + ? "Provider deletion retained without task content while destination access is disabled" + : "Provider edit invalidation retained without task content while destination access is disabled" + : "Destination is no longer enabled for message lifecycle events", + updatedAt: filteredAt, + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return; + } + + await issuesSvc.addComment( + linkedTarget.issueId, + lifecycle.text, + {}, + { authorType: "system" }, + tx, + ); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + conversationId: linkedTarget.conversationId, + principalId: lifecyclePrincipalId, + state: "processed", + processedAt: new Date(), + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + }); + } catch (error) { + const terminal = activeDelivery.attempts >= 5; + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: terminal ? "failed" : "retry", + nextAttemptAt: terminal + ? null + : new Date( + Date.now() + + Math.min(60_000, 1000 * 2 ** activeDelivery.attempts), + ), + redactedError: redactError(error), + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + }); + throw error; + } + } + + async function handleMessageUpdated( + event: ChatSdkMessageUpdatedCallbackEvent, + runtimeContext: RuntimeContext, + ) { + if ( + event.provider === "discord" && + !(await ensureDiscordGatewayRuntimeIsCurrent( + event.endpointId, + runtimeContext, + )) + ) { + return; + } + const text = `An external message was edited:\n\n${event.message.text.slice(0, MAX_INBOUND_TEXT)}`; + const telegramRich = + event.provider === "telegram" + ? normalizeTelegramRichMessage(event.message.raw) + : null; + await recordLifecycleDelivery( + { + actor: lifecycleActorFromAuthor( + event.provider, + event.message.author, + event.message.raw, + ), + endpointId: event.endpointId, + threadId: event.thread.id, + messageId: event.message.id, + eventKind: "message_updated", + text, + providerMessageSequence: + event.provider === "telegram" + ? telegramMessageSequence(event.message.raw) + : null, + providerSentAt: + ( + event.message.metadata.editedAt ?? event.message.metadata.dateSent + )?.toISOString() ?? null, + raw: event.message.raw, + revision: `${event.message.metadata.editedAt?.toISOString() ?? "unknown"}:${createHash("sha256").update(event.message.text).digest("hex")}${event.provider === "slack" ? `:${slackLifecycleFilesDigest(event.message.raw)}` : event.provider === "discord" ? `:${discordLifecycleFilesDigest(event.message.raw)}` : telegramRich ? `:${telegramRich.mediaDigest}` : ""}`, + }, + runtimeContext, + ); + } + + async function handleMessageDeleted( + event: ChatSdkCallbackEvent, + runtimeContext: RuntimeContext, + ) { + if ( + event.provider === "discord" && + !(await ensureDiscordGatewayRuntimeIsCurrent( + event.endpointId, + runtimeContext, + )) + ) { + return; + } + await recordLifecycleDelivery( + { + endpointId: event.endpointId, + threadId: event.event.threadId, + messageId: event.event.messageId, + eventKind: "message_deleted", + text: "An external message in this conversation was deleted.", + providerSentAt: event.event.deletedAt?.toISOString() ?? null, + raw: event.event.raw, + revision: event.event.deletedAt?.toISOString() ?? null, + }, + runtimeContext, + ); + } + return { conversationForThread, recordLifecycleDelivery, processLifecycleDelivery, handleMessageUpdated, handleMessageDeleted }; +} diff --git a/server/src/services/chat-channels/inbound/resource-admission.ts b/server/src/services/chat-channels/inbound/resource-admission.ts new file mode 100644 index 00000000000..e83ba1c9749 --- /dev/null +++ b/server/src/services/chat-channels/inbound/resource-admission.ts @@ -0,0 +1,113 @@ +import { createChatInboundAuthority } from "./authorization.js"; + +import type { EndpointRow } from "../runtime-types.js"; + +import { + and, + eq, + ne, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpointResources, + chatEndpoints, +} from "@paperclipai/db"; + +import { + notFound, +} from "../../../errors.js"; + +import type { + Thread, +} from "chat"; + +import type { ChatSurfaceKind } from "./surfaces.js"; + +type MessageResourceAdmissionInput = { + endpoint: EndpointRow; + thread: Thread; + addressed: boolean; + suppressSetupDestinationActivation: boolean; + surfaceKind: ChatSurfaceKind; +}; +export function createMessageResourceAdmission(db: Db, options: { ensureResource: ReturnType["ensureResource"] }) { + const { ensureResource } = options; + async function admitMessageResource(input: MessageResourceAdmissionInput) { + const { endpoint, thread, addressed, suppressSetupDestinationActivation, surfaceKind } = input; + const mayEnableSetupDestination = + // Slack channels start enabled. Never use first-test activation to + // override an existing channel the operator explicitly disabled. + endpoint.provider !== "slack" && + endpoint.provider !== "imessage-photon" && + !thread.isDM && + endpoint.status === "verifying" && + addressed && + !suppressSetupDestinationActivation && + !( + endpoint.provider === "microsoft-teams" && + surfaceKind === "linear_group" + ); + const resourceAdmission = mayEnableSetupDestination + ? await db.transaction(async (tx) => { + const currentEndpoint = await tx + .select({ status: chatEndpoints.status }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!currentEndpoint) throw notFound("Chat endpoint not found"); + const enabledResourceCount = await tx + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + ne(chatEndpointResources.type, "direct_message"), + ne(chatEndpointResources.type, "group_chat"), + ), + ) + .then((rows) => rows[0]?.count ?? 0); + const enableSetupDestination = + currentEndpoint.status === "verifying" && + enabledResourceCount === 0; + let resource = await ensureResource( + endpoint, + thread, + enableSetupDestination, + tx, + ); + if (enableSetupDestination && !resource.enabled) { + resource = await tx + .update(chatEndpointResources) + .set({ enabled: true, updatedAt: new Date() }) + .where(eq(chatEndpointResources.id, resource.id)) + .returning() + .then((rows) => rows[0] ?? resource); + } + return { enabledResourceCount, resource }; + }) + : { + enabledResourceCount: await db + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + ne(chatEndpointResources.type, "direct_message"), + ne(chatEndpointResources.type, "group_chat"), + ), + ) + .then((rows) => rows[0]?.count ?? 0), + resource: await ensureResource(endpoint, thread, false), + }; + return resourceAdmission; + } + return { admitMessageResource }; +} diff --git a/server/src/services/chat-channels/inbound/surfaces.ts b/server/src/services/chat-channels/inbound/surfaces.ts new file mode 100644 index 00000000000..7f89d794c6b --- /dev/null +++ b/server/src/services/chat-channels/inbound/surfaces.ts @@ -0,0 +1,180 @@ +import { slackThreadChannelId } from "../slack/messages.js"; + +import type { EndpointRow } from "../runtime-types.js"; + +import { baseTeamsConversationId } from "../microsoft-teams/resources.js"; +import { slackResourceLabelIsFallback } from "../slack/resources.js"; +import { telegramResourceLabelIsFallback } from "../telegram/resources.js"; + +import { + canonicalTeamsThreadId, + teamsConversationId, +} from "../../chat-control-chronology.js"; + +import { + chatEndpointResources, +} from "@paperclipai/db"; +import type { + ChatProvider, +} from "@paperclipai/shared"; + +import type { + Thread, +} from "chat"; + +type ResourceRow = typeof chatEndpointResources.$inferSelect; + + +type ChatSurfaceKind = "direct_message" | "linear_group" | "native_thread"; + +function providerResourceType( + provider: ChatProvider, + surfaceKind: ChatSurfaceKind, +): string { + if (surfaceKind === "direct_message") return "direct_message"; + if (provider === "github") return "repository"; + if (provider === "imessage-photon") return "group_chat"; + if (provider === "discord") return "channel"; + if (provider === "microsoft-teams") + return surfaceKind === "linear_group" ? "group_chat" : "channel"; + // Telegram topics are task/thread boundaries inside one group resource. The + // operator grants access to the chat once rather than having to rediscover + // and enable every topic independently. + if (provider === "telegram") return "chat"; + return "channel"; +} + + +function sameTeamsThreadIdentity(left: string, right: string): boolean { + const leftConversationId = teamsConversationId(left); + const rightConversationId = teamsConversationId(right); + return ( + leftConversationId !== null && + rightConversationId !== null && + leftConversationId === rightConversationId + ); +} + + +function durableExternalThreadIdentity(threadId: string): string { + return canonicalTeamsThreadId(threadId) ?? threadId; +} + +function teamsThreadRootMessageId(threadId: string): string | null { + const conversationId = teamsConversationId(threadId); + if (!conversationId) return null; + return /;messageid=([^;]+)/i.exec(conversationId)?.[1] ?? null; +} + +function canonicalProviderResourceId( + provider: ChatProvider, + thread: Pick, +): string { + if (provider === "slack") return thread.channelId.replace(/^slack:/, ""); + if (provider === "github") + return thread.channelId.replace(/^github:/, "").toLowerCase(); + if (provider === "discord") { + const parts = thread.channelId.split(":"); + return parts[2] ?? thread.channelId; + } + if (provider === "microsoft-teams") { + const conversationId = + teamsConversationId(thread.channelId) ?? teamsConversationId(thread.id); + return conversationId + ? baseTeamsConversationId(conversationId) + : baseTeamsConversationId(thread.channelId); + } + if (provider === "telegram") + return thread.channelId.replace(/^telegram:/, ""); + return thread.channelId; +} + +function providerResourceLabelFromThread( + provider: ChatProvider, + thread: Pick, +): { label: string; fallback: boolean } { + const providerResourceId = canonicalProviderResourceId(provider, thread); + const candidate = thread.channel.name?.trim(); + if (!candidate) return { label: providerResourceId, fallback: true }; + if ( + (provider === "slack" && + slackResourceLabelIsFallback(providerResourceId, candidate)) || + (provider === "telegram" && + telegramResourceLabelIsFallback(providerResourceId, candidate)) + ) { + return { label: providerResourceId, fallback: true }; + } + return { label: candidate, fallback: false }; +} + +/** + * Native channel threads keep one issue forever. Linear surfaces instead + * advance through Paperclip task generations because their provider id is + * reused after a task completes or the user explicitly starts a new task. + */ +function chatSurfaceKind( + provider: ChatProvider, + thread: Thread, +): ChatSurfaceKind { + if (thread.isDM) return "direct_message"; + if (provider === "imessage-photon") return "linear_group"; + if (provider === "telegram") { + return /^telegram:[^:]+:[^:]+$/.test(thread.id) + ? "native_thread" + : "linear_group"; + } + if (provider === "microsoft-teams") { + const conversationId = teamsConversationId(thread.id); + return conversationId?.includes(";messageid=") + ? "native_thread" + : "linear_group"; + } + return "native_thread"; +} + +/** + * Teams group chats are admitted as a class: installation at the provider and + * the endpoint's group-chat toggle are the two gates the operator can + * actually control. Teams channels, and every other non-DM destination, + * retain the explicit per-resource enablement gate shown in Settings. + */ +function nonDirectDestinationAllowed( + endpoint: EndpointRow, + resource: ResourceRow | null | undefined, +): boolean { + if (!resource || resource.availability !== "available") return false; + if ( + endpoint.provider === "microsoft-teams" && + resource.type === "group_chat" + ) { + return endpoint.allowGroupChats; + } + return resource.enabled; +} + +function linearControlCommand(text: string): "new" | "close" | "status" | null { + const match = /^\/(new|close|status)(?:@[\w.-]+)?\s*$/i.exec(text.trim()); + return ( + (match?.[1]?.toLowerCase() as "new" | "close" | "status" | undefined) ?? + null + ); +} + +function actionThreadMatchesConversation( + provider: ChatProvider, + actionThreadId: string, + conversationThreadId: string, +): boolean { + if (provider === "microsoft-teams") { + return sameTeamsThreadIdentity(actionThreadId, conversationThreadId); + } + if (provider !== "slack") return actionThreadId === conversationThreadId; + const actionChannelId = slackThreadChannelId(actionThreadId); + return ( + actionChannelId !== null && + actionChannelId === slackThreadChannelId(conversationThreadId) + ); +} + +export { providerResourceType, sameTeamsThreadIdentity, durableExternalThreadIdentity, teamsThreadRootMessageId, canonicalProviderResourceId, providerResourceLabelFromThread, chatSurfaceKind, nonDirectDestinationAllowed, linearControlCommand, actionThreadMatchesConversation }; +export type { ChatSurfaceKind }; diff --git a/server/src/services/chat-channels/interactions/callbacks.ts b/server/src/services/chat-channels/interactions/callbacks.ts new file mode 100644 index 00000000000..7db0a302b3b --- /dev/null +++ b/server/src/services/chat-channels/interactions/callbacks.ts @@ -0,0 +1,2013 @@ +import { createDiscordQuestionForms } from "../discord/question-forms.js"; + +import { createProviderEffects, type ProviderEffectPayload } from "../provider-effects.js"; +import { createTelegramCallbackNoticeAuthority } from "../telegram/callback-notices.js"; + +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js"; + +import { notifyChatDeliveryWork, notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatMessageLinks, + chatPublications, + issues, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatAdapterCapabilities, + ChatProvider, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + readTelegramCallbackProvenance, + telegramCallbackThreadId, + TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, +} from "../../chat-telegram-ephemeral.js"; + +import { + forbidden, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; + +import { + type ChatSdkCallbackEvent, +} from "../../chat-sdk-runtime.js"; +import type { + ChatSdkStatePersistence, +} from "../../chat-sdk-state.js"; +import { + logActivity, +} from "../../activity-log.js"; + +import { projectSafeChatPublication } from "../../chat-publication-projection.js"; + +import { + discordQuestionFormCorrectionModal, + discordQuestionFormDenialResponse, + discordQuestionFormThreadId, + isDiscordQuestionFormCorrectionId, + loadDiscordQuestionFormCorrection, +} from "../../chat-discord-question-forms.js"; + +import { + enqueueTerminalIssueInteractionChatPublications, + nativeChatQuestion, + TELEGRAM_CALLBACK_DATA_LIMIT_BYTES, + telegramChatSdkCallbackData, +} from "../../chat-interaction-publications.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; +import { + questionResponseDeliveryService, +} from "../../question-response-delivery.js"; +import { + chatQuestionFormDenialResponse, + chatQuestionFormValidationResponse, + claimChatQuestionFormSubmission, + completeChatQuestionFormSubmission, + isChatQuestionFormOpenActionId, + loadChatQuestionFormSubmissionToken, + resolveChatQuestionFormOpen, + validateChatQuestionFormSubmission, +} from "../../chat-question-forms.js"; + +import type { + ActionEvent, + Author, + ModalResponse, + ModalSubmitEvent, + Thread, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; +import { createTelegramConfirmationActions } from "../telegram/confirmation-actions.js"; +type ResourceRow = typeof chatEndpointResources.$inferSelect; +type PrincipalResolution = { principal: typeof chatExternalPrincipals.$inferSelect; userId: string | null; linkedDenied: boolean }; +type InteractionCallbacksOptions = { + CAPABILITIES: Record; + MAX_ERROR_TEXT: number; + actionThreadMatchesConversation: (provider: ChatProvider, actionThreadId: string, conversationThreadId: string) => boolean; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: ResourceRow | null | undefined) => boolean; + sameTeamsThreadIdentity: (left: string, right: string) => boolean; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise; + endpointRecord: (endpointId: string) => Promise; + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: RuntimeContext, threadId: string, raw: unknown) => Promise; + lockTelegramCallbackNoticeAuthority: ReturnType["lockTelegramCallbackNoticeAuthority"]; + stageProviderEffect: ReturnType["stageProviderEffect"]; + scheduleProviderEffect: (actionId: string, thread?: Thread) => void; + invalidateRuntime: (endpointId: string) => Promise; + redactError: (error: unknown) => string; + persistence: ChatSdkStatePersistence; + questionResponses: Pick, "deliver">; + scheduleMessageProcessing: (task: () => Promise) => void; + processPendingPublications: () => Promise; + confirmationResolutionPersistBarrier?: () => Promise; + questionFormOpenAuthorizationBarrier?: () => Promise; + questionResolutionPersistBarrier?: () => Promise; +}; + +export function createChatInteractionCallbacks(db: Db, options: InteractionCallbacksOptions) { + const { CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity, ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, recordCurrentMicrosoftTeamsRoute, lockTelegramCallbackNoticeAuthority, stageProviderEffect, scheduleProviderEffect, invalidateRuntime, redactError, persistence, questionResponses, scheduleMessageProcessing, processPendingPublications } = options; + const { handleDiscordFormCorrection, acceptDiscordQuestionForm } = createDiscordQuestionForms(db, { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, persistence }); + const { reconcileTerminalConfirmationActions, handleTelegramConfirmation } = createTelegramConfirmationActions(db, { + requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, + get confirmationResolutionPersistBarrier() { return options.confirmationResolutionPersistBarrier; }, + }); + + + async function denyExternalAction( + endpoint: EndpointRow, + event: ChatSdkCallbackEvent, + runtimeContext: LifecycleRuntimeFence, + safelyKnown: { + conversationId?: string | null; + principalId?: string | null; + } = {}, + ): Promise { + const telegramCallback = + endpoint.provider === "telegram" && endpoint.botExternalId + ? readTelegramCallbackProvenance(event.telegramCallback, { + companyId: endpoint.companyId, + endpointId: endpoint.id, + botUserId: endpoint.botExternalId, + threadId: event.event.threadId, + messageId: event.event.messageId, + userId: event.event.user.userId, + }) + : null; + const fingerprint = createHash("sha256") + .update( + JSON.stringify( + telegramCallback + ? [ + endpoint.id, + "telegram_callback_denial", + telegramCallback.botUserId, + telegramCallback.callbackId, + ] + : [ + endpoint.id, + event.provider, + event.event.threadId, + event.event.messageId, + event.event.user.userId, + event.event.actionId, + event.event.triggerId ?? null, + ], + ), + ) + .digest("hex"); + const providerEventId = `action-denied:${fingerprint}`; + const processedAt = new Date(); + const actionThread = event.event.thread; + const effectPayload = + actionThread && + typeof actionThread.postEphemeral === "function" && + CAPABILITIES[endpoint.provider].ephemeralMessages + ? ({ + version: 1, + effect: "ephemeral_message", + authorizationMode: "safe_notice", + threadId: event.event.threadId, + userId: event.event.user.userId, + text: "This action is no longer available. Open the linked Paperclip task or ask an operator to link this account.", + fallbackText: "This Paperclip action is no longer available.", + settleDelivery: false, + } as const) + : null; + let effect: typeof chatActions.$inferSelect | null = null; + try { + effect = await db.transaction(async (tx) => { + const currentEndpoint = telegramCallback + ? await runtimeCallbackEndpoint(tx, endpoint.id, runtimeContext, [ + "active", + ]) + : null; + const telegramAuthorizationSha256 = + currentEndpoint && telegramCallback + ? await lockTelegramCallbackNoticeAuthority( + tx, + currentEndpoint, + telegramCallback, + safelyKnown.principalId ?? null, + ) + : null; + await notifyChatDeliveryWork(tx); + const [inserted] = await tx + .insert(chatDeliveries) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: safelyKnown.conversationId ?? null, + principalId: safelyKnown.principalId ?? null, + providerEventId, + deduplicationKey: fingerprint, + eventKind: "action", + normalizedEvent: { + providerEventId, + kind: "action", + authorization: { outcome: "denied" }, + ...(telegramCallback + ? { telegramCallback, telegramAuthorizationSha256 } + : {}), + }, + state: "filtered", + attempts: 1, + redactedError: "External action denied by Paperclip authorization", + processedAt, + updatedAt: processedAt, + }) + .onConflictDoNothing() + .returning(); + const notice: Omit< + ProviderEffectPayload, + "runtimeGeneration" | "credentialFingerprint" + > | null = + telegramCallback && telegramAuthorizationSha256 + ? { + version: 1, + effect: "telegram_callback_notice", + authorizationMode: "safe_notice", + telegramCallback, + telegramAuthorizationSha256, + threadId: telegramCallbackThreadId(telegramCallback), + userId: telegramCallback.receiverUserId, + text: TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, + settleDelivery: false, + } + : effectPayload; + if (!inserted || !notice) return null; + return stageProviderEffect(tx, { + endpoint, + deliveryId: inserted.id, + conversationId: safelyKnown.conversationId ?? null, + principalId: safelyKnown.principalId ?? null, + providerActionId: `provider_effect:${providerEventId}`, + payload: notice, + runtimeContext, + }); + }); + } catch (error) { + logger.warn( + { + endpointId: endpoint.id, + provider: endpoint.provider, + error: redactError(error), + }, + "could not record denied external chat action", + ); + // Provider retries are useful only while the authoritative denial could + // not be recorded. Once the filtered delivery exists, a policy denial + // is a successfully handled webhook and must not be surfaced as a + // callback failure (which the Chat SDK correctly converts to a 503). + throw error; + } + if (effect && actionThread) { + // The callback is acknowledged after the authoritative denial and its + // provider effect are durable. Slow provider I/O runs out of band; a + // redelivery finds the same action and cannot enqueue another notice. + scheduleProviderEffect(effect.id, actionThread); + } + } + + async function recordModalOpenFailure( + endpoint: EndpointRow, + event: ChatSdkCallbackEvent, + runtimeContext: LifecycleRuntimeFence, + safelyKnown: { + attemptActionId?: string | null; + conversationId?: string | null; + principalId?: string | null; + resource?: ResourceRow | null; + }, + error: unknown, + ): Promise { + const disposition = classifyChatPublicationError(error, 1); + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + endpoint.id, + event.provider, + "modal_open", + event.event.triggerId ?? null, + event.event.threadId, + event.event.messageId, + event.event.user.userId, + event.event.actionId, + ]), + ) + .digest("hex"); + const providerEventId = `modal-open-failed:${fingerprint}`; + const failure = redactError(error); + const processedAt = new Date(); + const actionThread = event.event.thread; + const effectPayload = + actionThread && + typeof actionThread.postEphemeral === "function" && + CAPABILITIES[endpoint.provider].ephemeralMessages + ? ({ + version: 1, + effect: "ephemeral_message", + authorizationMode: "safe_notice", + threadId: event.event.threadId, + userId: event.event.user.userId, + text: "Paperclip could not open this form. Try the action again or open the linked Paperclip task.", + fallbackText: "Paperclip could not open this form.", + settleDelivery: false, + } as const) + : null; + let effect: typeof chatActions.$inferSelect | null = null; + let invalidateEndpointRuntime = false; + try { + effect = await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + const [inserted] = await tx + .insert(chatDeliveries) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: safelyKnown.conversationId ?? null, + principalId: safelyKnown.principalId ?? null, + providerEventId, + deduplicationKey: fingerprint, + eventKind: "action", + normalizedEvent: { + providerEventId, + kind: "action", + modal: { + phase: "open", + outcome: "failed", + disposition: disposition.kind, + }, + }, + // Modal callbacks are acknowledged after this audit row commits. + // They must never enter the replayable inbound-delivery queue. + state: "filtered", + attempts: 1, + redactedError: `Provider modal open failed: ${failure}`.slice( + 0, + MAX_ERROR_TEXT, + ), + processedAt, + updatedAt: processedAt, + }) + .onConflictDoNothing() + .returning(); + if (!inserted) return null; + + if (safelyKnown.attemptActionId) { + await tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: `question_form_open_${disposition.kind}`, + retryable: false, + }, + updatedAt: processedAt, + }) + .where( + and( + eq(chatActions.id, safelyKnown.attemptActionId), + eq(chatActions.status, "processing"), + ), + ); + } + + if (disposition.kind === "endpoint_attention") { + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + endpoint.id, + runtimeContext, + ["active"], + ); + if (currentEndpoint) { + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: + "Provider credentials or permissions need attention", + lastError: failure, + updatedAt: processedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: + "Provider credentials or permissions need attention", + lastError: failure, + healthCheckedAt: processedAt, + updatedAt: processedAt, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + invalidateEndpointRuntime = true; + } + } else if ( + disposition.kind === "resource_unavailable" && + safelyKnown.resource + ) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: processedAt }) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, safelyKnown.resource.id), + ), + ); + } + + if (!effectPayload) return null; + return stageProviderEffect(tx, { + endpoint, + deliveryId: inserted.id, + conversationId: safelyKnown.conversationId ?? null, + principalId: safelyKnown.principalId ?? null, + providerActionId: `provider_effect:${providerEventId}`, + payload: effectPayload, + runtimeContext, + }); + }); + } catch (auditError) { + logger.warn( + { + endpointId: endpoint.id, + provider: endpoint.provider, + error: redactError(auditError), + }, + "could not record external chat modal-open failure", + ); + throw auditError; + } + if (effect && actionThread) scheduleProviderEffect(effect.id, actionThread); + if (invalidateEndpointRuntime) { + await invalidateRuntime(endpoint.id).catch(() => undefined); + } + } + + async function recordModalSubmitDenial( + endpoint: EndpointRow, + event: ChatSdkCallbackEvent, + input: { + code: string; + conversationId?: string | null; + principalId?: string | null; + }, + database: DbOrTransaction = db, + ): Promise { + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + endpoint.id, + event.provider, + "modal_submit", + event.event.callbackId, + event.event.viewId, + event.event.user.userId, + input.code, + ]), + ) + .digest("hex"); + const providerEventId = `modal-submit-denied:${fingerprint}`; + const processedAt = new Date(); + try { + await database + .insert(chatDeliveries) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: input.conversationId ?? null, + principalId: input.principalId ?? null, + providerEventId, + deduplicationKey: fingerprint, + eventKind: "action", + normalizedEvent: { + providerEventId, + kind: "action", + authorization: { outcome: "denied" }, + modal: { + phase: "submit", + outcome: "denied", + code: input.code, + }, + }, + state: "filtered", + attempts: 1, + redactedError: "External chat modal submission denied by Paperclip", + processedAt, + updatedAt: processedAt, + }) + .onConflictDoNothing(); + } catch (error) { + logger.warn( + { + endpointId: endpoint.id, + provider: endpoint.provider, + error: redactError(error), + }, + "could not record denied external chat modal submission", + ); + throw error; + } + } + + async function handleAction( + event: ChatSdkCallbackEvent, + runtimeContext: RuntimeContext, + ) { + const record = await runtimeCallbackRecord( + event.endpointId, + runtimeContext, + ["active"], + ); + if (!record) { + throw forbidden("This chat action is not a current Paperclip question"); + } + const deny = async (safelyKnown?: { + conversationId?: string | null; + principalId?: string | null; + }) => { + await denyExternalAction( + record.endpoint, + event, + runtimeContext, + safelyKnown, + ); + if ( + event.provider === "discord" && + event.transport === "discord_gateway" + ) { + // Discord renders deferUpdate as a successful click. After the denial + // audit is durable, surface a transport-only sentinel so the Gateway + // adapter can withhold that misleading acknowledgement. The runtime, + // not raw JSON or discord.js methods, supplies the ingress context. + throw Object.assign( + new Error("Discord Gateway action was not admitted by Paperclip"), + { code: "chat_discord_gateway_action_rejected" }, + ); + } + }; + if ( + record.endpoint.status !== "active" || + record.endpoint.provider !== event.provider || + record.endpoint.capabilities.actions !== true + ) { + return deny(); + } + const principal = await ensurePrincipal( + record.endpoint, + event.event.user, + event.event.raw, + ); + // Executable external actions are deliberately stricter than ordinary + // sponsored-guest messages. They require a current endpoint-scoped link to + // an active non-viewer Paperclip member, and never run as a guest sponsor. + if ( + principal.linkedDenied || + !principal.userId || + principal.principal.kind !== "user" || + principal.principal.isBot + ) { + return deny({ principalId: principal.principal.id }); + } + const isCorrection = isDiscordQuestionFormCorrectionId( + event.event.actionId, + ); + const correction = + isCorrection && + event.provider === "discord" && + event.transport === "discord_gateway" + ? await loadDiscordQuestionFormCorrection( + persistence, + { + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + }, + event.event.actionId, + { + principalId: principal.principal.id, + userId: principal.userId, + externalUserId: event.event.user.userId, + }, + event.event.threadId, + ) + : null; + if ( + isCorrection && + (!correction || + discordQuestionFormThreadId(event.event.raw) !== event.event.threadId) + ) + return deny({ principalId: principal.principal.id }); + // The private correction handle selects the original publication, never + // the new ephemeral error message. It does not bypass any current source, + // destination, actor, open-token, or pending-interaction check below. + const sourceActionId = correction?.openActionId ?? event.event.actionId; + const sourceMessageId = + correction?.providerMessageId ?? event.event.messageId; + const actionBinding = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, record.endpoint.id), + inArray(chatActions.kind, [ + "question_answer", + "question_form_open", + "confirmation_response", + ]), + eq(chatActions.providerActionId, sourceActionId), + ), + ) + .then((rows) => (rows.length === 1 ? rows[0]! : null)); + const originalPublicationId = actionBinding?.payload.publicationId; + const actionInteractionId = actionBinding?.payload.interactionId; + if ( + correction && + (actionBinding?.kind !== "question_form_open" || + actionBinding.conversationId !== correction.conversationId || + originalPublicationId !== correction.publicationId || + actionInteractionId !== correction.interactionId || + actionBinding.payload.formActionId !== correction.submitActionId) + ) + return deny({ principalId: principal.principal.id }); + if ( + !actionBinding?.conversationId || + typeof originalPublicationId !== "string" || + typeof actionInteractionId !== "string" + ) { + return deny({ principalId: principal.principal.id }); + } + // The opaque provider action is the immutable link to the original + // publication. Resolve the conversation from that token, then verify the + // provider's thread independently; never trust a callback message id to + // select a Paperclip task. + const conversation = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, record.endpoint.companyId), + eq(chatConversations.endpointId, record.endpoint.id), + eq(chatConversations.id, actionBinding.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + !conversation || + !actionThreadMatchesConversation( + event.provider, + event.event.threadId, + conversation.externalThreadId, + ) + ) { + return deny({ principalId: principal.principal.id }); + } + const safelyKnown = { + conversationId: conversation.id, + principalId: principal.principal.id, + }; + const resource = conversation.resourceId + ? await db + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, record.endpoint.companyId), + eq(chatEndpointResources.endpointId, record.endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const resourceAllowed = conversation.isDirectMessage + ? record.endpoint.allowDirectMessages + : nonDirectDestinationAllowed(record.endpoint, resource); + if (!resourceAllowed) return deny(safelyKnown); + + let originalPublication = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, record.endpoint.companyId), + eq(chatPublications.endpointId, record.endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.id, originalPublicationId), + inArray(chatPublications.state, ["published", "delivery_unknown"]), + or( + isNull(chatPublications.providerMessageId), + eq(chatPublications.providerMessageId, sourceMessageId), + ), + ), + ) + .then((rows) => rows[0] ?? null); + if (!originalPublication) return deny(safelyKnown); + if ( + correction && + (originalPublication.state !== "published" || + originalPublication.providerMessageId !== correction.providerMessageId) + ) + return deny(safelyKnown); + if (!originalPublication.providerMessageId) { + const reconciledPublication = await db.transaction(async (tx) => { + const current = await tx + .select() + .from(chatPublications) + .where(eq(chatPublications.id, originalPublicationId)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !current || + current.companyId !== record.endpoint.companyId || + current.endpointId !== record.endpoint.id || + current.conversationId !== conversation.id || + current.issueId !== conversation.issueId || + !["published", "delivery_unknown"].includes(current.state) || + (current.providerMessageId !== null && + current.providerMessageId !== event.event.messageId) + ) { + return null; + } + const occupied = await tx + .select({ publicationId: chatMessageLinks.publicationId }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.endpointId, record.endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.providerMessageId, event.event.messageId), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .then((rows) => rows[0] ?? null); + if (occupied && occupied.publicationId !== current.id) return null; + const reconciledAt = new Date(); + await notifyChatPublicationWork(tx); + const [reconciled] = await tx + .update(chatPublications) + .set({ + state: "published", + providerMessageId: event.event.messageId, + publishedAt: current.publishedAt ?? reconciledAt, + nextAttemptAt: null, + redactedError: null, + updatedAt: reconciledAt, + }) + .where( + and( + eq(chatPublications.id, current.id), + inArray(chatPublications.state, [ + "published", + "delivery_unknown", + ]), + isNull(chatPublications.providerMessageId), + ), + ) + .returning(); + if (!reconciled) return null; + await tx + .insert(chatMessageLinks) + .values({ + companyId: current.companyId, + endpointId: current.endpointId, + conversationId: current.conversationId, + publicationId: current.id, + commentId: current.commentId, + providerMessageId: event.event.messageId, + direction: "outbound", + }) + .onConflictDoNothing(); + const linked = await tx + .select({ publicationId: chatMessageLinks.publicationId }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.endpointId, record.endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.providerMessageId, event.event.messageId), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .then((rows) => rows[0] ?? null); + return linked?.publicationId === reconciled.id ? reconciled : null; + }); + if (!reconciledPublication) return deny(safelyKnown); + originalPublication = reconciledPublication; + } + const messageBinding = await db + .select({ + link: chatMessageLinks, + linkedPublication: chatPublications, + }) + .from(chatMessageLinks) + .innerJoin( + chatPublications, + and( + eq(chatPublications.companyId, chatMessageLinks.companyId), + eq(chatPublications.endpointId, chatMessageLinks.endpointId), + eq(chatPublications.conversationId, chatMessageLinks.conversationId), + eq(chatPublications.id, chatMessageLinks.publicationId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, record.endpoint.companyId), + eq(chatMessageLinks.endpointId, record.endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.providerMessageId, sourceMessageId), + eq(chatMessageLinks.direction, "outbound"), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.state, "published"), + eq(chatPublications.providerMessageId, sourceMessageId), + ), + ) + .then((rows) => (rows.length === 1 ? rows[0]! : null)); + if (!messageBinding) return deny(safelyKnown); + const linkedPayload = messageBinding.linkedPublication + .payload as SafeChatPublicationPayload; + const linkStillAuthoritative = + messageBinding.link.publicationId === originalPublicationId || + (messageBinding.linkedPublication.idempotencyKey === + `interaction-resolution:${actionInteractionId}:${record.endpoint.id}` && + linkedPayload.interactionId === actionInteractionId); + if (!originalPublication || !linkStillAuthoritative) { + return deny(safelyKnown); + } + const issued = { + publication: originalPublication, + link: messageBinding.link, + conversation, + }; + + const payload = issued.publication.payload as SafeChatPublicationPayload; + if (event.provider === "telegram") { + const raw = event.event.raw; + const callbackData = + raw && typeof raw === "object" && "data" in raw + ? (raw as { data?: unknown }).data + : null; + if ( + typeof callbackData !== "string" || + Buffer.byteLength(callbackData, "utf8") > + TELEGRAM_CALLBACK_DATA_LIMIT_BYTES || + callbackData !== telegramChatSdkCallbackData(event.event.actionId) || + event.event.value !== undefined + ) { + return deny(safelyKnown); + } + } + if ( + !payload.interactionId || + (!correction && + event.provider !== "telegram" && + event.event.value !== payload.interactionId) || + !payload.card?.actions?.some( + (action) => + action.type === "callback" && action.actionId === sourceActionId, + ) + ) { + return deny(safelyKnown); + } + + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === payload.interactionId); + if ( + !interaction || + interaction.companyId !== record.endpoint.companyId || + interaction.issueId !== conversation.issueId + ) { + return deny(safelyKnown); + } + if (interaction.status !== "pending") { + if (correction) return deny(safelyKnown); + const completedAction = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.providerActionId, event.event.actionId), + eq(chatActions.principalId, principal.principal.id), + eq(chatActions.status, "processed"), + ), + ) + .then((rows) => rows[0] ?? null); + const completedPayload = completedAction?.payload; + const completedResult = completedAction?.result; + const completedResultMatches = + completedResult?.interactionId === interaction.id && + completedResult?.interactionStatus === interaction.status && + (interaction.kind !== "request_confirmation" || + completedResult.code === + "confirmation_resolution_committed_by_provider"); + if ( + completedAction && + completedResultMatches && + completedPayload?.publicationId === issued.publication.id && + completedPayload?.interactionId === interaction.id && + completedPayload?.messageId === event.event.messageId && + completedPayload?.actionId === event.event.actionId + ) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + return; + } + return deny(safelyKnown); + } + if (interaction.kind === "request_confirmation") { + return handleTelegramConfirmation({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, issued, interaction, deny, safelyKnown }); + } + if (interaction.kind !== "ask_user_questions") { + return deny(safelyKnown); + } + if (isChatQuestionFormOpenActionId(sourceActionId)) { + if ( + event.provider !== "slack" && + event.provider !== "microsoft-teams" && + !( + event.provider === "discord" && + event.transport === "discord_gateway" && + record.endpoint.capabilities.modals === true + ) + ) { + return deny(safelyKnown); + } + await options.questionFormOpenAuthorizationBarrier?.(); + let providerAttempted = false; + let attemptActionId: string | null = null; + try { + const attemptProviderActionId = event.event.triggerId + ? `question_form_open_attempt:${createHash("sha256") + .update( + JSON.stringify([ + record.endpoint.id, + event.provider, + event.event.triggerId, + ]), + ) + .digest("hex")}` + : null; + const preflight = await db.transaction(async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + const resolved = await resolveChatQuestionFormOpen(tx, { + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + conversationId: conversation.id, + interaction, + openActionId: sourceActionId, + }); + if (!resolved || resolved.publicationId !== issued.publication.id) { + return { kind: "denied" as const }; + } + if (!attemptProviderActionId) { + return { + kind: "ready" as const, + attemptActionId: null, + resolved, + }; + } + const [attempt] = await tx + .insert(chatActions) + .values({ + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + conversationId: conversation.id, + principalId: principal.principal.id, + kind: "question_form_open_attempt", + providerActionId: attemptProviderActionId, + payload: { version: 1 }, + status: "processing", + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + return attempt + ? { + kind: "ready" as const, + attemptActionId: attempt.id, + resolved, + } + : { kind: "duplicate" as const }; + }); + if (preflight.kind === "denied") return deny(safelyKnown); + if (preflight.kind === "duplicate") { + if (event.provider === "discord") { + // A prior attempt may have consumed Discord's single response. + // A fresh handler has no showModal closure state for that attempt; + // do not turn its processing/failed receipt into a success ACK. + throw Object.assign( + new Error("Discord modal response was already attempted"), + { code: "chat_discord_gateway_modal_response_indeterminate" }, + ); + } + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + return; + } + attemptActionId = preflight.attemptActionId; + + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + + // A provider modal open can take seconds and can outlive Slack's + // trigger. The final Paperclip authorization snapshot above commits + // before transport starts so provider latency never holds endpoint, + // destination, identity-link, or membership row locks. + const modal = correction + ? discordQuestionFormCorrectionModal( + preflight.resolved.modal, + correction, + ) + : preflight.resolved.modal; + if (!modal) return deny(safelyKnown); + providerAttempted = true; + const opened = await event.event.openModal(modal); + if (!opened) { + await recordModalOpenFailure( + record.endpoint, + event, + runtimeContext, + { ...safelyKnown, attemptActionId, resource }, + new Error("Provider did not confirm the modal open"), + ); + if (event.provider === "discord") return deny(safelyKnown); + return; + } + if (attemptActionId) { + const [settledAttempt] = await db + .update(chatActions) + .set({ + status: "processed", + result: { code: "question_form_opened" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, attemptActionId), + eq(chatActions.status, "processing"), + ), + ) + .returning({ id: chatActions.id }); + if (!settledAttempt) { + throw new Error( + "Question-form modal ownership changed after provider acceptance", + ); + } + } + } catch (error) { + if (isExternalActionAuthorizationChange(error)) { + return deny(safelyKnown); + } + if (!providerAttempted) throw error; + await recordModalOpenFailure( + record.endpoint, + event, + runtimeContext, + { ...safelyKnown, attemptActionId, resource }, + error, + ); + if (event.provider === "discord") return deny(safelyKnown); + // The provider attempt is now a durable, non-replayable Activity row. + // Acknowledge the callback so Chat SDK does not turn a definite denial + // or an ambiguous modal open into a duplicate retry loop. + return; + } + return; + } + + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.kind, "question_answer"), + eq(chatActions.providerActionId, event.event.actionId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!action || action.status !== "issued") return deny(safelyKnown); + const tokenPayload = action.payload; + const tokenExpiresAt = + typeof tokenPayload.expiresAt === "string" + ? Date.parse(tokenPayload.expiresAt) + : Number.NaN; + if (!Number.isFinite(tokenExpiresAt) || tokenExpiresAt <= Date.now()) { + await db + .update(chatActions) + .set({ + status: "expired", + result: { code: "question_action_token_expired" }, + updatedAt: new Date(), + }) + .where( + and(eq(chatActions.id, action.id), eq(chatActions.status, "issued")), + ); + return deny(safelyKnown); + } + if ( + tokenPayload.version !== 1 || + tokenPayload.publicationId !== issued.publication.id || + tokenPayload.interactionId !== payload.interactionId || + typeof tokenPayload.questionId !== "string" || + typeof tokenPayload.optionId !== "string" + ) { + return deny(safelyKnown); + } + + const question = nativeChatQuestion(interaction); + const option = question?.options.find( + (candidate) => candidate.id === tokenPayload.optionId, + ); + if (!question || question.id !== tokenPayload.questionId || !option) + return deny(safelyKnown); + + await options.questionResolutionPersistBarrier?.(); + + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + }) + .from(issues) + .where( + and( + eq(issues.companyId, record.endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!issue) return deny(safelyKnown); + try { + const answered = await issueThreadInteractionService(db).answerQuestions( + issue, + interaction.id, + { + answers: [{ questionId: question.id, optionIds: [option.id] }], + }, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + }, + afterResolveInTransaction: async (tx, resolved) => { + const [completed] = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + payload: { + ...tokenPayload, + messageId: event.event.messageId, + actionId: event.event.actionId, + value: event.event.value ?? null, + }, + result: { + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!completed) { + throw new Error( + "External question action ownership changed before commit", + ); + } + await tx + .update(chatActions) + .set({ + status: "expired", + result: { code: "interaction_resolved_by_sibling_action" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.companyId, issue.companyId), + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.kind, "question_answer"), + eq(chatActions.status, "issued"), + ne(chatActions.id, action.id), + eq( + sql`${chatActions.payload}->>'interactionId'`, + resolved.id, + ), + ), + ); + await logActivity(tx as unknown as Db, { + companyId: issue.companyId, + actorType: "user", + actorId: principal.userId!, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + endpointId: record.endpoint.id, + provider: record.endpoint.provider, + conversationId: conversation.id, + publicationId: issued.publication.id, + providerMessageId: event.event.messageId, + interactionId: resolved.id, + interactionKind: resolved.kind, + interactionStatus: resolved.status, + resolutionActorKind: "user", + answeredQuestionCount: 1, + }, + }); + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: issue.companyId, + endpointId: record.endpoint.id, + conversationId: conversation.id, + issueId: issue.id, + idempotencyKey: `interaction-resolution:${resolved.id}:${record.endpoint.id}`, + payload: projectSafeChatPublication({ + classification: "external", + source: "issue_interaction", + text: `Answered: ${option.label}.`, + interaction: { + id: resolved.id, + card: { + kind: "question", + title: question.prompt, + body: `Answered: ${option.label}.`, + actions: [], + }, + }, + }), + state: "pending", + }) + .onConflictDoNothing(); + }, + }, + ); + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + // The interaction, provider token, and response-delivery receipt are + // already durable. A provider callback must not wait for agent startup; + // drain the outbox after acknowledging the provider callback. + scheduleMessageProcessing(async () => { + await questionResponses.deliver(answered.id); + }); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) { + return deny(safelyKnown); + } + // `answerQuestions` commits the interaction and this action in one + // transaction, then performs a best-effort issue timestamp touch. If + // that post-commit touch fails, the provider callback is nevertheless + // durably complete. Do not corrupt the processed token to `failed` or + // return a retryable webhook response that turns Telegram's redelivery + // into a misleading stale-action notice. + try { + const raceResult = await db.transaction(async (tx) => { + const currentAction = await tx + .select({ status: chatActions.status }) + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + const currentInteraction = ( + await issueThreadInteractionService( + tx as unknown as Db, + ).listForIssue(issue.id) + ).find((candidate) => candidate.id === interaction.id); + if (currentInteraction && currentInteraction.status !== "pending") { + await tx + .update(chatActions) + .set({ + status: "expired", + result: { code: "interaction_resolved_elsewhere" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + inArray(chatActions.status, ["issued", "processing"]), + ), + ); + await enqueueTerminalIssueInteractionChatPublications( + tx, + currentInteraction, + ); + return { actionStatus: currentAction?.status, settledRace: true }; + } + return { actionStatus: currentAction?.status, settledRace: false }; + }); + if (raceResult.actionStatus === "processed") { + logger.warn( + { + endpointId: record.endpoint.id, + interactionId: interaction.id, + error: redactError(error), + }, + "external chat question resolved but post-commit follow-up failed", + ); + return; + } + if (raceResult.settledRace) { + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + return; + } + } catch (recoveryError) { + logger.warn( + { + endpointId: record.endpoint.id, + interactionId: interaction.id, + error: redactError(recoveryError), + }, + "could not reconcile an external chat question after resolution failed", + ); + } + throw error; + } + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + } + + async function handleModalSubmit( + event: ChatSdkCallbackEvent, + runtimeContext: RuntimeContext, + ): Promise { + const denialError = () => + forbidden("This chat form is not a current Paperclip question"); + const record = await runtimeCallbackRecord( + event.endpointId, + runtimeContext, + ["active"], + ); + if ( + !record || + record.endpoint.status !== "active" || + record.endpoint.provider !== event.provider || + record.endpoint.capabilities.modals !== true || + (event.provider !== "slack" && + event.provider !== "microsoft-teams" && + !( + event.provider === "discord" && event.transport === "discord_gateway" + )) + ) { + const staleRecord = + record ?? + (await endpointRecord(event.endpointId).then((candidate) => + candidate?.endpoint.provider === event.provider ? candidate : null, + )); + if (staleRecord) { + await recordModalSubmitDenial(staleRecord.endpoint, event, { + code: "chat_modal_runtime_not_current", + }); + } + return event.provider === "discord" + ? discordQuestionFormDenialResponse() + : chatQuestionFormDenialResponse(); + } + const deny = async ( + code: string, + safelyKnown: { + conversationId?: string | null; + principalId?: string | null; + } = {}, + payload?: Parameters[0], + ): Promise => { + await recordModalSubmitDenial(record.endpoint, event, { + code, + ...safelyKnown, + }); + return event.provider === "discord" + ? discordQuestionFormDenialResponse() + : chatQuestionFormDenialResponse(payload); + }; + const loaded = await loadChatQuestionFormSubmissionToken(db, { + callbackId: event.event.callbackId, + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + includeProcessed: true, + }); + if (!loaded) return deny("chat_modal_token_not_current"); + const conversation = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, record.endpoint.companyId), + eq(chatConversations.endpointId, record.endpoint.id), + eq(chatConversations.id, loaded.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + !conversation || + (event.provider === "discord" && + discordQuestionFormThreadId(event.event.raw) !== + conversation.externalThreadId) || + (event.event.relatedThread && + (event.provider === "microsoft-teams" + ? !sameTeamsThreadIdentity( + event.event.relatedThread.id, + conversation.externalThreadId, + ) + : event.event.relatedThread.id !== conversation.externalThreadId)) + ) { + return deny( + "chat_modal_conversation_not_current", + { + conversationId: conversation?.id ?? null, + }, + loaded.payload, + ); + } + const principal = await ensurePrincipal( + record.endpoint, + event.event.user, + event.event.raw, + ); + if ( + principal.linkedDenied || + !principal.userId || + principal.principal.kind !== "user" || + principal.principal.isBot + ) { + return deny( + "chat_modal_principal_not_authorized", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const resource = conversation.resourceId + ? await db + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, record.endpoint.companyId), + eq(chatEndpointResources.endpointId, record.endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const resourceAllowed = conversation.isDirectMessage + ? record.endpoint.allowDirectMessages + : nonDirectDestinationAllowed(record.endpoint, resource); + if (!resourceAllowed) { + return deny( + "chat_modal_destination_not_authorized", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + + const originalPublication = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, record.endpoint.companyId), + eq(chatPublications.endpointId, record.endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.id, loaded.publicationId), + eq(chatPublications.state, "published"), + event.provider !== "discord" && event.event.relatedMessage + ? eq( + chatPublications.providerMessageId, + event.event.relatedMessage.id, + ) + : undefined, + ), + ) + .then((rows) => rows[0] ?? null); + const providerMessageId = + (event.provider === "discord" + ? undefined + : event.event.relatedMessage?.id) ?? + originalPublication?.providerMessageId ?? + null; + if (!originalPublication || !providerMessageId) { + return deny( + "chat_modal_publication_not_current", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const currentMessageBinding = await db + .select({ + link: chatMessageLinks, + publication: chatPublications, + }) + .from(chatMessageLinks) + .innerJoin( + chatPublications, + and( + eq(chatPublications.companyId, chatMessageLinks.companyId), + eq(chatPublications.endpointId, chatMessageLinks.endpointId), + eq(chatPublications.conversationId, chatMessageLinks.conversationId), + eq(chatPublications.id, chatMessageLinks.publicationId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, record.endpoint.companyId), + eq(chatMessageLinks.endpointId, record.endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.providerMessageId, providerMessageId), + eq(chatMessageLinks.direction, "outbound"), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.state, "published"), + eq(chatPublications.providerMessageId, providerMessageId), + ), + ) + .then((rows) => (rows.length === 1 ? rows[0]! : null)); + const linkedPayload = currentMessageBinding?.publication.payload as + | SafeChatPublicationPayload + | undefined; + const linkStillAuthoritative = + currentMessageBinding?.link.publicationId === originalPublication.id || + (currentMessageBinding?.publication.idempotencyKey === + `interaction-resolution:${loaded.interactionId}:${record.endpoint.id}` && + linkedPayload?.interactionId === loaded.interactionId); + if (!currentMessageBinding || !linkStillAuthoritative) { + return deny( + "chat_modal_message_binding_not_current", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const issued = { + publication: originalPublication, + link: currentMessageBinding.link, + }; + const payload = issued.publication.payload as SafeChatPublicationPayload; + if (payload.interactionId !== loaded.interactionId) { + return deny( + "chat_modal_interaction_binding_not_current", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === loaded.interactionId); + const discordReplayAnswersMatch = ( + resolved: typeof interaction, + ): boolean => { + if (event.provider !== "discord") return true; + if ( + resolved?.kind !== "ask_user_questions" || + resolved.status !== "answered" + ) + return false; + // Parse only for comparison after current token/source/actor checks. + // This local pending projection cannot reopen or resolve an interaction. + const submitted = validateChatQuestionFormSubmission({ + callbackId: event.event.callbackId, + privateMetadata: event.event.privateMetadata, + interaction: { ...resolved, status: "pending" }, + payload: loaded.payload, + values: event.event.values, + }); + return ( + submitted.ok && + JSON.stringify(submitted.answers) === + JSON.stringify(resolved.result?.answers) + ); + }; + const isExactProcessedReplay = ( + current: Awaited>, + ) => + interaction?.companyId === record.endpoint.companyId && + interaction.issueId === conversation.issueId && + interaction.kind === "ask_user_questions" && + interaction.status === "answered" && + interaction.resolvedByUserId === principal.userId && + current?.status === "processed" && + current.actionRowId === loaded.actionRowId && + current.conversationId === conversation.id && + current.publicationId === originalPublication.id && + current.interactionId === loaded.interactionId && + current.principalId === principal.principal.id && + current.result?.code === "question_form_answered" && + current.result?.interactionId === loaded.interactionId && + discordReplayAnswersMatch(interaction); + const acceptedResponse = async (): Promise => { + if (event.provider === "discord") { + return acceptDiscordQuestionForm({ record, event, principal: { ...principal, userId: principal.userId! }, loaded }); + } + return { action: "clear" }; + }; + if (loaded.status === "processed") { + if (!isExactProcessedReplay(loaded)) { + return deny( + "chat_modal_replay_not_authorized", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + if (event.event.relatedThread) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.relatedThread.id, + event.event.raw, + ); + } + return acceptedResponse(); + } + if ( + !interaction || + interaction.companyId !== record.endpoint.companyId || + interaction.issueId !== conversation.issueId || + interaction.kind !== "ask_user_questions" || + interaction.status !== "pending" + ) { + // Another callback can commit the same token between the initial token + // read and this interaction read. Only its exact processed receipt may + // acknowledge the stale snapshot; an answer by another token or actor + // does not authorize this form. Recheck current policy under its locks. + let processedReplay = false; + if ( + interaction?.companyId === record.endpoint.companyId && + interaction.issueId === conversation.issueId && + interaction.kind === "ask_user_questions" && + interaction.status === "answered" + ) { + try { + processedReplay = await db.transaction(async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + return isExactProcessedReplay( + await loadChatQuestionFormSubmissionToken(tx, { + callbackId: event.event.callbackId, + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + includeProcessed: true, + }), + ); + }); + } catch (error) { + if (!isExternalActionAuthorizationChange(error)) throw error; + } + } + if (processedReplay) { + if (event.event.relatedThread) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.relatedThread.id, + event.event.raw, + ); + } + return acceptedResponse(); + } + return deny( + "chat_modal_interaction_not_pending", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const validation = validateChatQuestionFormSubmission({ + callbackId: event.event.callbackId, + privateMetadata: event.event.privateMetadata, + interaction, + payload: loaded.payload, + values: event.event.values, + }); + if (!validation.ok) { + if (validation.code === "invalid_form") { + if (event.provider === "discord") { + return handleDiscordFormCorrection({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, loaded, originalPublication, providerMessageId, interaction, payload, validation, deny }); + } + return chatQuestionFormValidationResponse({ + provider: event.provider, + callbackId: event.event.callbackId, + privateMetadata: event.event.privateMetadata, + interaction, + payload: loaded.payload, + values: event.event.values, + }); + } + return deny( + `chat_modal_${validation.code}`, + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + }) + .from(issues) + .where( + and( + eq(issues.companyId, record.endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!issue) { + return deny( + "chat_modal_issue_not_current", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + await options.questionResolutionPersistBarrier?.(); + let answered: Awaited< + ReturnType< + ReturnType["answerQuestions"] + > + >; + try { + answered = await issueThreadInteractionService(db).answerQuestions( + issue, + interaction.id, + { answers: validation.answers }, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: event.endpointId, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + }, + afterResolveInTransaction: async (tx, resolved) => { + const claimed = await claimChatQuestionFormSubmission(tx, { + actionRowId: loaded.actionRowId, + principalId: principal.principal.id, + }); + if (!claimed) throw denialError(); + const completed = await completeChatQuestionFormSubmission(tx, { + actionRowId: loaded.actionRowId, + interactionId: resolved.id, + }); + if (!completed) throw denialError(); + await tx + .update(chatActions) + .set({ + status: "expired", + result: { code: "question_form_submitted" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.companyId, issue.companyId), + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.kind, "question_form_open"), + eq(chatActions.status, "issued"), + eq( + sql`${chatActions.payload}->>'interactionId'`, + resolved.id, + ), + ), + ); + await logActivity(tx as unknown as Db, { + companyId: issue.companyId, + actorType: "user", + actorId: principal.userId!, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat_modal", + endpointId: record.endpoint.id, + provider: record.endpoint.provider, + conversationId: conversation.id, + publicationId: issued.publication.id, + interactionId: resolved.id, + interactionKind: resolved.kind, + interactionStatus: resolved.status, + resolutionActorKind: "user", + answeredQuestionCount: validation.answers.length, + }, + }); + }, + }, + ); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) { + await db.transaction(async (tx) => { + await tx + .update(chatActions) + .set({ + status: "expired", + result: { code: "chat_action_authorization_changed" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, loaded.actionRowId), + eq(chatActions.status, "issued"), + ), + ); + await recordModalSubmitDenial( + record.endpoint, + event, + { + code: "chat_action_authorization_changed", + conversationId: conversation.id, + principalId: principal.principal.id, + }, + tx, + ); + }); + return event.provider === "discord" + ? discordQuestionFormDenialResponse() + : chatQuestionFormDenialResponse(loaded.payload); + } + const settledRace = await db.transaction(async (tx) => { + const currentInteraction = ( + await issueThreadInteractionService(tx as unknown as Db).listForIssue( + issue.id, + ) + ).find((candidate) => candidate.id === interaction.id); + if (!currentInteraction || currentInteraction.status === "pending") { + return false; + } + await enqueueTerminalIssueInteractionChatPublications( + tx, + currentInteraction, + ); + return true; + }); + if (!settledRace) { + const status = + error && typeof error === "object" + ? (error as { status?: unknown }).status + : null; + if (status === 403) { + return deny( + "chat_modal_resolution_not_authorized", + { + conversationId: conversation.id, + principalId: principal.principal.id, + }, + loaded.payload, + ); + } + throw error; + } + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + if (event.provider === "discord") { + const current = await loadChatQuestionFormSubmissionToken(db, { + callbackId: event.event.callbackId, + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + includeProcessed: true, + }); + const resolved = ( + await issueThreadInteractionService(db).listForIssue( + conversation.issueId, + ) + ).find((candidate) => candidate.id === interaction.id); + if ( + current?.status !== "processed" || + current.actionRowId !== loaded.actionRowId || + current.principalId !== principal.principal.id || + current.result?.code !== "question_form_answered" || + current.result.interactionId !== interaction.id || + resolved?.status !== "answered" || + resolved.resolvedByUserId !== principal.userId || + !discordReplayAnswersMatch(resolved) + ) + return deny("chat_modal_resolution_committed_elsewhere"); + } + return acceptedResponse(); + } + if (event.event.relatedThread) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.relatedThread.id, + event.event.raw, + ); + } + scheduleMessageProcessing(async () => { + await questionResponses.deliver(answered.id); + }); + return acceptedResponse(); + } + return { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions }; +} diff --git a/server/src/services/chat-channels/interactions/continuations.ts b/server/src/services/chat-channels/interactions/continuations.ts new file mode 100644 index 00000000000..8b6697e91c3 --- /dev/null +++ b/server/src/services/chat-channels/interactions/continuations.ts @@ -0,0 +1,432 @@ +import { + and, + asc, + desc, + eq, + isNotNull, + isNull, + lte, + notInArray, + or, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + agentWakeupRequests, + chatActions, + chatEndpoints, + chatMessageLinks, + issues, +} from "@paperclipai/db"; + +import { isUniqueViolation } from "../../../db-errors.js"; + +import { logger } from "../../../middleware/logger.js"; + +import { + type IssueAssignmentWakeupDeps, +} from "../../issue-assignment-wakeup.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; + +type InteractionContinuationsOptions = { + heartbeat: IssueAssignmentWakeupDeps; + redactError: (error: unknown) => string; + CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS: number; +}; + +export function createChatInteractionContinuations(db: Db, options: InteractionContinuationsOptions) { + const { redactError, CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS } = options; + + + async function processPendingInteractionWakeups( + limit = 100, + ): Promise { + const staleBefore = new Date(Date.now() - 30_000); + const retryBefore = new Date( + Date.now() - CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, + ); + const candidates = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.kind, "interaction_wakeup"), + or( + and( + eq(chatActions.status, "issued"), + or( + isNull(chatActions.result), + lte(chatActions.updatedAt, retryBefore), + ), + ), + and( + eq(chatActions.status, "processing"), + lte(chatActions.updatedAt, staleBefore), + ), + ), + ), + ) + .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) + .limit(limit); + let processed = 0; + for (const candidate of candidates) { + const [claimed] = await db + .update(chatActions) + .set({ status: "processing", updatedAt: new Date() }) + .where( + and( + eq(chatActions.id, candidate.id), + or( + and( + eq(chatActions.status, "issued"), + or( + isNull(chatActions.result), + lte(chatActions.updatedAt, retryBefore), + ), + ), + and( + eq(chatActions.status, "processing"), + lte(chatActions.updatedAt, staleBefore), + ), + ), + ), + ) + .returning(); + if (!claimed) continue; + const payload = claimed.payload; + const requestedByActorType = + payload.requestedByActorType === "user" || + payload.requestedByActorType === "agent" || + payload.requestedByActorType === "system" + ? payload.requestedByActorType + : typeof payload.requestedByUserId === "string" + ? "user" + : null; + const requestedByActorId = + typeof payload.requestedByActorId === "string" + ? payload.requestedByActorId + : typeof payload.requestedByUserId === "string" + ? payload.requestedByUserId + : null; + if ( + payload.version !== 1 || + typeof payload.interactionId !== "string" || + typeof payload.interactionKind !== "string" || + !["accepted", "rejected", "answered", "cancelled", "failed"].includes( + String(payload.interactionStatus), + ) || + typeof payload.issueId !== "string" || + typeof payload.agentId !== "string" || + !requestedByActorType || + !requestedByActorId + ) { + await db + .update(chatActions) + .set({ + status: "failed", + result: { code: "interaction_wakeup_payload_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, claimed.id), + eq(chatActions.status, "processing"), + ), + ); + continue; + } + const currentIssue = await db + .select({ + assigneeAgentId: issues.assigneeAgentId, + status: issues.status, + }) + .from(issues) + .where( + and( + eq(issues.companyId, claimed.companyId), + eq(issues.id, payload.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + const currentInteraction = currentIssue + ? ( + await issueThreadInteractionService(db).listForIssue( + payload.issueId, + ) + ).find((interaction) => interaction.id === payload.interactionId) + : null; + if ( + !currentIssue || + !currentInteraction || + currentInteraction.companyId !== claimed.companyId || + currentInteraction.kind !== payload.interactionKind || + currentInteraction.status !== payload.interactionStatus || + currentIssue.assigneeAgentId !== payload.agentId || + currentIssue.status === "done" || + currentIssue.status === "cancelled" + ) { + await db + .update(chatActions) + .set({ + status: "processed", + result: { code: "interaction_wakeup_no_longer_applicable" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, claimed.id), + eq(chatActions.status, "processing"), + ), + ); + processed += 1; + continue; + } + const idempotencyKey = `interaction:${payload.interactionId}:${payload.interactionStatus}`; + try { + const sourceCommentId = currentInteraction.sourceCommentId ?? null; + const sourceRunId = currentInteraction.sourceRunId ?? null; + // A resolved interaction is still part of the originating external + // turn. Preserve one verified inbound comment edge on the continuation + // run so queued/working/failure milestones return to this exact + // endpoint and conversation instead of disappearing as an internal + // automation run. + const chatOrigin = claimed.conversationId + ? await db + .select({ + commentId: chatMessageLinks.commentId, + provider: chatEndpoints.provider, + }) + .from(chatEndpoints) + .innerJoin( + chatMessageLinks, + and( + eq(chatMessageLinks.companyId, chatEndpoints.companyId), + eq(chatMessageLinks.endpointId, chatEndpoints.id), + ), + ) + .where( + and( + eq(chatEndpoints.companyId, claimed.companyId), + eq(chatEndpoints.id, claimed.endpointId), + eq(chatMessageLinks.conversationId, claimed.conversationId), + eq(chatMessageLinks.direction, "inbound"), + isNotNull(chatMessageLinks.commentId), + ), + ) + .orderBy( + desc(chatMessageLinks.createdAt), + desc(chatMessageLinks.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + const wakeCommentId = sourceCommentId ?? chatOrigin?.commentId ?? null; + const existing = await db + .select({ + agentId: agentWakeupRequests.agentId, + id: agentWakeupRequests.id, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, claimed.companyId), + eq(agentWakeupRequests.idempotencyKey, idempotencyKey), + notInArray(agentWakeupRequests.status, [ + "skipped", + "failed", + "cancelled", + ]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + const queuedRun = !existing + ? await options.heartbeat.wakeup(payload.agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: payload.issueId, + interactionId: payload.interactionId, + interactionKind: payload.interactionKind, + interactionStatus: payload.interactionStatus, + sourceCommentId, + sourceRunId, + ...(wakeCommentId + ? { + wakeCommentId, + wakeCommentIds: [wakeCommentId], + } + : {}), + ...(payload.planReviewInteraction && + typeof payload.planReviewInteraction === "object" + ? { planReviewInteraction: payload.planReviewInteraction } + : {}), + mutation: "interaction", + }, + idempotencyKey, + allowRunCoalescing: false, + requestedByActorType, + requestedByActorId, + contextSnapshot: { + issueId: payload.issueId, + taskId: payload.issueId, + interactionId: payload.interactionId, + interactionKind: payload.interactionKind, + interactionStatus: payload.interactionStatus, + sourceCommentId, + sourceRunId, + ...(payload.planReviewInteraction && + typeof payload.planReviewInteraction === "object" + ? { planReviewInteraction: payload.planReviewInteraction } + : {}), + wakeReason: "issue_commented", + source: chatOrigin + ? `chat:${chatOrigin.provider}` + : "external_chat.interaction.resolve", + ...(wakeCommentId + ? { + wakeCommentId, + wakeCommentIds: [wakeCommentId], + } + : {}), + ...(payload.forceFreshSession === true + ? { forceFreshSession: true } + : {}), + ...(typeof payload.workspaceRefreshReason === "string" + ? { + workspaceRefreshReason: payload.workspaceRefreshReason, + } + : {}), + }, + }) + : null; + const durable = + existing ?? + (await db + .select({ + agentId: agentWakeupRequests.agentId, + id: agentWakeupRequests.id, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, claimed.companyId), + eq(agentWakeupRequests.idempotencyKey, idempotencyKey), + notInArray(agentWakeupRequests.status, [ + "skipped", + "failed", + "cancelled", + ]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null)); + if (!queuedRun && !durable) { + await db + .update(chatActions) + .set({ + status: "issued", + result: { code: "interaction_wakeup_deferred" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, claimed.id), + eq(chatActions.status, "processing"), + ), + ); + continue; + } + await db + .update(chatActions) + .set({ + status: "processed", + result: { + code: durable + ? durable.agentId === payload.agentId + ? "interaction_wakeup_already_durable" + : "interaction_wakeup_coalesced_after_reassignment" + : "interaction_wakeup_queued", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, claimed.id), + eq(chatActions.status, "processing"), + ), + ); + processed += 1; + } catch (error) { + const durable = isUniqueViolation(error) + ? await db + .select({ + agentId: agentWakeupRequests.agentId, + id: agentWakeupRequests.id, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, claimed.companyId), + eq(agentWakeupRequests.idempotencyKey, idempotencyKey), + notInArray(agentWakeupRequests.status, [ + "skipped", + "failed", + "cancelled", + ]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + const previousResult = + claimed.result && typeof claimed.result === "object" + ? (claimed.result as Record) + : {}; + const previousAttempts = + typeof previousResult.attemptCount === "number" && + Number.isInteger(previousResult.attemptCount) && + previousResult.attemptCount >= 0 + ? previousResult.attemptCount + : 0; + const attemptCount = previousAttempts + 1; + await db + .update(chatActions) + .set({ + status: durable ? "processed" : "issued", + result: { + code: durable + ? durable.agentId === payload.agentId + ? "interaction_wakeup_already_durable" + : "interaction_wakeup_coalesced_after_reassignment" + : "interaction_wakeup_failed", + ...(!durable ? { attemptCount } : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, claimed.id), + eq(chatActions.status, "processing"), + ), + ); + if (!durable) { + logger.warn( + { + endpointId: claimed.endpointId, + interactionId: payload.interactionId, + attemptCount, + error: redactError(error), + }, + "failed to queue external interaction continuation wake", + ); + } + } + } + return processed; + } + return { processPendingInteractionWakeups }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/destination-admission.ts b/server/src/services/chat-channels/microsoft-teams/destination-admission.ts new file mode 100644 index 00000000000..917aec4b9dc --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/destination-admission.ts @@ -0,0 +1,68 @@ +import type { Message, Thread } from "chat"; +import { and, eq, sql } from "drizzle-orm"; +import { chatEndpointResources } from "@paperclipai/db"; +import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js"; +import { nonDirectDestinationAllowed, teamsThreadRootMessageId } from "../inbound/surfaces.js"; + +type DestinationAdmissionInput = { + endpoint: EndpointRow; + currentEndpoint: Pick; + thread: Thread; + message: Message; + addressed: boolean; +}; +type DestinationAdmissionOptions = { + ensureResource: (endpoint: EndpointRow, thread: Thread, enableForSetup: boolean, database?: DbOrTransaction) => Promise; +}; +export function createTeamsDestinationAdmission(options: DestinationAdmissionOptions) { + const { ensureResource } = options; + async function admitTeamsDestination(input: DestinationAdmissionInput, tx: DbTransaction) { + const { endpoint, currentEndpoint, thread, message, addressed } = input; + let destinationAccepting = true; + let provisionalTeamsSetupReply = false; + + let resource = await ensureResource(endpoint, thread, false, tx); + const enabledChannelCount = await tx + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + eq(chatEndpointResources.type, "channel"), + ), + ) + .then((rows) => rows[0]?.count ?? 0); + const canActivateFirstSetupChannel = + currentEndpoint.status === "verifying" && + resource.type === "channel" && + resource.availability === "available" && + enabledChannelCount === 0 && + addressed; + if (canActivateFirstSetupChannel && !resource.enabled) { + resource = await tx + .update(chatEndpointResources) + .set({ enabled: true, updatedAt: new Date() }) + .where(eq(chatEndpointResources.id, resource.id)) + .returning() + .then((rows) => rows[0] ?? resource); + } + const teamsRootMessageId = teamsThreadRootMessageId(thread.id); + provisionalTeamsSetupReply = + currentEndpoint.status === "verifying" && + resource.type === "channel" && + resource.availability === "available" && + enabledChannelCount === 0 && + !addressed && + Boolean(teamsRootMessageId) && + teamsRootMessageId !== message.id; + destinationAccepting = + nonDirectDestinationAllowed( + { ...endpoint, allowGroupChats: currentEndpoint.allowGroupChats }, + resource, + ) || provisionalTeamsSetupReply; + return { destinationAccepting, provisionalTeamsSetupReply }; + } + return { admitTeamsDestination }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/routes.ts b/server/src/services/chat-channels/microsoft-teams/routes.ts new file mode 100644 index 00000000000..541a40a75b6 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/routes.ts @@ -0,0 +1,55 @@ +import type { EndpointRow, DbTransaction, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; + +import type { Db } from "@paperclipai/db"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +type TeamsRoutesOptions = { + runtime: Pick; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; +}; + + + +function microsoftTeamsServiceUrl(raw: unknown): string | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const value = (raw as { serviceUrl?: unknown }).serviceUrl; + return typeof value === "string" && value ? value : null; +} +export function createTeamsRoutes(db: Db, options: TeamsRoutesOptions) { + const { runtime, runtimeCallbackEndpoint } = options; + + + async function recordCurrentMicrosoftTeamsRoute( + endpoint: EndpointRow, + context: InboundRuntimeContext | undefined, + threadId: string, + raw: unknown, + recordAcceptedActivity = true, + ): Promise { + if (endpoint.provider !== "microsoft-teams" || !context?.endpointRuntime) { + return; + } + const serviceUrl = microsoftTeamsServiceUrl(raw); + if (!serviceUrl) return; + await db.transaction(async (tx) => { + const current = await runtimeCallbackEndpoint(tx, endpoint.id, context, [ + "verifying", + "active", + ]); + if (!current || runtime.get(endpoint.id) !== context.endpointRuntime) { + return; + } + await context.endpointRuntime.recordMicrosoftTeamsRoute( + threadId, + serviceUrl, + recordAcceptedActivity ? raw : undefined, + ); + }); + } + return { recordCurrentMicrosoftTeamsRoute }; +} diff --git a/server/src/services/chat-channels/provider-catalog.ts b/server/src/services/chat-channels/provider-catalog.ts new file mode 100644 index 00000000000..5beb7879400 --- /dev/null +++ b/server/src/services/chat-channels/provider-catalog.ts @@ -0,0 +1,108 @@ +import type { + ChatAdapterCapabilities, + ChatProvider, +} from "@paperclipai/shared"; + +const PROVIDER_LABELS: Record = { + speko: "Speko", + "imessage-photon": "iMessage Photon", + agentmail: "AgentMail", + slack: "Slack", + github: "GitHub", + discord: "Discord", + "microsoft-teams": "Microsoft Teams", + telegram: "Telegram", +}; + +const CAPABILITIES: Record = { + speko: { threads: false, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: false, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, + "imessage-photon": { threads: false, directMessages: true, nativeStreaming: false, messageEdits: true, messageDeletes: false, reactions: false, files: true, cards: false, actions: true, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, + agentmail: { threads: true, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: true, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: true }, + slack: { + threads: true, + directMessages: true, + nativeStreaming: true, + messageEdits: true, + messageDeletes: true, + reactions: true, + files: true, + cards: true, + actions: true, + modals: true, + slashCommands: true, + ephemeralMessages: true, + proactiveDirectMessages: false, + }, + github: { + threads: true, + directMessages: false, + nativeStreaming: false, + messageEdits: true, + messageDeletes: true, + reactions: true, + files: false, + cards: true, + actions: false, + modals: false, + slashCommands: false, + ephemeralMessages: false, + proactiveDirectMessages: false, + }, + discord: { + threads: true, + directMessages: true, + nativeStreaming: false, + messageEdits: true, + messageDeletes: true, + reactions: true, + files: true, + cards: true, + actions: true, + modals: true, + // Automatic registration upgrades these only after a durable provider + // receipt. A draft or an uncertain/conflicting registration stays false. + slashCommands: false, + ephemeralMessages: false, + proactiveDirectMessages: false, + }, + "microsoft-teams": { + threads: true, + directMessages: true, + // Production webhook processing is deferred into Paperclip's durable + // ingress queue. The Teams adapter's request-scoped DM streamer is gone by + // the time agent output is published, so advertise the durable behavior we + // can actually provide. editMessage still lets one run coalesce its + // queued, working, and final states in place. + nativeStreaming: false, + // Paperclip supplements the pinned adapter's missing inbound Bot Framework + // messageUpdate/messageDelete dispatch after provider authentication. + messageEdits: true, + messageDeletes: true, + reactions: true, + files: true, + cards: true, + actions: true, + modals: true, + slashCommands: false, + ephemeralMessages: true, + proactiveDirectMessages: false, + }, + telegram: { + threads: true, + directMessages: true, + nativeStreaming: true, + messageEdits: true, + // Telegram's Bot API does not emit an update when a user deletes a + // message, so this capability cannot be offered truthfully. + messageDeletes: false, + reactions: true, + files: true, + cards: true, + actions: true, + modals: false, + slashCommands: false, + ephemeralMessages: false, + proactiveDirectMessages: false, + }, +}; +export { PROVIDER_LABELS, CAPABILITIES }; diff --git a/server/src/services/chat-channels/publications/authorization.ts b/server/src/services/chat-channels/publications/authorization.ts new file mode 100644 index 00000000000..9b3a82d58b5 --- /dev/null +++ b/server/src/services/chat-channels/publications/authorization.ts @@ -0,0 +1,344 @@ +import { createGitHubPublicationPolicy } from "../github/publication-policy.js"; + +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../runtime-types.js"; +import { createChatFailedRunRetry } from "../failed-run-retry.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { authorizeSlackBoardPublication } from "../../slack-board-authority.js"; + +import { slackPublicationAllowed } from "../../connectors/slack-access.js"; + +import { + and, + eq, + gt, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatPublications, + heartbeatRuns, + issueComments, +} from "@paperclipai/db"; + +import { + authorizeNativeChatReviewPresentation, + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; +import { isExternalChatWaitAuthorizationContention } from "../../native-runtime/chat-attachment-reuse.js"; + +import { isExplicitOperatorPublication } from "./policy.js"; +import { attachmentFailureNoticeFence } from "./policy.js"; +import type { createChatPublicationTaskControl } from "./task-control.js"; +type ConversationRow = typeof chatConversations.$inferSelect; +type PublicationAuthorizationOptions = { + authorizeInboundWakePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise; + authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise; + fetchImpl: typeof globalThis.fetch; + githubCurrentOmissionPreparedText: ReturnType["githubCurrentOmissionPreparedText"]; + hasCommittedTaskControlCompletion: ReturnType["hasCommittedTaskControlCompletion"]; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; userId?: string | null }>; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + resolveCredentials: (endpoint: EndpointRow, tx?: DbOrTransaction) => Promise>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + FailedChatRetryPublicationReadError: ReturnType["FailedChatRetryPublicationReadError"]; +}; +export function createChatPublicationAuthorization(db: Db, options: PublicationAuthorizationOptions) { + const { authorizeInboundWakePublication, authorizeRetainedChatSourcePublication, fetchImpl, githubCurrentOmissionPreparedText, hasCommittedTaskControlCompletion, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, resolveCredentials, runtimeCallbackEndpoint, FailedChatRetryPublicationReadError } = options; + + + async function claimPublicationTransportAuthorization(input: { + credentialLease: CredentialMutationLeaseGuard; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + runtimeContext: LifecycleRuntimeFence; + }): Promise<{ + authorizationActionId: string | null; + conversation: ConversationRow; + endpoint: EndpointRow; + preparedText: string | null; + } | null> { + return db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const currentPublication = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, input.publication.attempts + 1), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!currentPublication) return null; + try { + if ( + !(await authorizeRetainedChatSourcePublication(tx, input.publication)) + ) + return null; + } catch (error) { + if (error instanceof NativeChatReviewPresentationContentionError) + throw error; + throw new FailedChatRetryPublicationReadError(); + } + const attachmentFailureFence = attachmentFailureNoticeFence( + input.publication.idempotencyKey, + ); + if ( + attachmentFailureFence && + (attachmentFailureFence.generation !== + input.runtimeContext.generation || + attachmentFailureFence.credentialFingerprint !== + input.runtimeContext.credentialFingerprint) + ) { + return null; + } + const endpoint = await runtimeCallbackEndpoint( + tx, + input.publication.endpointId, + input.runtimeContext, + ["verifying", "active"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, input.publication.conversationId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId && !conversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const attachmentFailureNoticeSuperseded = + attachmentFailureFence && conversation + ? await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, conversation.companyId), + eq(chatConversations.endpointId, conversation.endpointId), + eq( + chatConversations.externalThreadId, + conversation.externalThreadId, + ), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1) + .then((rows) => rows.length > 0) + : false; + const isTaskControl = + input.publication.idempotencyKey.startsWith("control:"); + const providerVisibleCompletion = + conversation?.state === "completed" && + !isExplicitOperatorPublication(input.publication) && + (await hasCommittedTaskControlCompletion(conversation.id, tx)); + const authorizationAction = + endpoint && isTaskControl + ? await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.conversationId, conversation?.id ?? ""), + eq(chatActions.kind, "task_control_authorization"), + eq( + chatActions.providerActionId, + `task-control-authorization:${input.publication.id}`, + ), + eq(chatActions.status, "issued"), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const destinationAllowed = + endpoint !== null && + conversation !== null && + (endpoint.provider !== "slack" || await slackPublicationAllowed(tx, endpoint.companyId, endpoint.id, input.publication.issueId, conversation.externalConversationId, null)) && + (conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)); + if ( + !endpoint || + !conversation || + (attachmentFailureFence && + ((conversation.state !== "active" && + conversation.state !== "waiting") || + attachmentFailureNoticeSuperseded)) || + providerVisibleCompletion || + (isTaskControl && !authorizationAction?.principalId) || + !destinationAllowed + ) { + return null; + } + let authorizationActionId: string | null = null; + if (endpoint.provider === "slack") { + const credentials = await resolveCredentials(endpoint, tx); + if (!await authorizeSlackBoardPublication(tx, endpoint, conversation, input.publication, credentials.botToken ?? "", fetchImpl)) return null; + } + if ( + input.publication.idempotencyKey.startsWith("wake:") && + !(await authorizeInboundWakePublication(tx, input.publication)) + ) + return null; + // A native response alongside an older completion review has its own + // narrow authorization. Recheck that exact proof, review and original + // requester at the transport claim, including file publications whose + // comment is the run's earlier internal attachment-selection comment. + if ( + input.publication.commentId && + !isExplicitOperatorPublication(input.publication) + ) { + const [origin] = await tx + .select({ + runId: heartbeatRuns.id, + resultJson: heartbeatRuns.resultJson, + }) + .from(issueComments) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, issueComments.createdByRunId), + eq(heartbeatRuns.companyId, issueComments.companyId), + ), + ) + .where( + and( + eq(issueComments.id, input.publication.commentId), + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, input.publication.issueId), + ), + ) + .limit(1); + if ( + origin?.resultJson?.finalizationReasonCode === + "governed_response_waiting" + ) { + try { + if ( + !(await authorizeNativeChatReviewPresentation( + tx as unknown as Db, + { + companyId: endpoint.companyId, + issueId: input.publication.issueId, + runId: origin.runId, + resultJson: origin.resultJson, + destination: { + endpointId: endpoint.id, + conversationId: conversation.id, + }, + }, + "nonblocking", + )) + ) + return null; + } catch (error) { + if (isExternalChatWaitAuthorizationContention(error)) + throw new NativeChatReviewPresentationContentionError(); + throw error; + } + } + } + const preparedText = await githubCurrentOmissionPreparedText( + tx, + input.publication, + endpoint, + ); + if (preparedText === false) return null; + if (authorizationAction?.principalId) { + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + authorizationAction.principalId, + ); + if (!authorization.allowed) return null; + const [claimedAuthorization] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: input.publication.attempts + 1, + authorizedUserId: authorization.userId, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + runtimeGeneration: input.runtimeContext.generation, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, authorizationAction.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimedAuthorization) return null; + authorizationActionId = claimedAuthorization.id; + } + if (preparedText !== null) { + // Persist the exact provider text before I/O. A retry must reuse this + // body, not accumulate links or regenerate it from changed settings. + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + payload: { ...input.publication.payload, text: preparedText }, + }) + .where(eq(chatPublications.id, input.publication.id)); + } + await input.credentialLease.assertOwned(tx); + // The publication's streaming row plus the optional authorization action + // are the durable transport claim. Their database locks are released + // before provider I/O. The renewable credential lease intentionally + // remains held so reconnect and reach mutations linearize after this + // committed authorization snapshot and transport attempt. + return { + authorizationActionId, + conversation, + endpoint, + preparedText, + }; + }); + } + return { claimPublicationTransportAuthorization }; +} diff --git a/server/src/services/chat-channels/publications/failures.ts b/server/src/services/chat-channels/publications/failures.ts new file mode 100644 index 00000000000..5c6bb302827 --- /dev/null +++ b/server/src/services/chat-channels/publications/failures.ts @@ -0,0 +1,328 @@ +import { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError } from "../outbound-attachments.js"; + +import type { EndpointRow, DbTransaction, LifecycleRuntimeFence } from "../runtime-types.js"; +import { createChatFailedRunRetry } from "../failed-run-retry.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + inArray, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatPublications, + toolConnections, +} from "@paperclipai/db"; + +import { logger } from "../../../middleware/logger.js"; +import { redactSensitiveText } from "../../../redaction.js"; + +import { + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; + +import { projectSafeChatPublication } from "../../chat-publication-projection.js"; +import { safeChatTaskUrl } from "../../chat-task-url.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import { ATTACHMENT_FAILURE_NOTICE_PREFIX, definiteAttachmentProviderRejection, attachmentFailureKind } from "./policy.js"; +type PublicationFailuresOptions = { + FailedChatRetryPublicationReadError: ReturnType["FailedChatRetryPublicationReadError"]; + MAX_ERROR_TEXT: number; + getTaskBaseUrl: () => string | null | undefined; + invalidateRuntime: (endpointId: string) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; +}; +export function createChatPublicationFailures(db: Db, options: PublicationFailuresOptions) { + const { FailedChatRetryPublicationReadError, MAX_ERROR_TEXT, getTaskBaseUrl, invalidateRuntime, runtimeCallbackEndpoint } = options; + + + async function finalizePublicationFailure( + publication: typeof chatPublications.$inferSelect, + error: unknown, + providerAccepted = false, + runtimeContext?: LifecycleRuntimeFence | null, + credentialLease?: CredentialMutationLeaseGuard, + ): Promise { + const attempts = publication.attempts + 1; + const disposition = providerAccepted + ? { + kind: "delivery_unknown" as const, + reason: + "Provider accepted the publication, but Paperclip could not confirm its durable result", + } + : error instanceof NativeChatReviewPresentationContentionError + ? { kind: "retry" as const, retryAfterMs: 250, reason: error.message } + : error instanceof FailedChatRetryPublicationReadError + ? { + kind: "retry" as const, + retryAfterMs: 1_000, + reason: error.message, + } + : isOutboundAttachmentValidationError(error) + ? { + kind: "failed" as const, + reason: error.message, + } + : isOutboundAttachmentHydrationError(error) + ? { + kind: "retry" as const, + retryAfterMs: Math.min( + 60_000, + 2 ** Math.max(0, attempts) * 1_000, + ), + reason: error.message, + } + : classifyChatPublicationError(error, attempts); + const failure = redactSensitiveText(disposition.reason).slice( + 0, + MAX_ERROR_TEXT, + ); + // Provider-directed rate limits are not delivery failures. Keep the + // durable publication retryable for the provider's full interval instead + // of exhausting the five-attempt transport budget while flood control is + // still active. + const terminalRetry = + disposition.kind === "retry" && + disposition.providerRateLimit !== true && + attempts >= 5; + const attachmentFailure = attachmentFailureKind(publication.payload); + const attachmentFailureRuntimeContext = + !providerAccepted && + disposition.kind === "failed" && + runtimeContext && + attachmentFailure && + definiteAttachmentProviderRejection(error) + ? runtimeContext + : null; + let invalidateCurrentRuntime = false; + const finalized = await db.transaction(async (tx) => { + await credentialLease?.assertOwned(tx); + const state = + disposition.kind === "retry" && !terminalRetry + ? "retry" + : disposition.kind === "delivery_unknown" + ? "delivery_unknown" + : disposition.kind === "resource_unavailable" + ? "cancelled" + : "failed"; + await notifyChatPublicationWork(tx); + const [ownedPublication] = await tx + .update(chatPublications) + .set({ + state, + nextAttemptAt: + disposition.kind === "retry" && !terminalRetry + ? new Date(Date.now() + disposition.retryAfterMs) + : null, + redactedError: failure, + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, attempts), + ), + ) + .returning({ id: chatPublications.id }); + if (!ownedPublication) return false; + + if (publication.idempotencyKey.startsWith("control:")) { + await tx + .update(chatActions) + .set({ + status: + state === "delivery_unknown" + ? "delivery_unknown" + : state === "cancelled" + ? "cancelled" + : "issued", + result: { + code: + state === "delivery_unknown" + ? "task_control_delivery_unknown" + : state === "cancelled" + ? "task_control_destination_unavailable" + : "task_control_transport_failed", + attempts, + retryable: state === "retry", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempts}`, + ), + ); + } + + const currentEndpoint = + runtimeContext && + (disposition.kind === "endpoint_attention" || + disposition.kind === "resource_unavailable") + ? await runtimeCallbackEndpoint( + tx, + publication.endpointId, + runtimeContext, + ["verifying", "active"], + ) + : null; + + if (disposition.kind === "endpoint_attention" && currentEndpoint) { + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: "Provider credentials or permissions need attention", + lastError: failure, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: "Provider credentials or permissions need attention", + lastError: failure, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, currentEndpoint.connectionId)); + invalidateCurrentRuntime = true; + } else if ( + disposition.kind === "resource_unavailable" && + currentEndpoint + ) { + const binding = await tx + .select({ resourceId: chatConversations.resourceId }) + .from(chatConversations) + .where(eq(chatConversations.id, publication.conversationId)) + .then((result) => result[0] ?? null); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: new Date() }) + .where( + and( + eq(chatConversations.id, publication.conversationId), + eq(chatConversations.endpointId, currentEndpoint.id), + ), + ); + if (binding?.resourceId) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: new Date() }) + .where( + and( + eq(chatEndpointResources.id, binding.resourceId), + eq(chatEndpointResources.endpointId, currentEndpoint.id), + ), + ); + } + } + + if (attachmentFailureRuntimeContext && attachmentFailure) { + const noticeEndpoint = await runtimeCallbackEndpoint( + tx, + publication.endpointId, + attachmentFailureRuntimeContext, + ["verifying", "active"], + ); + const noticeConversation = noticeEndpoint + ? await tx + .select({ + id: chatConversations.id, + state: chatConversations.state, + }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId), + eq(chatConversations.id, publication.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + const providerCanSendTextNotice = + noticeEndpoint && + ["slack", "discord", "telegram"].includes(noticeEndpoint.provider) && + (attachmentFailure.provider === null || + noticeEndpoint.provider === attachmentFailure.provider); + if (providerCanSendTextNotice && noticeConversation) { + const taskUrl = safeChatTaskUrl( + getTaskBaseUrl(), + publication.issueId, + ); + const noticeText = + attachmentFailure.kind === "generated_response" + ? "Paperclip could not send the response attachment. The complete response remains on its Paperclip task for an operator to retry." + : "Paperclip could not send an attachment. The file remains on its Paperclip task for an operator to retry."; + const idempotencyKey = `${ATTACHMENT_FAILURE_NOTICE_PREFIX}${publication.id}:${attachmentFailureRuntimeContext.generation}:${attachmentFailureRuntimeContext.credentialFingerprint}`; + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: publication.companyId, + endpointId: publication.endpointId, + conversationId: publication.conversationId, + issueId: publication.issueId, + commentId: null, + idempotencyKey, + payload: projectSafeChatPublication({ + classification: "external", + source: "safe_milestone", + text: taskUrl + ? `${noticeText} Open task: ${taskUrl}` + : noticeText, + }), + state: "pending", + }) + .onConflictDoNothing(); + } + } + + await credentialLease?.assertOwned(tx); + return true; + }); + if (finalized && invalidateCurrentRuntime) { + await credentialLease?.assertOwned(); + await invalidateRuntime(publication.endpointId).catch(() => undefined); + } + if (finalized) { + logger.warn( + { + endpointId: publication.endpointId, + publicationId: publication.id, + error: failure, + disposition: disposition.kind, + }, + "chat publication failed", + ); + } + return finalized; + } + return { finalizePublicationFailure }; +} diff --git a/server/src/services/chat-channels/publications/order.ts b/server/src/services/chat-channels/publications/order.ts new file mode 100644 index 00000000000..180dc43d449 --- /dev/null +++ b/server/src/services/chat-channels/publications/order.ts @@ -0,0 +1,36 @@ +import { + and, + eq, + lt, + or, + sql, +} from "drizzle-orm"; +import { type AnyPgColumn } from "drizzle-orm/pg-core"; + +type PublicationOrderTable = { + createdAt: AnyPgColumn; + id: AnyPgColumn; + payload: AnyPgColumn; +}; + +function publicationTransportOrderKey(table: PublicationOrderTable) { + return sql`coalesce(${table.payload}->'transportPart'->>'orderKey', ${table.id}::text)`; +} + +function publicationPrecedes( + earlier: PublicationOrderTable, + later: PublicationOrderTable, +) { + return or( + lt(earlier.createdAt, later.createdAt), + and( + eq(earlier.createdAt, later.createdAt), + lt( + publicationTransportOrderKey(earlier), + publicationTransportOrderKey(later), + ), + ), + ); +} + +export { publicationTransportOrderKey, publicationPrecedes }; diff --git a/server/src/services/chat-channels/publications/policy.ts b/server/src/services/chat-channels/publications/policy.ts new file mode 100644 index 00000000000..0abf10db4cd --- /dev/null +++ b/server/src/services/chat-channels/publications/policy.ts @@ -0,0 +1,132 @@ +import type { LifecycleRuntimeFence } from "../runtime-types.js"; + +import { + chatPublications, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + + function isExplicitOperatorPublication( + publication: Pick, + ): boolean { + return /^(?:explicit:|explicit-board:)/.test(publication.idempotencyKey); + } + +const ATTACHMENT_FAILURE_NOTICE_PREFIX = "attachment-failure-notice:"; + +function attachmentFailureNoticeFence( + idempotencyKey: string, +): LifecycleRuntimeFence | null { + const match = + /^attachment-failure-notice:[0-9a-f-]{36}:(\d+):([0-9a-f]{64})$/i.exec( + idempotencyKey, + ); + if (!match) return null; + const generation = Number(match[1]); + if (!Number.isSafeInteger(generation) || generation < 0) return null; + return { generation, credentialFingerprint: match[2]!.toLowerCase() }; +} + +function definiteAttachmentProviderRejection(error: unknown): boolean { + const pending: Array<{ depth: number; value: unknown }> = [ + { depth: 0, value: error }, + ]; + const seen = new Set(); + const statuses: number[] = []; + const names: string[] = []; + const codes: string[] = []; + while (pending.length > 0) { + const current = pending.shift(); + if ( + !current || + current.depth > 4 || + !current.value || + typeof current.value !== "object" || + seen.has(current.value) + ) { + continue; + } + seen.add(current.value); + const record = current.value as { + cause?: unknown; + code?: unknown; + details?: { code?: unknown; providerStatus?: unknown }; + innerHttpError?: { statusCode?: unknown }; + name?: unknown; + original?: unknown; + originalError?: unknown; + response?: { status?: unknown }; + status?: unknown; + statusCode?: unknown; + }; + for (const status of [ + record.status, + record.statusCode, + record.response?.status, + record.details?.providerStatus, + record.innerHttpError?.statusCode, + ]) { + if (typeof status === "number" && Number.isInteger(status)) { + statuses.push(status); + } + } + if (typeof record.name === "string") names.push(record.name); + for (const code of [record.code, record.details?.code]) { + if (typeof code === "string") codes.push(code); + } + for (const nested of [ + record.cause, + record.original, + record.originalError, + ]) { + pending.push({ depth: current.depth + 1, value: nested }); + } + } + if ( + names.some((name) => + ["ValidationError", "NotImplementedError"].includes(name), + ) || + codes.some((code) => + [ + "CHAT_ADAPTER_COMPATIBILITY_ERROR", + "CHAT_PROVIDER_PRETRANSPORT_REJECTED", + "NOT_IMPLEMENTED", + "VALIDATION_ERROR", + ].includes(code), + ) + ) { + return false; + } + // Slack's Web API can reject a file operation with `ok: false` in an HTTP + // 200 response. The SDK's structured platform-error code is still a + // definitive provider rejection; the main disposition classifier has + // already excluded auth, destination, and rate-limit outcomes. + if (codes.includes("slack_webapi_platform_error")) return true; + return statuses.some( + (status) => + status >= 400 && + status < 500 && + ![401, 403, 404, 410, 429].includes(status), + ); +} + +function attachmentFailureKind( + payload: SafeChatPublicationPayload, +): + | { kind: "generated_response"; provider: "discord" | "telegram" } + | { kind: "selected_file"; provider: null } + | null { + if (payload.transportPart?.mode === "discord_markdown_attachment") { + return { kind: "generated_response", provider: "discord" }; + } + if (payload.transportPart?.mode === "telegram_markdown_attachment") { + return { kind: "generated_response", provider: "telegram" }; + } + if (payload.attachmentIds?.length) { + return { kind: "selected_file", provider: null }; + } + return null; +} + +export { isExplicitOperatorPublication, ATTACHMENT_FAILURE_NOTICE_PREFIX, attachmentFailureNoticeFence, definiteAttachmentProviderRejection, attachmentFailureKind }; diff --git a/server/src/services/chat-channels/publications/preparation.ts b/server/src/services/chat-channels/publications/preparation.ts new file mode 100644 index 00000000000..54df34ef5bd --- /dev/null +++ b/server/src/services/chat-channels/publications/preparation.ts @@ -0,0 +1,150 @@ +import { createDiscordPublicationPreparation } from "../discord/publication-preparation.js"; +import { createTelegramPublicationPreparation } from "../telegram/publication-preparation.js"; +import { createChatPublicationReplacement } from "../publication-replacement.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpoints, + chatPublications, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + renderPublicationTransportText, + splitNativePublicationText, +} from "../../chat-publication-text-parts.js"; + +type PublicationPreparationOptions = { + runPublicationToReplace: ReturnType["runPublicationToReplace"]; + publicationTransportPreparationBarrier?: (input: { publicationId: string }) => Promise; +}; +export function createChatPublicationPreparation(db: Db, options: PublicationPreparationOptions) { + const { runPublicationToReplace } = options; + const { prepareDiscordPublication } = createDiscordPublicationPreparation(db, { runPublicationToReplace }); + const { prepareTelegramPublication } = createTelegramPublicationPreparation(db, { runPublicationToReplace }); + + + async function ensureDurablePublicationTransport( + publication: typeof chatPublications.$inferSelect, + ): Promise { + const persisted = publication.payload as SafeChatPublicationPayload; + if ( + !persisted || + typeof persisted !== "object" || + typeof persisted.text !== "string" + ) { + throw Object.assign( + new Error("Durable chat publication payload is malformed"), + { code: "VALIDATION_ERROR", name: "ValidationError" }, + ); + } + // Validate closed persisted wrappers even on retries that are already split. + renderPublicationTransportText(persisted); + await options.publicationTransportPreparationBarrier?.({ + publicationId: publication.id, + }); + const provider = await db + .select({ provider: chatEndpoints.provider }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, publication.endpointId)) + .then((rows) => rows[0]?.provider ?? null); + if ( + provider === "slack" || + provider === "github" || + provider === "microsoft-teams" + ) { + if ( + persisted.transportPart || + persisted.card || + persisted.interactionId || + persisted.attachmentIds?.length + ) + return publication; + const parts = splitNativePublicationText(provider, persisted.text); + if (parts.length === 1) return publication; + return db.transaction(async (tx) => { + const current = await tx + .select() + .from(chatPublications) + .where(eq(chatPublications.id, publication.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) return publication; + const currentPayload = current.payload as SafeChatPublicationPayload; + renderPublicationTransportText(currentPayload); + if ( + currentPayload.transportPart || + currentPayload.card || + currentPayload.interactionId || + currentPayload.attachmentIds?.length || + !["pending", "retry"].includes(current.state) + ) + return current; + const currentParts = + currentPayload.text === persisted.text + ? parts + : splitNativePublicationText(provider, currentPayload.text); + if (currentParts.length === 1) return current; + const batchId = current.id; + const updatedAt = new Date(); + const payloadFor = (index: number): SafeChatPublicationPayload => { + const part = currentParts[index]!; + const payload = { + ...currentPayload, + text: part.text, + transportPart: { + batchId, + index, + count: currentParts.length, + mode: "inline" as const, + orderKey: `${batchId}:${String(index).padStart(4, "0")}`, + ...(part.prefix ? { prefix: part.prefix } : {}), + ...(part.suffix ? { suffix: part.suffix } : {}), + }, + }; + if (index > 0) delete payload.progressState; + return payload; + }; + const rootPayload = payloadFor(0); + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: rootPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + await notifyChatPublicationWork(tx); + await tx.insert(chatPublications).values( + currentParts.slice(1).map((_part, offset) => ({ + companyId: current.companyId, + endpointId: current.endpointId, + conversationId: current.conversationId, + issueId: current.issueId, + commentId: current.commentId, + idempotencyKey: `${current.idempotencyKey}:${provider}-part:${offset + 1}`, + payload: payloadFor(offset + 1), + state: "pending" as const, + createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, + updatedAt, + })), + ); + return { ...current, payload: rootPayload, updatedAt }; + }); + } + if (provider === "discord") { + return prepareDiscordPublication(publication, persisted); + } + + if (provider !== "telegram") return publication; + return prepareTelegramPublication(publication, persisted); + } + return { ensureDurablePublicationTransport }; +} diff --git a/server/src/services/chat-channels/publications/queue.ts b/server/src/services/chat-channels/publications/queue.ts new file mode 100644 index 00000000000..7aa0537fe70 --- /dev/null +++ b/server/src/services/chat-channels/publications/queue.ts @@ -0,0 +1,389 @@ +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { unresolvedSlackPublicationCondition } from "../../connectors/slack-publication.js"; + +import { settleExternalConversation } from "../../slack-conversation-lifecycle.js"; + +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + isNull, + lte, + notExists, + notInArray, + or, + sql, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatEndpointLeases, + chatEndpoints, + chatPublications, + chatTeamsFileTransfers, +} from "@paperclipai/db"; + +import { logger } from "../../../middleware/logger.js"; + +import { publicationTransportOrderKey, publicationPrecedes } from "./order.js"; +import type { createChatPublicationWorker } from "./worker.js"; +type PublicationQueueOptions = { + PUBLICATION_ENDPOINT_CONCURRENCY: number; + enqueueInboundWakeupPublications: () => Promise; + processPendingInteractionWakeups: (limit?: number) => Promise; + reconcileTerminalConfirmationActions: (limit?: number) => Promise; + processSelectedPublication: ReturnType["processSelectedPublication"]; + publicationEndpointTasks: Map>; + teamsFileRetryAfter: Map; + redactError: (error: unknown) => string; + isShuttingDown: () => boolean; + wakePublications: () => void; +}; +export function createChatPublicationQueue(db: Db, options: PublicationQueueOptions) { + const { PUBLICATION_ENDPOINT_CONCURRENCY, enqueueInboundWakeupPublications, processPendingInteractionWakeups, reconcileTerminalConfirmationActions, processSelectedPublication, publicationEndpointTasks, teamsFileRetryAfter, redactError, wakePublications, isShuttingDown } = options; + + + function publicationEligibility({ now, busyEndpointIds = [], coolingTeamsPublicationIds = [], attemptedIds = [] }: { now?: Date; busyEndpointIds?: string[]; coolingTeamsPublicationIds?: string[]; attemptedIds?: string[] } = {}) { + const earlierPublication = alias(chatPublications, "earlier_chat_publications"); + return and( + sql`not (${unresolvedSlackPublicationCondition()})`, + // An explicit Board send promises both delivery and work. Keep + // its text/files pending until the durable wake has been accepted. + sql`not exists (select 1 from chat_actions a where a.company_id = ${chatPublications.companyId} and a.endpoint_id = ${chatPublications.endpointId} and a.conversation_id = ${chatPublications.conversationId} and a.kind = 'slack_board_message' and a.status = 'received' and a.payload->>'commentId' = ${chatPublications.commentId}::text)`, + or( + and( + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`, + inArray(chatPublications.state, ["pending", "retry"]), + notExists( + db + .select({ id: chatTeamsFileTransfers.id }) + .from(chatTeamsFileTransfers) + .where( + and( + eq( + chatTeamsFileTransfers.companyId, + chatPublications.companyId, + ), + eq( + chatTeamsFileTransfers.publicationId, + chatPublications.id, + ), + ), + ), + ), + ), + // Dedicated protocol work shares the endpoint concurrency cap, + // never the ordinary publication claim or resend implementation. + sql`exists (${db + .select({ id: chatTeamsFileTransfers.id }) + .from(chatTeamsFileTransfers) + .where( + and( + eq( + chatTeamsFileTransfers.companyId, + chatPublications.companyId, + ), + eq( + chatTeamsFileTransfers.endpointId, + chatPublications.endpointId, + ), + eq( + chatTeamsFileTransfers.conversationId, + chatPublications.conversationId, + ), + eq( + chatTeamsFileTransfers.publicationId, + chatPublications.id, + ), + sql`exists (${db + .select({ id: chatEndpoints.id }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, chatPublications.endpointId), + eq( + chatEndpoints.companyId, + chatPublications.companyId, + ), + eq(chatEndpoints.provider, "microsoft-teams"), + eq(chatEndpoints.status, "active"), + ), + )})`, + or( + inArray(chatTeamsFileTransfers.phase, [ + "consent_pending", + "upload_pending", + "file_info_pending", + ]), + and( + eq(chatTeamsFileTransfers.phase, "consent_unknown"), + isNotNull(chatTeamsFileTransfers.responseActivityId), + sql`${chatTeamsFileTransfers.privateState}->'response' is not null`, + ), + ), + ), + )})`, + ), + busyEndpointIds.length > 0 + ? notInArray(chatPublications.endpointId, busyEndpointIds) + : undefined, + coolingTeamsPublicationIds.length > 0 + ? notInArray(chatPublications.id, coolingTeamsPublicationIds) + : undefined, + // Inactive endpoints must not consume the eligibility page or + // acquire an artificial retry deadline. When an operator resumes + // them, due work is immediately eligible; genuine provider retry + // deadlines below remain unchanged. + notExists( + db + .select({ id: chatEndpoints.id }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, chatPublications.endpointId), + inArray(chatEndpoints.status, ["paused", "attention"]), + ), + ), + ), + now ? or(isNull(chatPublications.nextAttemptAt), lte(chatPublications.nextAttemptAt, now)) : undefined, + attemptedIds.length > 0 + ? notInArray(chatPublications.id, attemptedIds) + : undefined, + notExists( + db + .select({ id: earlierPublication.id }) + .from(earlierPublication) + .where( + and( + eq( + earlierPublication.conversationId, + chatPublications.conversationId, + ), + publicationPrecedes(earlierPublication, chatPublications), + inArray(earlierPublication.state, [ + "pending", + "retry", + "streaming", + "delivery_unknown", + ]), + ), + ), + ), + ); + } + + async function processPendingPublications( + limit = 25, + { waitForCompletion = true, maintenance = true }: { waitForCompletion?: boolean; maintenance?: boolean } = {}, + ) { + if (isShuttingDown()) return 0; + if (maintenance) { + await enqueueInboundWakeupPublications(); + await reconcileTerminalConfirmationActions(limit); + } + const now = new Date(); + const staleBefore = new Date(now.getTime() - 60_000); + // A process that disappears after the provider accepted a post but before + // Paperclip persisted its message id leaves an ambiguous delivery. Never + // resend it automatically; an operator can explicitly replay after + // checking the provider conversation. + const quarantinedPublications = await db.transaction(async tx => { + const condition = and( + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`, + eq(chatPublications.state, "streaming"), + lte(chatPublications.updatedAt, staleBefore), + // Teams owns separate staged I/O intents and a longer attempt lease. + // Only that protocol may recover/quarantine its in-flight stages. + notExists( + db + .select({ id: chatTeamsFileTransfers.id }) + .from(chatTeamsFileTransfers) + .where( + and( + eq( + chatTeamsFileTransfers.companyId, + chatPublications.companyId, + ), + eq(chatTeamsFileTransfers.publicationId, chatPublications.id), + ), + ), + ), + notExists( + db + .select({ id: chatEndpointLeases.id }) + .from(chatEndpointLeases) + .where( + and( + eq(chatEndpointLeases.companyId, chatPublications.companyId), + eq( + chatEndpointLeases.endpointId, + chatPublications.endpointId, + ), + sql`${chatEndpointLeases.leaseKey} = 'publication:' || ${chatPublications.id}::text || ':' || ${chatPublications.attempts}::text`, + gt(chatEndpointLeases.expiresAt, now), + ), + ), + ), + ); + const candidates = await tx.select({ id: chatPublications.id }).from(chatPublications).where(condition).for("update", { skipLocked: true }); + if (!candidates.length) return []; + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "delivery_unknown", + nextAttemptAt: null, + redactedError: + "Provider delivery could not be confirmed after the worker stopped. Check the external conversation before replaying.", + updatedAt: now, + }) + .where( + and(condition, inArray(chatPublications.id, candidates.map(row => row.id))), + ) + .returning({ id: chatPublications.id }); + }); + if (quarantinedPublications.length > 0) { + await db + .update(chatActions) + .set({ + status: "delivery_unknown", + result: { + code: "task_control_delivery_unknown", + retryable: false, + }, + updatedAt: now, + }) + .where( + and( + eq(chatActions.kind, "task_control_authorization"), + eq(chatActions.status, "processing"), + inArray( + chatActions.providerActionId, + quarantinedPublications.map( + (publication) => `task-control-authorization:${publication.id}`, + ), + ), + ), + ); + } + const attemptedIds: string[] = []; + const ownedTasks = new Set>(); + let failed = false; + let firstError: unknown; + try { + while (attemptedIds.length < limit && !failed && !isShuttingDown()) { + if (publicationEndpointTasks.size >= PUBLICATION_ENDPOINT_CONCURRENCY) { + if (!waitForCompletion) break; + await Promise.race(publicationEndpointTasks.values()); + continue; + } + const busyEndpointIds = [...publicationEndpointTasks.keys()]; + for (const [id, retryAfter] of teamsFileRetryAfter) + if (retryAfter <= Date.now()) teamsFileRetryAfter.delete(id); + const coolingTeamsPublicationIds = [...teamsFileRetryAfter.keys()]; + // Select only each conversation's current head before applying the + // global limit. Re-query after every batch so one invocation can still + // drain a conversation's newly unblocked milestones in FIFO order. + const rows = await db + .select() + .from(chatPublications) + .where( + publicationEligibility({ now, busyEndpointIds, coolingTeamsPublicationIds, attemptedIds }), + ) + .orderBy( + asc(chatPublications.createdAt), + asc(publicationTransportOrderKey(chatPublications)), + ) + .limit(limit - attemptedIds.length); + if (rows.length === 0) { + // The query excluded the busy-endpoint snapshot above. A worker + // may have completed while PostgreSQL selected this empty page, + // exposing a new head that must be queried before declaring idle. + if (busyEndpointIds.some((id) => !publicationEndpointTasks.has(id))) + continue; + if (!waitForCompletion || publicationEndpointTasks.size === 0) break; + await Promise.race(publicationEndpointTasks.values()); + continue; + } + let started = false; + for (const selectedPublication of rows) { + if ( + publicationEndpointTasks.size >= PUBLICATION_ENDPOINT_CONCURRENCY || + attemptedIds.length >= limit || + failed || + isShuttingDown() + ) + break; + if (publicationEndpointTasks.has(selectedPublication.endpointId)) + continue; + attemptedIds.push(selectedPublication.id); + started = true; + // Reserve synchronously before provider work; overlapping drains share + // this cap. Different conversations for one bot keep the existing + // endpoint-exclusive credential fence instead of timing out behind it. + const task = Promise.resolve() + .then(async () => { + await processSelectedPublication(selectedPublication); + await settleExternalConversation(db, selectedPublication.companyId, selectedPublication.issueId).catch((err) => { + logger.warn({ err, publicationId: selectedPublication.id }, "Slack conversation settlement deferred to reconciliation"); + }); + }) + .catch((error: unknown) => { + if (!failed) { + failed = true; + firstError = error; + } + if (!waitForCompletion) { + logger.error( + { + endpointId: selectedPublication.endpointId, + publicationId: selectedPublication.id, + error: redactError(error), + }, + "scheduled chat publication worker failed", + ); + } + }) + .finally(() => { + if ( + publicationEndpointTasks.get(selectedPublication.endpointId) === + task + ) { + publicationEndpointTasks.delete(selectedPublication.endpointId); + } + ownedTasks.delete(task); + wakePublications(); + }); + publicationEndpointTasks.set(selectedPublication.endpointId, task); + ownedTasks.add(task); + } + // Re-query while slots remain: one busy endpoint must not fill the + // candidate page and hide later eligible endpoints behind its backlog. + if (!started && publicationEndpointTasks.size > 0) { + if (!waitForCompletion) break; + await Promise.race(publicationEndpointTasks.values()); + } + } + } catch (error) { + if (!failed) { + failed = true; + firstError = error; + } + } finally { + // Observe rejection immediately above and join every started worker + // before this invocation returns or propagates its original failure. + if (waitForCompletion) await Promise.all(ownedTasks); + } + if (failed) throw firstError; + if (maintenance) await processPendingInteractionWakeups(limit); + return attemptedIds.length; + } + return { processPendingPublications, publicationEligibility }; +} diff --git a/server/src/services/chat-channels/publications/task-control.ts b/server/src/services/chat-channels/publications/task-control.ts new file mode 100644 index 00000000000..ebe1a2b4ee9 --- /dev/null +++ b/server/src/services/chat-channels/publications/task-control.ts @@ -0,0 +1,149 @@ +import type { DbOrTransaction } from "../runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { + and, + eq, + gt, + inArray, + like, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatPublications, + issues, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { projectSafeChatPublication } from "../../chat-publication-projection.js"; + +import { publicationTransportOrderKey } from "./order.js"; +export function createChatPublicationTaskControl(db: Db) { + + + async function currentTaskControlPayload( + publication: typeof chatPublications.$inferSelect, + ): Promise { + const persisted = publication.payload as SafeChatPublicationPayload; + if (!publication.idempotencyKey.startsWith("control:status:")) { + return persisted; + } + const issue = await db + .select({ + identifier: issues.identifier, + status: issues.status, + title: issues.title, + }) + .from(issues) + .where( + and( + eq(issues.companyId, publication.companyId), + eq(issues.id, publication.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!issue) return persisted; + return projectSafeChatPublication({ + classification: "external", + source: "task_control", + text: `${issue.identifier}: ${issue.title} — ${issue.status}`, + }); + } + + function taskControlCompletesConversation( + publication: Pick, + ): boolean { + return /^control:(?:new|close):/.test(publication.idempotencyKey); + } + + async function hasCommittedTaskControlCompletion( + conversationId: string, + database: DbOrTransaction = db, + ): Promise { + return database + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.conversationId, conversationId), + eq(chatPublications.state, "published"), + or( + like(chatPublications.idempotencyKey, "control:new:%"), + like(chatPublications.idempotencyKey, "control:close:%"), + ), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + } + + async function commitTaskControlCompletion( + tx: Db, + publication: Pick< + typeof chatPublications.$inferSelect, + | "companyId" + | "conversationId" + | "createdAt" + | "endpointId" + | "id" + | "idempotencyKey" + | "payload" + >, + committedAt: Date, + ): Promise { + if (!taskControlCompletesConversation(publication)) return; + await tx + .update(chatConversations) + .set({ state: "completed", updatedAt: committedAt }) + .where( + and( + eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId), + eq(chatConversations.id, publication.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + // The provider-visible completion is the external boundary. Anything + // enqueued later for this old generation stays internal even if its run + // finishes after /new or /close. A racing insert that commits after this + // transaction is rejected by the completed-conversation send guard. + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + state: "cancelled", + nextAttemptAt: null, + redactedError: + "Conversation was completed by a task control before delivery", + updatedAt: committedAt, + }) + .where( + and( + eq(chatPublications.companyId, publication.companyId), + eq(chatPublications.endpointId, publication.endpointId), + eq(chatPublications.conversationId, publication.conversationId), + or( + gt(chatPublications.createdAt, publication.createdAt), + and( + eq(chatPublications.createdAt, publication.createdAt), + gt( + publicationTransportOrderKey(chatPublications), + publication.payload.transportPart?.orderKey ?? publication.id, + ), + ), + ), + inArray(chatPublications.state, ["pending", "retry", "streaming"]), + sql`not (${chatPublications.idempotencyKey} like 'explicit:%' or ${chatPublications.idempotencyKey} like 'explicit-board:%')`, + ), + ); + } + return { currentTaskControlPayload, hasCommittedTaskControlCompletion, commitTaskControlCompletion }; +} diff --git a/server/src/services/chat-channels/publications/transport.ts b/server/src/services/chat-channels/publications/transport.ts new file mode 100644 index 00000000000..e375fe805ed --- /dev/null +++ b/server/src/services/chat-channels/publications/transport.ts @@ -0,0 +1,439 @@ +import type { PublicationTransportInput } from "./types.js"; +import { createPhotonPublicationDelivery } from "../imessage-photon/publications.js"; +import { createOutboundAttachments } from "../outbound-attachments.js"; +import { discordMarkdownAttachment } from "../discord/messages.js"; +import { telegramMarkdownAttachment } from "../telegram/messages.js"; + +import type { EndpointRow, DbOrTransaction } from "../runtime-types.js"; + +import { createChatPublicationReplacement } from "../publication-replacement.js"; + +import { voiceSessionService } from "../../voice/voice-session-service.js"; + +import { voiceSessionStore } from "../../voice/voice-session-store.js"; +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatGitHubConfigurations } from "@paperclipai/db"; + +import { githubResponseCommentService } from "../../chat-github-response-comments.js"; + +import { + and, + eq, + isNull, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; +import type { + ChatAdapterCapabilities, + ChatEndpointSetupState, + ChatProvider, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { telegramAttachmentForUpload } from "../../chat-telegram-photo.js"; + +import { + conflict, + forbidden, +} from "../../../errors.js"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { safeChatTaskUrl } from "../../chat-task-url.js"; + +import { + renderPublicationTransportText, +} from "../../chat-publication-text-parts.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import { + prepareTeamsInlineImage, +} from "../../chat-teams-inline-images.js"; +import { + shouldStreamSafePublicationText, + streamSafePublicationText, +} from "../../chat-publication-stream.js"; + +import { + isChatQuestionFormOpenActionId, +} from "../../chat-question-forms.js"; + +import type { + AdapterPostableMessage, + Attachment, + FileUpload, +} from "chat"; +import { Actions, Button, Card, CardText, LinkButton } from "chat"; +import type { EndpointRecord } from "../runtime-types.js"; +import { isExplicitOperatorPublication } from "./policy.js"; +type PublicationTransportOptions = { + endpointRecord: (endpointId: string) => Promise; + fetchImpl: typeof globalThis.fetch; + getTaskBaseUrl: () => string | null | undefined; + publicationFiles: ReturnType["publicationFiles"]; + receiptReactionCompletionRunId: ReturnType["receiptReactionCompletionRunId"]; + runIdFromMilestonePublication: ReturnType["runIdFromMilestonePublication"]; + runtime: Pick; + runtimeFor: (endpoint: EndpointRow) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + CAPABILITIES: Record; + voice: Pick, "pushReplies">; + voiceStore: Pick, "enqueuePublication">; + scheduleMessageProcessing: (task: () => Promise) => void; +}; +export function createChatPublicationTransport(db: Db, options: PublicationTransportOptions) { + const { endpointRecord, fetchImpl, getTaskBaseUrl, publicationFiles, receiptReactionCompletionRunId, runIdFromMilestonePublication, runtime, runtimeFor, runtimeGeneration, CAPABILITIES, voice, voiceStore, scheduleMessageProcessing } = options; + const { deliverPhotonPublication } = createPhotonPublicationDelivery(db, { endpointRecord, runtimeGeneration, runtime, receiptReactionCompletionRunId, getTaskBaseUrl }); + + +async function attemptProviderPublication( + send: () => Promise, +): Promise { + return await send(); +} + +async function editOrPostProviderPublication( + edit: () => Promise, + post: () => Promise, +): Promise { + try { + return await edit(); + } catch (error) { + // A definite 404 proves the old progress comment no longer exists. It is + // therefore safe to create a replacement without risking a duplicate. + // Ambiguous transport failures must still enter delivery_unknown. + if (classifyChatPublicationError(error, 1).kind !== "resource_unavailable") + throw error; + return await post(); + } +} + +function safeCardForPublication( + payload: SafeChatPublicationPayload, + provider: ChatProvider, +) { + if (!payload.card) return null; + const children = []; + if (payload.card.body) children.push(CardText(payload.card.body)); + const actions = (payload.card.actions ?? []).map((action) => + action.type === "callback" + ? Button({ + id: action.actionId, + label: action.label, + style: action.style ?? "default", + // Telegram encodes both id and value into callback_data, whose hard + // limit is 64 bytes. Its opaque action id resolves through the + // durable token ledger, so repeating the interaction UUID is both + // unnecessary and too large. Other adapters retain the value as a + // defense-in-depth binding supported by their larger envelopes. + value: provider === "telegram" ? undefined : payload.interactionId, + actionType: isChatQuestionFormOpenActionId(action.actionId) + ? "modal" + : undefined, + }) + : LinkButton({ label: action.label, url: action.url }), + ); + if (actions.length) children.push(Actions(actions)); + return Card({ title: payload.card.title, children }); +} + + async function recordExactOutboundPublicationLink( + tx: DbOrTransaction, + input: { + publication: typeof chatPublications.$inferSelect; + providerMessageId: string; + }, + ): Promise { + const inserted = await tx + .insert(chatMessageLinks) + .values({ + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + publicationId: input.publication.id, + commentId: input.publication.commentId, + providerMessageId: input.providerMessageId, + direction: "outbound", + }) + .onConflictDoNothing() + .returning({ id: chatMessageLinks.id }); + if (inserted.length) return true; + return tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, input.publication.companyId), + eq(chatMessageLinks.endpointId, input.publication.endpointId), + eq(chatMessageLinks.conversationId, input.publication.conversationId), + eq(chatMessageLinks.publicationId, input.publication.id), + input.publication.commentId + ? eq(chatMessageLinks.commentId, input.publication.commentId) + : isNull(chatMessageLinks.commentId), + eq(chatMessageLinks.providerMessageId, input.providerMessageId), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + } + + async function postSafePublication(input: PublicationTransportInput) { + if (input.endpoint.provider === "speko") { + const receipt = await voiceStore.enqueuePublication(input.endpoint.companyId, input.publication.id); + scheduleMessageProcessing(() => voice.pushReplies(25, input.endpoint.companyId, input.endpoint.id).then(() => undefined)); + return receipt; + } + if (input.endpoint.provider === "github" && ["failed", "completed"].includes(input.payload.progressState ?? "") && + !input.payload.interactionId && !isExplicitOperatorPublication(input.publication) && + (await db.select({ endpointId: chatGitHubConfigurations.endpointId }).from(chatGitHubConfigurations).where(and( + eq(chatGitHubConfigurations.companyId, input.endpoint.companyId), eq(chatGitHubConfigurations.endpointId, input.endpoint.id))).limit(1)).length > 0) { + const runId = runIdFromMilestonePublication(input.publication); + if (!runId) throw forbidden("GitHub failure has no run binding"); + const receipt = await githubResponseCommentService(db, fetchImpl).failRun({ + companyId: input.publication.companyId, endpointId: input.endpoint.id, + issueId: input.publication.issueId, runId, body: input.payload.progressState === "completed" + ? "This turn ended without publishing a final response. See the Paperclip task for details." + : renderPublicationTransportText(input.payload), + }); + if (!receipt) throw conflict("GitHub response publication is busy"); + return { id: receipt.id, threadId: input.conversation.externalThreadId, raw: {}, text: input.payload.text }; + } + const endpointRuntime = await runtimeFor(input.endpoint); + const thread = endpointRuntime.thread(input.conversation.externalThreadId); + const card = safeCardForPublication(input.payload, input.endpoint.provider); + let attachments: Attachment[] = []; + let files: FileUpload[] = []; + let text = renderPublicationTransportText(input.payload); + if (input.payload.attachmentIds?.length) { + if ( + input.endpoint.provider === "microsoft-teams" && + !input.conversation.isDirectMessage + ) { + // The staged personal-file lane remains authoritative for consent. + // Channel/group pictures need no OneDrive upload or public asset URL: + // send only verified original picture bytes through the native adapter. + const candidates = await publicationFiles( + input.publication, + input.payload, + { teamsInlineImagesOnly: true }, + ); + const pictures = await Promise.all( + candidates.map(prepareTeamsInlineImage), + ); + if (pictures.length && pictures.every((picture) => picture !== null)) { + files = pictures; + } + } + const nativeFileSurface = + CAPABILITIES[input.endpoint.provider].files && + input.endpoint.provider !== "microsoft-teams"; + if (nativeFileSurface) { + const uploads = await publicationFiles( + input.publication, + input.payload, + ); + if (input.endpoint.provider === "telegram") { + attachments = await Promise.all( + uploads.map(telegramAttachmentForUpload), + ); + } else { + files = uploads; + } + } else if (files.length === 0) { + const taskUrl = safeChatTaskUrl( + getTaskBaseUrl(), + input.publication.issueId, + ); + if ( + input.endpoint.provider === "github" || + input.endpoint.provider === "microsoft-teams" + ) { + // GitHub lacks a bot upload contract. Teams reaches this fallback + // only without an admissible personal-consent destination. Replace + // our generated caption, preserving any future custom text. + const generatedFileLabel = /^Shared (.+)\.$/s.exec(text)?.[1] ?? null; + const limitation = + input.endpoint.provider === "github" + ? "This GitHub App connection cannot upload file bytes into comments." + : "Direct file delivery isn't available for this Teams conversation."; + if (generatedFileLabel) { + const saved = taskUrl + ? `File saved on the Paperclip task: ${generatedFileLabel}.` + : `File saved on the private Paperclip task: ${generatedFileLabel}.`; + text = taskUrl + ? `${saved} ${limitation} Download it: ${taskUrl}` + : `${saved} ${limitation}`; + } else { + const handoff = taskUrl + ? `Open the file on its Paperclip task: ${taskUrl}` + : "The file remains available only on the private Paperclip task."; + text = `${text}\n\n${limitation} ${handoff}`; + } + } else { + const attachmentFallback = taskUrl + ? `Open the task in Paperclip: ${taskUrl}` + : "Open the task in Paperclip to download the attachment."; + text = `${text}\n\n${attachmentFallback}`; + } + } + } + if (input.endpoint.provider === "imessage-photon") { + return deliverPhotonPublication(input, endpointRuntime, thread, text, files); + } + if ( + input.endpoint.provider === "discord" && + input.payload.transportPart?.mode === "discord_markdown_attachment" + ) { + files.push(discordMarkdownAttachment(text)); + text = + input.payload.transportPart.count > 1 + ? "Complete response attached." + : "Paperclip attached the complete response because it exceeds Discord’s message limit."; + } + if ( + input.endpoint.provider === "telegram" && + input.payload.transportPart?.mode === "telegram_markdown_attachment" + ) { + attachments.push(telegramMarkdownAttachment(text)); + text = + input.payload.transportPart.count > 1 + ? "Complete response attached." + : "Paperclip attached the complete response to preserve its Markdown formatting."; + } + if (card && CAPABILITIES[input.endpoint.provider].cards) { + return await attemptProviderPublication(async () => + input.replaceProviderMessageId + ? await editOrPostProviderPublication( + () => + thread.adapter.editMessage( + thread.id, + input.replaceProviderMessageId!, + { + card, + fallbackText: text, + ...(files.length ? { files } : {}), + }, + ), + () => + thread.post({ + card, + fallbackText: text, + ...(files.length ? { files } : {}), + }), + ) + : await thread.post({ + card, + fallbackText: text, + ...(files.length ? { files } : {}), + }), + ); + } + if (attachments.length) { + const attachmentMessage: AdapterPostableMessage = { + attachments, + markdown: text, + }; + return await attemptProviderPublication(async () => + input.replaceProviderMessageId + ? await editOrPostProviderPublication( + () => + thread.adapter.editMessage( + thread.id, + input.replaceProviderMessageId!, + attachmentMessage, + ), + () => thread.post(attachmentMessage), + ) + : await thread.post(attachmentMessage), + ); + } + if (files.length) { + // The pinned Slack adapter performs file upload and text publication as + // two provider calls when both fields are present. A rate limit or + // network failure on the second call would make an automatic retry + // duplicate the already-shared file. File publications have their own + // ordered outbox row, so Slack receives a single native upload here. + const fileMessage: AdapterPostableMessage = { + // Empty Slack markdown keeps the SDK call type-safe while its pinned + // adapter treats this as a file-only upload and skips chat.postMessage. + markdown: input.endpoint.provider === "slack" ? "" : text, + files, + }; + if ( + input.endpoint.provider === "slack" && + !input.replaceProviderMessageId && + !input.payload.card && + !input.payload.interactionId && + !input.publication.idempotencyKey.startsWith("control:") && + input.payload.attachmentIds?.length && + input.onSlackFileUploadAccepted + ) { + return await attemptProviderPublication( + async () => + await endpointRuntime.postSlackFilePublication( + thread.id, + fileMessage, + input.onSlackFileUploadAccepted!, + ), + ); + } + return await attemptProviderPublication(async () => + input.replaceProviderMessageId + ? await editOrPostProviderPublication( + () => + thread.adapter.editMessage( + thread.id, + input.replaceProviderMessageId!, + fileMessage, + ), + () => thread.post(fileMessage), + ) + : await thread.post(fileMessage), + ); + } + if ( + !input.replaceProviderMessageId && + !input.payload.transportPart && + CAPABILITIES[input.endpoint.provider].nativeStreaming && + (input.endpoint.provider !== "telegram" || + !input.conversation.isDirectMessage || + input.telegramDraftControl !== undefined) && + shouldStreamSafePublicationText(text) + ) { + return await attemptProviderPublication(async () => + input.telegramDraftControl + ? endpointRuntime.streamTelegramDraft( + input.conversation.externalThreadId, + streamSafePublicationText(text), + input.telegramDraftControl, + ) + : thread.post(streamSafePublicationText(text)), + ); + } + return await attemptProviderPublication(async () => + input.replaceProviderMessageId + ? await editOrPostProviderPublication( + () => + thread.adapter.editMessage( + thread.id, + input.replaceProviderMessageId!, + { markdown: text }, + ), + () => thread.post({ markdown: text }), + ) + : await thread.post({ markdown: text }), + ); + } + return { postSafePublication, recordExactOutboundPublicationLink }; +} diff --git a/server/src/services/chat-channels/publications/types.ts b/server/src/services/chat-channels/publications/types.ts new file mode 100644 index 00000000000..ee4bef967c6 --- /dev/null +++ b/server/src/services/chat-channels/publications/types.ts @@ -0,0 +1,18 @@ +import type { chatConversations, chatPublications } from "@paperclipai/db"; +import type { SafeChatPublicationPayload } from "@paperclipai/shared"; +import type { EndpointRow } from "../runtime-types.js"; +import type { TelegramDraftControl } from "../../chat-telegram-draft-stop.js"; +import type { SlackFileUploadAcceptedReceipt } from "../../chat-sdk-runtime.js"; +type ConversationRow = typeof chatConversations.$inferSelect; +export type PublicationTransportInput = { + endpoint: EndpointRow; + conversation: ConversationRow; + publication: typeof chatPublications.$inferSelect; + payload: SafeChatPublicationPayload; + replaceProviderMessageId?: string | null; + telegramDraftControl?: TelegramDraftControl; + beforePhotonWrite?(): Promise; + onSlackFileUploadAccepted?: ( + receipt: SlackFileUploadAcceptedReceipt, + ) => Promise; + }; diff --git a/server/src/services/chat-channels/publications/worker.ts b/server/src/services/chat-channels/publications/worker.ts new file mode 100644 index 00000000000..8a205ae5775 --- /dev/null +++ b/server/src/services/chat-channels/publications/worker.ts @@ -0,0 +1,1081 @@ +import { createGitHubPublicationPolicy } from "../github/publication-policy.js"; + +import { createTeamsFileDelivery } from "../microsoft-teams/file-delivery.js"; + +import { createReceiptReactions } from "../receipt-reactions.js"; + +import { createTelegramPublicationDrafts } from "../telegram/publication-drafts.js"; +import { createSlackFileReceipts } from "../slack/file-receipts.js"; + +import { createTelegramMaintenance } from "../telegram/maintenance.js"; +import type { EndpointRow, DbOrTransaction, RuntimeContext } from "../runtime-types.js"; + +import { createChatPublicationReplacement } from "../publication-replacement.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { + stageReceiptReactionRemovals, +} from "../../chat-receipt-reactions.js"; + +import { chatCredentialMutationLease, CREDENTIAL_MUTATION_LEASE_TTL_MS, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { slackExplicitPublicationDuplicate } from "../../connectors/slack-publication.js"; + +import { PhotonError } from "../../photon/cloud.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + eq, + inArray, + lt, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointLeases, + chatEndpointResources, + chatEndpoints, + chatMessageLinks, + chatPublications, + chatTeamsFileTransfers, + issueThreadInteractions, +} from "@paperclipai/db"; +import type { + ChatAdapterCapabilities, + ChatProvider, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + isTelegramDraftStopped, +} from "../../chat-telegram-draft-stop.js"; + +import { logger } from "../../../middleware/logger.js"; + +import { + renderPublicationTransportText, +} from "../../chat-publication-text-parts.js"; + +import { + stageSlackSessionSync, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; +import { + parseInboundWakePublicationKey, +} from "../../chat-inbound-wakeup-publications.js"; + +import { + shouldStreamSafePublicationText, +} from "../../chat-publication-stream.js"; + +import { publicationTransportOrderKey } from "./order.js"; +import { isExplicitOperatorPublication } from "./policy.js"; +import type { EndpointRecord } from "../runtime-types.js"; +import type { createChatPublicationAuthorization } from "./authorization.js"; +import type { createChatPublicationTaskControl } from "./task-control.js"; +import type { createChatPublicationFailures } from "./failures.js"; +import type { createChatPublicationPreparation } from "./preparation.js"; +import type { createChatPublicationTransport } from "./transport.js"; +type PublicationWorkerOptions = Pick, "runOwnershipMilestoneSupersessionReason" | "runPublicationToReplace" | "receiptReactionCompletionRunId" | "inboundWakePublicationToReplace" | "interactionResolutionPublicationToReplace" | "interactionPromptPublicationToReplace" | "progressSupersededByFastResponse" | "runProgressSupersededByPublishedInteraction" | "taskStatusPublicationToReplace" | "closeProgressToReplace"> & { + CAPABILITIES: Record; + authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise; + claimPublicationTransportAuthorization: ReturnType["claimPublicationTransportAuthorization"]; + commitTaskControlCompletion: ReturnType["commitTaskControlCompletion"]; + currentTaskControlPayload: ReturnType["currentTaskControlPayload"]; + endpointRecord: (endpointId: string) => Promise; + ensureDurablePublicationTransport: ReturnType["ensureDurablePublicationTransport"]; + finalizePublicationFailure: ReturnType["finalizePublicationFailure"]; + hasCommittedTaskControlCompletion: ReturnType["hasCommittedTaskControlCompletion"]; + hasTelegramStopSubscription: ReturnType["hasTelegramStopSubscription"]; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + postSafePublication: ReturnType["postSafePublication"]; + processReceiptReaction: ReturnType["processReceiptReaction"]; + recordSlackFileUploadReceipt: ReturnType["recordSlackFileUploadReceipt"]; + redactError: (error: unknown) => string; + reserveTelegramPublicationDraft: ReturnType["reserveTelegramPublicationDraft"]; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + scheduleMessageProcessing: (task: () => Promise) => void; + settleGitHubAutomaticPublication: ReturnType["settleGitHubAutomaticPublication"]; + stageSlackFilePublicationSuccess: ReturnType["stageSlackFilePublicationSuccess"]; + tryProcessTeamsFilePublication: ReturnType["tryProcessTeamsFilePublication"]; + withCredentialMutationLease: ReturnType; +}; +export function createChatPublicationWorker(db: Db, options: PublicationWorkerOptions) { + const { CAPABILITIES, authorizeRetainedChatSourcePublication, claimPublicationTransportAuthorization, closeProgressToReplace, commitTaskControlCompletion, currentTaskControlPayload, endpointRecord, ensureDurablePublicationTransport, finalizePublicationFailure, hasCommittedTaskControlCompletion, hasTelegramStopSubscription, inboundWakePublicationToReplace, interactionPromptPublicationToReplace, interactionResolutionPublicationToReplace, nonDirectDestinationAllowed, postSafePublication, processReceiptReaction, progressSupersededByFastResponse, recordSlackFileUploadReceipt, redactError, reserveTelegramPublicationDraft, runOwnershipMilestoneSupersessionReason, runProgressSupersededByPublishedInteraction, runPublicationToReplace, runtimeContextForRecord, scheduleMessageProcessing, settleGitHubAutomaticPublication, stageSlackFilePublicationSuccess, taskStatusPublicationToReplace, tryProcessTeamsFilePublication, withCredentialMutationLease, receiptReactionCompletionRunId } = options; + + + async function stageTerminalReceiptReactionRemovals( + tx: Db, + input: { + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + payload: SafeChatPublicationPayload; + runtimeContext: RuntimeContext; + closedProgressRunId?: string; + }, + ): Promise { + if ( + !["discord", "slack", "telegram", "github"].includes(input.endpoint.provider) + ) return []; + const runId = input.closedProgressRunId ?? + (await receiptReactionCompletionRunId(tx, input.publication, input.payload)); + if (!runId) return []; + return stageReceiptReactionRemovals(tx, { + endpoint: input.endpoint, + binding: input.publication, + runId, + runtimeContext: input.runtimeContext, + }); + } + + async function settlePublicationPreparationFailure( + publication: typeof chatPublications.$inferSelect, + error: unknown, + ): Promise { + const attempts = publication.attempts + 1; + const classified = classifyChatPublicationError(error, attempts); + // No provider I/O has started at this boundary. Unknown transport or + // database failures are therefore safe to retry; only a definite local + // validation rejection (or repeated exhaustion) is terminal. + const terminal = classified.kind === "failed" || attempts >= 5; + const retryAfterMs = + classified.kind === "retry" + ? classified.retryAfterMs + : Math.min(60_000, 2 ** Math.max(0, attempts) * 1_000); + const result = await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + const [settled] = await tx + .update(chatPublications) + .set({ + state: terminal ? "failed" : "retry", + attempts, + nextAttemptAt: terminal ? null : new Date(Date.now() + retryAfterMs), + redactedError: terminal + ? "Publication payload could not be prepared for provider delivery" + : "Publication preparation failed before provider delivery", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, publication.state), + eq(chatPublications.attempts, publication.attempts), + ), + ) + .returning({ id: chatPublications.id }); + if (!settled) return false; + if (terminal && publication.idempotencyKey.startsWith("control:")) { + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: "task_control_publication_invalid", + attempts, + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "issued"), + ), + ); + } + return true; + }); + if (result) { + logger.warn( + { + endpointId: publication.endpointId, + publicationId: publication.id, + error: redactError(error), + terminal, + }, + "chat publication preparation failed", + ); + } + return result; + } + + async function withPublicationAttemptLease( + publication: typeof chatPublications.$inferSelect, + operation: (lease: CredentialMutationLeaseGuard) => Promise, + ): Promise { + // A scheduled sweep may overlap a slow provider call (also on a standby + // server). Claim and exact-attempt ownership must become visible together: + // endpoint-wide activity is not evidence that this publication is alive. + const token = randomUUID(); + const leaseKey = `publication:${publication.id}:${publication.attempts + 1}`; + const now = new Date(); + const owned = and( + eq(chatEndpointLeases.companyId, publication.companyId), + eq(chatEndpointLeases.endpointId, publication.endpointId), + eq(chatEndpointLeases.leaseKey, leaseKey), + eq(chatEndpointLeases.token, token), + ); + const claimed = await db.transaction(async (tx) => { + // Transfer issuance locks this same publication. Check after acquiring + // that lock, not through a pre-lock snapshot that could miss a new lane. + const current = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, publication.companyId), + eq(chatPublications.id, publication.id), + eq(chatPublications.state, publication.state), + eq(chatPublications.attempts, publication.attempts), + ), + ) + .for("update") + .then((rows) => rows[0]); + if (!current) return false; + const transfer = await tx + .select({ id: chatTeamsFileTransfers.id }) + .from(chatTeamsFileTransfers) + .where( + and( + eq(chatTeamsFileTransfers.companyId, publication.companyId), + eq(chatTeamsFileTransfers.publicationId, current.id), + ), + ) + .then((rows) => rows[0]); + if (transfer) return false; + await notifyChatPublicationWork(tx); + const [row] = await tx + .update(chatPublications) + .set({ + state: "streaming", + attempts: publication.attempts + 1, + updatedAt: now, + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, publication.state), + eq(chatPublications.attempts, publication.attempts), + ), + ) + .returning({ id: chatPublications.id }); + if (!row) return false; + await tx.insert(chatEndpointLeases).values({ + companyId: publication.companyId, + endpointId: publication.endpointId, + leaseKey, + token, + expiresAt: new Date(now.getTime() + CREDENTIAL_MUTATION_LEASE_TTL_MS), + }); + return true; + }); + if (!claimed) return; + let leaseLoss: Error | null = null; + const assertOwned: CredentialMutationLeaseGuard["assertOwned"] = async ( + database = db, + ) => { + if (leaseLoss) throw leaseLoss; + try { + // Use a savepoint when the caller already owns a transaction. The + // decision clock is sampled only after the lease row lock resolves; + // an UPDATE predicate using a pre-lock clock can revive expired work. + await database.transaction(async (tx) => { + const current = await tx + .select({ expiresAt: chatEndpointLeases.expiresAt }) + .from(chatEndpointLeases) + .where(owned) + .for("update") + .then((rows) => rows[0] ?? null); + const checkedAt = new Date(); + if ( + leaseLoss || + !current || + current.expiresAt.getTime() <= checkedAt.getTime() + ) { + throw new Error("Publication attempt lease is no longer owned"); + } + await tx + .update(chatEndpointLeases) + .set({ + expiresAt: new Date( + checkedAt.getTime() + CREDENTIAL_MUTATION_LEASE_TTL_MS, + ), + updatedAt: checkedAt, + }) + .where(owned); + }); + } catch (error) { + leaseLoss = Object.assign( + new Error( + "Chat publication attempt ownership was lost before completion", + { cause: error }, + ), + { code: "CHAT_PUBLICATION_LEASE_LOST" }, + ); + throw leaseLoss; + } + }; + let renewal: Promise | null = null; + const timer = setInterval(() => { + if (renewal || leaseLoss) return; + renewal = assertOwned() + .catch(() => undefined) + .finally(() => { + renewal = null; + }); + }, CREDENTIAL_MUTATION_LEASE_TTL_MS / 3); + timer.unref?.(); + try { + await operation({ assertOwned }); + } finally { + clearInterval(timer); + await renewal; + await db.delete(chatEndpointLeases).where(owned); + } + } + + async function processSelectedPublication( + selectedPublication: typeof chatPublications.$inferSelect, + ): Promise { + if (await settleGitHubAutomaticPublication(selectedPublication)) return; + let publication: typeof chatPublications.$inferSelect; + try { + publication = + await ensureDurablePublicationTransport(selectedPublication); + } catch (error) { + try { + await settlePublicationPreparationFailure(selectedPublication, error); + } catch (settlementError) { + logger.error( + { + endpointId: selectedPublication.endpointId, + publicationId: selectedPublication.id, + error: redactError(settlementError), + }, + "could not durably settle chat publication preparation failure", + ); + } + return; + } + const explicitSlackDelivery = await slackExplicitPublicationDuplicate(db, publication); + if (explicitSlackDelivery === "unresolved") { + // Keep the durable publication pending until the explicit send is settled; + // a missing provider receipt does not authorize a duplicate. + return; + } + if (explicitSlackDelivery === "delivered") { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx.update(chatPublications).set({ state: "cancelled", redactedError: "Identical response already delivered by Slack tool", updatedAt: new Date() }).where(and(eq(chatPublications.id, publication.id), inArray(chatPublications.state, ["pending", "retry"]))) +; + }); + return; + } + const earlierOpenPublication = await db + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.conversationId, publication.conversationId), + or( + lt(chatPublications.createdAt, publication.createdAt), + and( + eq(chatPublications.createdAt, publication.createdAt), + lt( + publicationTransportOrderKey(chatPublications), + publication.payload.transportPart?.orderKey ?? publication.id, + ), + ), + ), + inArray(chatPublications.state, [ + "pending", + "retry", + "streaming", + "delivery_unknown", + ]), + ), + ) + .limit(1) + .then((result) => result[0] ?? null); + if (earlierOpenPublication) return; + if (await tryProcessTeamsFilePublication(publication)) return; + await withPublicationAttemptLease(publication, async (publicationLease) => { + let providerAccepted = false; + let publicationRuntimeContext: RuntimeContext | null = null; + let failureHandledWithinCredentialLease = false; + let slackFileReceiptActionId: string | null = null; + const settlePublicationFailure = async ( + error: unknown, + credentialLease?: CredentialMutationLeaseGuard, + ) => { + try { + await finalizePublicationFailure( + publication, + error, + providerAccepted, + publicationRuntimeContext, + credentialLease ?? publicationLease, + ); + } catch (finalizationError) { + // If the provider accepted the message, a failed receipt write + // must remain streaming until the stale scan quarantines it. A + // later lifecycle mutation must not turn this into an implicit + // replay or allow a stale failure to overwrite the new runtime. + logger.error( + { + endpointId: publication.endpointId, + publicationId: publication.id, + error: redactError(finalizationError), + }, + "could not durably finalize chat publication failure", + ); + } + }; + try { + const recordForLease = await endpointRecord(publication.endpointId); + if (!recordForLease) + throw new Error("Chat publication binding is unavailable"); + const currentPublicationRuntimeContext = + runtimeContextForRecord(recordForLease); + publicationRuntimeContext = currentPublicationRuntimeContext; + const [endpoint, conversation] = await Promise.all([ + db + .select() + .from(chatEndpoints) + .where(eq(chatEndpoints.id, publication.endpointId)) + .then((result) => result[0] ?? null), + db + .select() + .from(chatConversations) + .where(eq(chatConversations.id, publication.conversationId)) + .then((result) => result[0] ?? null), + ]); + if (!endpoint || !conversation) + throw new Error("Chat publication binding is unavailable"); + const providerVisibleCompletion = + conversation.state === "completed" && + !isExplicitOperatorPublication(publication) && + (await hasCommittedTaskControlCompletion(conversation.id)); + if (endpoint.status === "paused" || endpoint.status === "attention") { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "pending", + attempts: publication.attempts, + // Pause can race the eligibility read. The query excludes + // inactive endpoints on the next pass, so retain only the + // original provider deadline rather than delaying Resume. + nextAttemptAt: publication.nextAttemptAt, + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + // The setup conversation is a real end-to-end test: once provider + // credentials are verified, its safe agent response must be able to + // reach the provider before the operator confirms the final wizard + // step. Draft, paused, revoked, and archived endpoints remain closed. + if ( + !["active", "verifying"].includes(endpoint.status) || + ["unavailable", "endpoint_removed"].includes(conversation.state) || + providerVisibleCompletion + ) { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "cancelled", + redactedError: providerVisibleCompletion + ? "Conversation was completed before delivery" + : "External destination is no longer active", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + if (conversation.isDirectMessage) { + if (!endpoint.allowDirectMessages) { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "cancelled", + redactedError: "Direct messages are disabled in Paperclip", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + } else { + const resource = conversation.resourceId + ? await db + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.companyId, endpoint.companyId), + ), + ) + .then((result) => result[0] ?? null) + : null; + if (!nonDirectDestinationAllowed(endpoint, resource)) { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "cancelled", + redactedError: "Destination is disabled in Paperclip", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + } + const interactionId = publication.payload.interactionId; + const isInteractionPrompt = + Boolean(interactionId) && + publication.idempotencyKey === + `interaction:${interactionId}:${publication.endpointId}`; + if (isInteractionPrompt && publication.issueId) { + const currentInteraction = await db + .select({ status: issueThreadInteractions.status }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.id, interactionId!), + eq(issueThreadInteractions.companyId, publication.companyId), + eq(issueThreadInteractions.issueId, publication.issueId), + ), + ) + .then((result) => result[0] ?? null); + if (!currentInteraction || currentInteraction.status !== "pending") { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "cancelled", + attempts: publication.attempts, + nextAttemptAt: null, + redactedError: "Interaction resolved before provider delivery", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + } + // Milestone selection and insertion can overlap across sweeps. An old + // snapshot inserted after newer attention must not overwrite it simply + // because it now occupies the next FIFO slot. + const progressSupersessionReason = + (await runOwnershipMilestoneSupersessionReason(publication)) ?? + ((await progressSupersededByFastResponse(publication)) ? "Acknowledged by contextual fast response" : null) ?? + ((await runProgressSupersededByPublishedInteraction(publication)) + ? "Run progress was superseded by its provider interaction" + : null); + if (progressSupersessionReason) { + await publicationLease.assertOwned(); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatPublications) + .set({ + state: "cancelled", + attempts: publication.attempts, + nextAttemptAt: null, + redactedError: progressSupersessionReason, + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + }); + return; + } + // Status is sampled when it reaches the head of the provider lane, + // not when the command was admitted. If an already-streaming final + // publication won the race, this reply reflects Paperclip's latest + // authoritative task state after that earlier send commits. + let payload = await currentTaskControlPayload(publication); + await withCredentialMutationLease( + recordForLease.endpoint, + async (credentialGuard) => { + const credentialLease: CredentialMutationLeaseGuard = { + assertOwned: async (database = db) => { + await credentialGuard.assertOwned(database); + await publicationLease.assertOwned(database); + }, + }; + try { + if (await settleGitHubAutomaticPublication(publication, credentialLease)) return; + // A prior failure's provider ID may have been reused while this + // worker waited for the endpoint lane. Select against current + // outbound links only after owning that lane, before the final + // source authorization claim and provider I/O. + const closedProgress = await closeProgressToReplace( + publication, + endpoint, + ); + const replaceProviderMessageId = endpoint.provider !== "imessage-photon" && CAPABILITIES[endpoint.provider] + .messageEdits + ? (closedProgress?.providerMessageId ?? + (await interactionResolutionPublicationToReplace( + publication, + payload, + )) ?? + (await interactionPromptPublicationToReplace( + publication, + payload, + )) ?? + (await runPublicationToReplace(publication, payload)) ?? + (await inboundWakePublicationToReplace( + publication, + payload, + )) ?? + (await taskStatusPublicationToReplace(publication, payload))) + : null; + const authorizationClaim = + await claimPublicationTransportAuthorization({ + credentialLease, + endpoint: recordForLease.endpoint, + publication, + runtimeContext: currentPublicationRuntimeContext, + }); + if (!authorizationClaim) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + state: "cancelled", + nextAttemptAt: null, + redactedError: + "Task control requester or destination is no longer authorized", + updatedAt: new Date(), + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: "task_control_authorization_changed", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "issued"), + ), + ); + await credentialLease.assertOwned(tx); + }); + return; + } + // The durable claim above is the authorization linearization + // point. Provider I/O runs without a database transaction or + // row lock. The renewable lease preserves credential/runtime + // identity until conditional settlement. + if (authorizationClaim.preparedText !== null) + payload = { ...payload, text: authorizationClaim.preparedText }; + const telegramDraft = + authorizationClaim.endpoint.provider === "telegram" && + authorizationClaim.conversation.isDirectMessage && + !replaceProviderMessageId && + !payload.transportPart && + !payload.card && + !payload.attachmentIds?.length && + shouldStreamSafePublicationText( + renderPublicationTransportText(payload), + ) && + (await hasTelegramStopSubscription( + authorizationClaim.endpoint, + currentPublicationRuntimeContext, + )) + ? await reserveTelegramPublicationDraft({ + endpoint: authorizationClaim.endpoint, + conversation: authorizationClaim.conversation, + publication, + text: renderPublicationTransportText(payload), + credentialLease, + runtimeContext: currentPublicationRuntimeContext, + }) + : null; + const sent = await postSafePublication({ + endpoint: authorizationClaim.endpoint, + conversation: authorizationClaim.conversation, + publication, + payload, + replaceProviderMessageId, + telegramDraftControl: telegramDraft?.control, + beforePhotonWrite: () => db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + if (!(await authorizeRetainedChatSourcePublication(tx, publication))) throw new PhotonError("rejected", "Publication source authorization changed"); + }), + onSlackFileUploadAccepted: async (receipt) => { + // uploadV2 has completed at this point. Mark acceptance + // before the durable callback so a local write failure is + // still quarantined and can never trigger an implicit upload. + providerAccepted = true; + slackFileReceiptActionId = await recordSlackFileUploadReceipt( + { + credentialLease, + endpoint: authorizationClaim.endpoint, + conversation: authorizationClaim.conversation, + publication, + receipt, + runtimeContext: currentPublicationRuntimeContext, + }, + ); + }, + }); + if (isTelegramDraftStopped(sent)) { + // Only the durable stop transaction can produce this outcome. + // It settled this exact publication; never fabricate a provider + // receipt, complete its task, or enqueue a retry from this path. + if (!telegramDraft) + throw new Error("Unexpected Telegram draft stop result"); + const stopped = await db + .select({ id: chatActions.id }) + .from(chatActions) + .innerJoin( + chatPublications, + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "cancelled"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ) + .where( + and( + eq(chatActions.id, telegramDraft.actionId), + eq(chatActions.status, "cancelled"), + sql`${chatActions.result}->>'phase' = 'stopped'`, + ), + ); + if (stopped.length !== 1) + throw new Error( + "Telegram draft stop was not durably committed", + ); + return; + } + providerAccepted = true; + // A resolved SDK call is not a usable delivery receipt. Teams, + // for example, returns an empty ID for an empty HTTP success + // body. Preserve uncertainty instead of publishing a broken + // message link or automatically sending the content again. + if ( + typeof sent?.id !== "string" || + !sent.id || + sent.id.length > 2048 || + sent.id.trim() !== sent.id || + /[\u0000-\u001f\u007f]/.test(sent.id) + ) { + throw new Error( + "Provider did not return a usable message receipt", + ); + } + const { authorizationActionId } = authorizationClaim; + const receiptRemovalActionIds: string[] = []; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const committedAt = new Date(); + await notifyChatPublicationWork(tx); + const [completedPublication] = await tx + .update(chatPublications) + .set({ + state: "published", + payload, + providerMessageId: sent.id, + publishedAt: committedAt, + redactedError: null, + updatedAt: committedAt, + }) + .where( + and( + eq(chatPublications.id, publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publication.attempts + 1), + ), + ) + .returning({ id: chatPublications.id }); + if (!completedPublication) { + throw new Error( + "Chat publication ownership changed before commit", + ); + } + if (telegramDraft) { + const completedDraft = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + phase: "published", + providerMessageId: sent.id, + }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, telegramDraft.actionId), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'phase' = 'final_sending'`, + ), + ) + .returning({ id: chatActions.id }); + if (completedDraft.length !== 1) + throw new Error( + "Telegram final draft ownership changed before commit", + ); + } + if (slackFileReceiptActionId) { + const [completedReceipt] = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + code: "slack_file_upload_identity_confirmed", + attempts: 1, + }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, slackFileReceiptActionId), + eq(chatActions.kind, "slack_file_upload_receipt"), + eq(chatActions.status, "received"), + sql`(${chatActions.payload}->>'publicationId')::uuid = ${publication.id}`, + sql`(${chatActions.payload}->>'publicationAttempt')::int = ${publication.attempts + 1}`, + ), + ) + .returning({ id: chatActions.id }); + if (!completedReceipt) { + throw new Error( + "Slack file upload receipt ownership changed before commit", + ); + } + } + if (slackFileReceiptActionId) { + await stageSlackFilePublicationSuccess(tx, { + committedAt, + endpoint: authorizationClaim.endpoint, + publication, + providerMessageId: sent.id, + runtimeContext: currentPublicationRuntimeContext, + stageSessionSync: + !isExplicitOperatorPublication(publication) && + !publication.idempotencyKey.startsWith("control:status:"), + }); + } else { + // A queue notice references its causal inbound comment for + // authorization, but is not an authored copy of that comment. + // Reactions/readers must not attribute bot status to the user. + const linkedCommentId = parseInboundWakePublicationKey( + publication.idempotencyKey, + ) + ? null + : publication.commentId; + const messageLinkInsert = tx.insert(chatMessageLinks).values({ + companyId: publication.companyId, + endpointId: publication.endpointId, + conversationId: publication.conversationId, + publicationId: publication.id, + commentId: linkedCommentId, + providerMessageId: sent.id, + direction: "outbound", + }); + if (replaceProviderMessageId) { + await messageLinkInsert.onConflictDoUpdate({ + target: [ + chatMessageLinks.endpointId, + chatMessageLinks.conversationId, + chatMessageLinks.providerMessageId, + ], + set: { + publicationId: publication.id, + commentId: linkedCommentId, + }, + }); + } else { + await messageLinkInsert.onConflictDoNothing(); + } + await tx + .update(chatEndpoints) + .set({ + lastPublicationAt: committedAt, + updatedAt: committedAt, + }) + .where( + and( + eq(chatEndpoints.id, authorizationClaim.endpoint.id), + inArray(chatEndpoints.status, ["verifying", "active"]), + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${currentPublicationRuntimeContext.generation}`, + ), + ); + } + if (authorizationActionId) { + const processedAuthorization = await tx + .update(chatActions) + .set({ + status: "processed", + result: { code: "task_control_authorized_and_sent" }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, authorizationActionId), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${publication.attempts + 1}`, + ), + ) + .returning({ id: chatActions.id }); + if (!processedAuthorization.length) { + throw new Error( + "Task-control authorization ownership changed before commit", + ); + } + } + await commitTaskControlCompletion( + tx as unknown as Db, + publication, + committedAt, + ); + if ( + !slackFileReceiptActionId && + authorizationClaim.endpoint.provider === "slack" && + !isExplicitOperatorPublication(publication) && + !parseInboundWakePublicationKey(publication.idempotencyKey) && + !publication.idempotencyKey.startsWith("control:status:") + ) { + await stageSlackSessionSync(tx, { + companyId: publication.companyId, + endpointId: publication.endpointId, + conversationId: publication.conversationId, + runtimeGeneration: + currentPublicationRuntimeContext.generation, + credentialFingerprint: + currentPublicationRuntimeContext.credentialFingerprint, + }); + } + receiptRemovalActionIds.push( + ...(await stageTerminalReceiptReactionRemovals( + tx as unknown as Db, + { + endpoint: authorizationClaim.endpoint, + publication, + payload, + runtimeContext: currentPublicationRuntimeContext, + closedProgressRunId: closedProgress?.runId, + }, + )), + ); + await credentialLease.assertOwned(tx); + }); + // Receipt cleanup is non-critical provider I/O. Dispatch it + // after the terminal reply and durable action commit so a + // slow reaction endpoint cannot hold the publication lane. + // If this process stops first, the action outbox resumes the + // idempotent removal on a later reconciliation sweep. + for (const actionId of receiptRemovalActionIds) { + scheduleMessageProcessing(() => + processReceiptReaction(actionId), + ); + } + } catch (error) { + // Authentication, membership, and destination failures mutate + // endpoint/runtime state. Settle them before releasing the + // credential lease so a concurrent reconnect or provider + // recovery cannot be overwritten by this stale attempt. + failureHandledWithinCredentialLease = true; + // If ownership was already lost, leave the publication in its + // conservative streaming state for quarantine. Mutating health + // from this obsolete runtime would overwrite the new owner. + await settlePublicationFailure(error, credentialLease); + } + }, + ); + } catch (error) { + if (!failureHandledWithinCredentialLease) + await settlePublicationFailure(error); + } + }); + } + return { processSelectedPublication, stageTerminalReceiptReactionRemovals }; +} diff --git a/server/src/services/chat-channels/runtime-types.ts b/server/src/services/chat-channels/runtime-types.ts index 809d5050e0c..c0c9dce4631 100644 --- a/server/src/services/chat-channels/runtime-types.ts +++ b/server/src/services/chat-channels/runtime-types.ts @@ -1,3 +1,5 @@ +import type { Message, Thread } from "chat"; +import type { ChatSdkMessageCallbackEvent } from "../chat-sdk-runtime.js"; import type { Db, chatEndpoints } from "@paperclipai/db"; import type { ToolCredentialSecretRef, ChatEndpointSetupState, ChatEndpointCallbackSurfaces } from "@paperclipai/shared"; import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; @@ -66,3 +68,26 @@ export type PreparedProviderInventory = { inventory: import("../chat-provider-inventory.js").ChatProviderInventoryResult | null; managementUrl?: string; }; + +export type LiveInboundMessage = { + endpoint: EndpointRow; + message: Message; + receiptReactionSupported: boolean; + providerUrl: string | null; + runtimeContext?: InboundRuntimeContext; + thread: Thread; + trigger: ChatSdkMessageCallbackEvent["trigger"]; +}; + +export type DiscordGatewayOwnership = { + companyId: string; + context: RuntimeContext; + endpointId: string; + expiresAt: Date; + leaseKey: "discord_gateway_runtime" | "photon_receiver_runtime"; + renewTimer: ReturnType | null; + renewal: Promise | null; + stopPromise: Promise | null; + stopping: boolean; + token: string; +}; diff --git a/server/src/services/chat-channels/sdk-state.ts b/server/src/services/chat-channels/sdk-state.ts new file mode 100644 index 00000000000..5fb2f86db91 --- /dev/null +++ b/server/src/services/chat-channels/sdk-state.ts @@ -0,0 +1,88 @@ +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatSdkState, +} from "@paperclipai/db"; + +import type { + ChatSdkStateCompareAndSetInput, + ChatSdkStateDeleteInput, + ChatSdkStatePersistence, + ChatSdkStateScope, +} from "../chat-sdk-state.js"; + +export function createChatSdkStatePersistence(db: Db): ChatSdkStatePersistence { + return { + async read(scope: ChatSdkStateScope, key: string) { + return db + .select({ + value: chatSdkState.value, + version: chatSdkState.version, + expiresAt: chatSdkState.expiresAt, + }) + .from(chatSdkState) + .where( + and( + eq(chatSdkState.companyId, scope.companyId), + eq(chatSdkState.endpointId, scope.endpointId), + eq(chatSdkState.stateKey, key), + ), + ) + .then((rows) => rows[0] ?? null); + }, + async compareAndSet(input: ChatSdkStateCompareAndSetInput) { + if (input.expectedVersion === null) { + const inserted = await db + .insert(chatSdkState) + .values({ + companyId: input.companyId, + endpointId: input.endpointId, + stateKey: input.key, + version: 1, + value: input.value, + expiresAt: input.expiresAt, + }) + .onConflictDoNothing() + .returning({ id: chatSdkState.id }); + return inserted.length === 1; + } + const updated = await db + .update(chatSdkState) + .set({ + version: input.expectedVersion + 1, + value: input.value, + expiresAt: input.expiresAt, + updatedAt: new Date(), + }) + .where( + and( + eq(chatSdkState.companyId, input.companyId), + eq(chatSdkState.endpointId, input.endpointId), + eq(chatSdkState.stateKey, input.key), + eq(chatSdkState.version, input.expectedVersion), + ), + ) + .returning({ id: chatSdkState.id }); + return updated.length === 1; + }, + async deleteIfVersion(input: ChatSdkStateDeleteInput) { + const deleted = await db + .delete(chatSdkState) + .where( + and( + eq(chatSdkState.companyId, input.companyId), + eq(chatSdkState.endpointId, input.endpointId), + eq(chatSdkState.stateKey, input.key), + eq(chatSdkState.version, input.expectedVersion), + ), + ) + .returning({ id: chatSdkState.id }); + return deleted.length === 1; + }, + }; +} diff --git a/server/src/services/chat-channels/slack/setup.ts b/server/src/services/chat-channels/slack/setup.ts new file mode 100644 index 00000000000..3f442d11a1d --- /dev/null +++ b/server/src/services/chat-channels/slack/setup.ts @@ -0,0 +1,89 @@ +import { slackCommandForAgent } from "./commands.js"; +import { canonicalCallbackUrl, slackCallbackMatchesPublicUrl } from "./callback-verification.js"; +import type { EndpointRow, SlackCallbackSurface, InternalSetupState } from "../runtime-types.js"; + +import type { + ChatEndpointCallbackSurfaces, +} from "@paperclipai/shared"; + +export function slackSetupState(input: { + endpoint: Pick< + EndpointRow, + | "provider" + | "providerAccountId" + | "publicId" + | "status" + | "setup" + | "botUsername" + >; + webhookUrl: string | null; + step: EndpointRow["setup"]["step"]; + assignedAgentName?: string | null; +}) { + const { endpoint, webhookUrl, step, assignedAgentName } = input; + + const observations = (endpoint.setup as InternalSetupState) + .slackCallbackSurfaces; + const currentUrl = webhookUrl ? canonicalCallbackUrl(webhookUrl) : null; + const surfaceState = ( + surface: SlackCallbackSurface, + ): ChatEndpointCallbackSurfaces[SlackCallbackSurface] => { + const observation = observations?.[surface]; + if (!observation) return { status: "unverified" }; + return { + status: + currentUrl !== null && + slackCallbackMatchesPublicUrl(observation.url, currentUrl) + ? "current" + : "stale", + observedAt: observation.observedAt, + }; + }; + const callbackSurfaces: ChatEndpointCallbackSurfaces = { + events: surfaceState("events"), + interactivity: surfaceState("interactivity"), + slashCommands: surfaceState("slashCommands"), + }; + return { + step, + testStartedAt: endpoint.setup.testStartedAt ?? null, + testSkipped: endpoint.setup.testSkipped ?? false, + authorizationUrl: "https://api.slack.com/apps?new_app=1", + providerUrl: "https://app.slack.com/", + webhookUrl, + webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, + callbackSurfaces, + callbacksNeedUpdate: Object.values(callbackSurfaces).some( + (surface) => surface.status === "stale", + ), + slackApp: endpoint.setup.slackApp, + slackSetupMethod: endpoint.setup.slackSetupMethod, + slackAvatar: endpoint.setup.slackAvatar, + slackManagerError: endpoint.setup.slackManagerError, + slackAccount: endpoint.setup.slackAccount, + // Slack registers the slash command in the provider configuration. + // Keep that identity immutable when the assigned agent is renamed; + // deriving it remains only a compatibility path for older rows. + command: + typeof endpoint.setup.command === "string" + ? endpoint.setup.command + : assignedAgentName + ? slackCommandForAgent(assignedAgentName, endpoint.publicId) + : null, + } as const; + +} + +import type { ResolvedChatSdkProviderConfig } from "../../chat-sdk-runtime.js"; + +export function slackRuntimeConfig(endpoint: EndpointRow, userName: string, credentials: Record): ResolvedChatSdkProviderConfig { + return { + provider: "slack", + userName, + credentials: { + botToken: credentials.botToken, + signingSecret: credentials.signingSecret, + botUserId: endpoint.botExternalId ?? undefined, + }, + }; +} diff --git a/server/src/services/chat-channels/speko/setup.ts b/server/src/services/chat-channels/speko/setup.ts new file mode 100644 index 00000000000..cda78300620 --- /dev/null +++ b/server/src/services/chat-channels/speko/setup.ts @@ -0,0 +1,37 @@ +import type { EndpointRow } from "../runtime-types.js"; + +import { syncSpekoVoiceTools } from "../../voice/speko-agent-tools.js"; +import { configureSpekoSessionTools } from "../../voice/speko-tool-setup.js"; +import { createSpekoProvider } from "../../voice/speko-provider.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import type { Db } from "@paperclipai/db"; + +import { + forbidden, + unprocessable, +} from "../../../errors.js"; + +import type { DbTransaction } from "../runtime-types.js"; +type SpekoSetupOptions = { + fetchImpl: typeof globalThis.fetch; + getWebhookPublicBaseUrl: () => string | null; + allowLocalVoiceBoard?: boolean; +}; +export function createSpekoSetup(db: Db, options: SpekoSetupOptions) { + const { fetchImpl, getWebhookPublicBaseUrl } = options; + + async function installSpekoVoiceTools(tx: DbTransaction, endpoint: EndpointRow, actorUserId?: string | null) { + if (!actorUserId) throw forbidden("An authenticated connection owner is required to install phone tools"); + await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); + } + + async function configureSpekoTools(endpoint: EndpointRow, credentials: Record, credentialLease: CredentialMutationLeaseGuard, previousSpekoSigningSecret: string | undefined) { + const webhookPublicBaseUrl = getWebhookPublicBaseUrl(); + if (!webhookPublicBaseUrl?.startsWith("https://")) throw unprocessable("Speko needs an explicitly configured public HTTPS callback origin"); + await configureSpekoSessionTools(db, { companyId: endpoint.companyId, endpointId: endpoint.id, agentId: credentials.agentId, callbackUrl: `${webhookPublicBaseUrl}/api/voice-webhooks/${endpoint.publicId}/tools`, signingSecret: credentials.signingSecret, previousSigningSecret: previousSpekoSigningSecret, client: createSpekoProvider(credentials.apiKey, fetchImpl), assertOwned: () => credentialLease.assertOwned() }); + } + + return { installSpekoVoiceTools, configureSpekoTools }; +} diff --git a/server/src/services/chat-channels/telegram/confirmation-actions.ts b/server/src/services/chat-channels/telegram/confirmation-actions.ts new file mode 100644 index 00000000000..8e5c9971b8f --- /dev/null +++ b/server/src/services/chat-channels/telegram/confirmation-actions.ts @@ -0,0 +1,656 @@ +import type { DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { + and, + asc, + eq, + inArray, + ne, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpoints, + chatExternalPrincipals, + chatPublications, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; + +import { + type ChatSdkCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { projectSafeChatPublication } from "../../chat-publication-projection.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; + +import type { EndpointRecord } from "../runtime-types.js"; +import type { ActionEvent } from "chat"; +import { nativeTelegramConfirmation } from "../../chat-interaction-publications.js"; +type TelegramConfirmationInput = { + record: EndpointRecord; + event: ChatSdkCallbackEvent; + runtimeContext: RuntimeContext; + conversation: typeof chatConversations.$inferSelect; + principal: { principal: typeof chatExternalPrincipals.$inferSelect; userId: string }; + issued: { publication: typeof chatPublications.$inferSelect }; + interaction: Awaited["listForIssue"]>>[number]; + deny: (known?: { conversationId?: string | null; principalId?: string | null }) => Promise; + safelyKnown: { conversationId: string; principalId: string }; +}; +type TelegramConfirmationOptions = { + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + scheduleMessageProcessing: (task: () => Promise) => void; + processPendingPublications: () => Promise; + confirmationResolutionPersistBarrier?: () => Promise; +}; +export function createTelegramConfirmationActions(db: Db, options: TelegramConfirmationOptions) { + const { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications } = options; + async function handleTelegramConfirmation(input: TelegramConfirmationInput) { + const { record, event, runtimeContext, conversation, principal, issued, interaction, deny, safelyKnown } = input; + + if ( + event.provider !== "telegram" || + !nativeTelegramConfirmation(interaction) + ) { + return deny(safelyKnown); + } + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.kind, "confirmation_response"), + eq(chatActions.providerActionId, event.event.actionId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!action || action.status !== "issued") return deny(safelyKnown); + const tokenPayload = action.payload; + const tokenExpiresAt = + typeof tokenPayload.expiresAt === "string" + ? Date.parse(tokenPayload.expiresAt) + : Number.NaN; + if (!Number.isFinite(tokenExpiresAt) || tokenExpiresAt <= Date.now()) { + await db + .update(chatActions) + .set({ + status: "expired", + result: { code: "confirmation_action_token_expired" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ); + return deny(safelyKnown); + } + if ( + tokenPayload.version !== 1 || + tokenPayload.publicationId !== issued.publication.id || + tokenPayload.interactionId !== interaction.id || + (tokenPayload.decision !== "accept" && + tokenPayload.decision !== "reject") + ) { + return deny(safelyKnown); + } + + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + goalId: issues.goalId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + }) + .from(issues) + .where( + and( + eq(issues.companyId, record.endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!issue) { + return deny(safelyKnown); + } + + const decision = tokenPayload.decision; + try { + if (decision === "accept") { + await issueThreadInteractionService(db).acceptInteraction( + issue, + interaction.id, + {}, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + }, + afterResolveInTransaction: async (tx, resolved) => { + const [owned] = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processing", + payload: { + ...tokenPayload, + messageId: event.event.messageId, + actionId: event.event.actionId, + value: null, + }, + result: { + code: "confirmation_resolution_committed_by_provider", + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!owned) { + throw new Error( + "External confirmation action ownership changed before interaction commit", + ); + } + }, + }, + ); + } else { + await issueThreadInteractionService(db).rejectInteraction( + issue, + interaction.id, + {}, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: record.endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext, + }); + }, + afterResolveInTransaction: async (tx, resolved) => { + const [owned] = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processing", + payload: { + ...tokenPayload, + messageId: event.event.messageId, + actionId: event.event.actionId, + value: null, + }, + result: { + code: "confirmation_resolution_committed_by_provider", + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!owned) { + throw new Error( + "External confirmation action ownership changed before interaction commit", + ); + } + }, + }, + ); + } + } catch (error) { + if (isExternalActionAuthorizationChange(error)) { + return deny(safelyKnown); + } + const currentStatus = await db + .select({ status: issueThreadInteractions.status }) + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interaction.id)) + .then((rows) => rows[0]?.status ?? null); + if (currentStatus !== null && currentStatus !== "pending") { + await settleTerminalConfirmationAction(action.id); + const completedAction = await db + .select({ status: chatActions.status, result: chatActions.result }) + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + if ( + completedAction?.status === "processed" && + completedAction.result?.code === + "confirmation_resolution_committed_by_provider" + ) { + return; + } + return deny(safelyKnown); + } + throw error; + } + + await options.confirmationResolutionPersistBarrier?.(); + if (!(await settleTerminalConfirmationAction(action.id))) { + throw new Error( + "External confirmation action ownership changed before settlement", + ); + } + + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + return; + + } + + + async function settleTerminalConfirmationAction( + actionId: string, + ): Promise { + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "confirmation_response"), + ), + ) + .then((rows) => rows[0] ?? null); + if (action?.status === "processed") return true; + if (!action || action.status !== "processing" || !action.conversationId) + return false; + const token = action.payload; + if ( + token.version !== 1 || + typeof token.publicationId !== "string" || + typeof token.interactionId !== "string" || + (token.decision !== "accept" && token.decision !== "reject") + ) { + await db + .update(chatActions) + .set({ + status: "failed", + result: { code: "confirmation_action_payload_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ); + return false; + } + const [conversation, publication, endpoint] = await Promise.all([ + db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, action.companyId), + eq(chatConversations.endpointId, action.endpointId), + eq(chatConversations.id, action.conversationId), + ), + ) + .then((rows) => rows[0] ?? null), + db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, action.companyId), + eq(chatPublications.endpointId, action.endpointId), + eq(chatPublications.id, token.publicationId as string), + ), + ) + .then((rows) => rows[0] ?? null), + db + .select() + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.companyId, action.companyId), + eq(chatEndpoints.id, action.endpointId), + ), + ) + .then((rows) => rows[0] ?? null), + ]); + if ( + !conversation || + !publication || + !endpoint || + endpoint.provider !== "telegram" || + publication.conversationId !== conversation.id || + publication.payload.interactionId !== token.interactionId + ) { + await db + .update(chatActions) + .set({ + status: "failed", + result: { code: "confirmation_action_binding_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ); + return false; + } + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === token.interactionId); + if ( + !interaction || + interaction.kind !== "request_confirmation" || + !nativeTelegramConfirmation(interaction) + ) { + await db + .update(chatActions) + .set({ + status: "failed", + result: { code: "confirmation_interaction_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ); + return false; + } + if ( + interaction.status !== "accepted" && + interaction.status !== "rejected" + ) { + if (interaction.status === "pending") return false; + const [retired] = await db + .update(chatActions) + .set({ + status: "expired", + result: { + code: "confirmation_interaction_resolved_elsewhere", + interactionId: interaction.id, + interactionStatus: interaction.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ) + .returning({ id: chatActions.id }); + if (retired) return true; + const winner = await db + .select({ status: chatActions.status }) + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + return winner?.status === "expired" || winner?.status === "processed"; + } + const decisionMatches = + (token.decision === "accept" && interaction.status === "accepted") || + (token.decision === "reject" && interaction.status === "rejected"); + if (!decisionMatches) { + await db + .update(chatActions) + .set({ + status: "expired", + result: { code: "interaction_resolved_by_sibling_action" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ); + return false; + } + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + assigneeAgentId: issues.assigneeAgentId, + status: issues.status, + }) + .from(issues) + .where( + and( + eq(issues.companyId, action.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!issue || !interaction.resolvedByUserId) { + await db + .update(chatActions) + .set({ + status: "failed", + result: { code: "confirmation_resolution_context_missing" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ); + return false; + } + const resolvedByUserId = interaction.resolvedByUserId; + const shouldWake = + Boolean(issue.assigneeAgentId) && + issue.status !== "done" && + issue.status !== "cancelled" && + (interaction.continuationPolicy === "wake_assignee" || + (interaction.continuationPolicy === "wake_assignee_on_accept" && + interaction.status === "accepted")); + const resolutionLabel = + interaction.status === "accepted" ? "Accepted" : "Rejected"; + const resolvedByThisProviderAction = + action.result?.code === "confirmation_resolution_committed_by_provider"; + return db.transaction(async (tx) => { + const [completed] = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + code: resolvedByThisProviderAction + ? "confirmation_resolution_committed_by_provider" + : "confirmation_resolution_reconciled", + interactionId: interaction.id, + interactionStatus: interaction.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + ), + ) + .returning({ id: chatActions.id }); + if (!completed) { + // A publication sweep may reconcile the durable action while the + // original provider callback is still in flight. Treat that CAS loss + // as success once the winner has processed the same action so the + // provider does not receive a spurious 5xx and redeliver it. + const winner = await tx + .select({ status: chatActions.status }) + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + return winner?.status === "processed"; + } + await tx + .update(chatActions) + .set({ + status: "expired", + result: { code: "interaction_resolved_by_sibling_action" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.companyId, issue.companyId), + eq(chatActions.endpointId, action.endpointId), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.kind, "confirmation_response"), + inArray(chatActions.status, ["issued", "processing"]), + ne(chatActions.id, action.id), + eq( + sql`${chatActions.payload}->>'interactionId'`, + interaction.id, + ), + ), + ); + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: issue.companyId, + endpointId: action.endpointId, + conversationId: conversation.id, + issueId: issue.id, + idempotencyKey: `interaction-resolution:${interaction.id}:${action.endpointId}`, + payload: projectSafeChatPublication({ + classification: "external", + source: "issue_interaction", + text: `${resolutionLabel}: ${interaction.payload.prompt}`, + interaction: { + id: interaction.id, + card: { + kind: "confirmation", + title: interaction.payload.prompt, + body: resolutionLabel, + actions: [], + }, + }, + }), + state: "pending", + }) + .onConflictDoNothing(); + if (shouldWake && issue.assigneeAgentId) { + await tx + .insert(chatActions) + .values({ + companyId: issue.companyId, + endpointId: action.endpointId, + conversationId: conversation.id, + principalId: action.principalId, + kind: "interaction_wakeup", + providerActionId: `interaction_wakeup:${interaction.id}`, + payload: { + version: 1, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + issueId: issue.id, + agentId: issue.assigneeAgentId, + sourceCommentId: interaction.sourceCommentId ?? null, + sourceRunId: interaction.sourceRunId ?? null, + requestedByUserId: resolvedByUserId, + requestedByActorType: "user", + requestedByActorId: resolvedByUserId, + }, + status: "issued", + }) + .onConflictDoNothing(); + } + // A provider callback can lose the pending -> terminal interaction race + // to the Paperclip UI. In that case this transaction only retires the + // now-redundant provider action and must not fabricate a second external + // resolution event attributed to the board winner. + if (resolvedByThisProviderAction) { + await logActivity(tx as unknown as Db, { + companyId: issue.companyId, + actorType: "user", + actorId: resolvedByUserId, + action: + interaction.status === "accepted" + ? "issue.thread_interaction_accepted" + : "issue.thread_interaction_rejected", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + endpointId: action.endpointId, + provider: endpoint.provider, + conversationId: conversation.id, + publicationId: publication.id, + providerMessageId: publication.providerMessageId, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + resolutionActorKind: "user", + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, + }, + }); + } + return true; + }); + } + + async function reconcileTerminalConfirmationActions( + limit = 100, + ): Promise { + const candidates = await db + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.kind, "confirmation_response"), + eq(chatActions.status, "processing"), + ), + ) + .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) + .limit(limit); + let settled = 0; + for (const candidate of candidates) { + if (await settleTerminalConfirmationAction(candidate.id)) settled += 1; + } + return settled; + } + return { settleTerminalConfirmationAction, reconcileTerminalConfirmationActions, handleTelegramConfirmation }; +} diff --git a/server/src/services/chat-channels/telegram/destination-admission.ts b/server/src/services/chat-channels/telegram/destination-admission.ts new file mode 100644 index 00000000000..03edf2a6b4b --- /dev/null +++ b/server/src/services/chat-channels/telegram/destination-admission.ts @@ -0,0 +1,62 @@ +import type { Thread } from "chat"; +import { and, eq, ne, sql } from "drizzle-orm"; +import { chatEndpointResources } from "@paperclipai/db"; +import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js"; +import { nonDirectDestinationAllowed } from "../inbound/surfaces.js"; + +type DestinationAdmissionInput = { + endpoint: EndpointRow; + currentEndpoint: Pick; + thread: Thread; + addressed: boolean; +}; +type DestinationAdmissionOptions = { + ensureResource: (endpoint: EndpointRow, thread: Thread, enableForSetup: boolean, database?: DbOrTransaction) => Promise; +}; +export function createTelegramDestinationAdmission(options: DestinationAdmissionOptions) { + const { ensureResource } = options; + async function admitTelegramDestination(input: DestinationAdmissionInput, tx: DbTransaction) { + const { endpoint, currentEndpoint, thread, addressed } = input; + let destinationAccepting = true; + + let resource = await ensureResource(endpoint, thread, false, tx); + const enabledResourceCount = await tx + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + ne(chatEndpointResources.type, "direct_message"), + ne(chatEndpointResources.type, "group_chat"), + ), + ) + .then((rows) => rows[0]?.count ?? 0); + const canActivateFirstSetupGroup = + currentEndpoint.status === "verifying" && + resource.availability === "available" && + enabledResourceCount === 0 && + addressed; + if (canActivateFirstSetupGroup && !resource.enabled) { + resource = await tx + .update(chatEndpointResources) + .set({ enabled: true, updatedAt: new Date() }) + .where(eq(chatEndpointResources.id, resource.id)) + .returning() + .then((rows) => rows[0] ?? resource); + } + // Telegram's privacy-mode contract is explicit: in a group or forum + // topic, every admitted turn must address the bot. A direct reply to a + // bot message is normalized as a mention by the pinned adapter, while + // unrelated subscribed traffic remains unaddressed and is filtered. + destinationAccepting = + addressed && + nonDirectDestinationAllowed( + { ...endpoint, allowGroupChats: currentEndpoint.allowGroupChats }, + resource, + ); + return destinationAccepting; + } + return { admitTelegramDestination }; +} diff --git a/server/src/services/chat-channels/telegram/publication-preparation.ts b/server/src/services/chat-channels/telegram/publication-preparation.ts new file mode 100644 index 00000000000..638a63d8cd3 --- /dev/null +++ b/server/src/services/chat-channels/telegram/publication-preparation.ts @@ -0,0 +1,192 @@ +import { createChatPublicationReplacement } from "../publication-replacement.js"; +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; +import { + eq, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + chatPublications, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + splitTelegramPublicationText, + telegramMarkdownRequiresAttachment, +} from "../../chat-publication-stream.js"; +type PublicationPreparationOptions = { + runPublicationToReplace: ReturnType["runPublicationToReplace"]; +}; +export function createTelegramPublicationPreparation(db: Db, options: PublicationPreparationOptions) { + const { runPublicationToReplace } = options; + async function prepareTelegramPublication(publication: typeof chatPublications.$inferSelect, persisted: SafeChatPublicationPayload): Promise {if ( + persisted.transportPart || + persisted.card || + persisted.interactionId || + persisted.attachmentIds?.length + ) { + return publication; + } + if (telegramMarkdownRequiresAttachment(persisted.text)) { + // Telegram parses each message independently. A fixed-size split can + // turn the second half of a code fence, link, or list into unrelated + // plain text even though concatenating the source parts is lossless. + // Preserve structured long Markdown as one document. When this is a run + // final, first complete its existing progress placeholder, then send the + // file through a separate ordered outbox row because Telegram cannot add + // an attachment while editing that placeholder. + const replacesProgressMessage = Boolean( + await runPublicationToReplace(publication, persisted), + ); + return db.transaction(async (tx) => { + const current = await tx + .select() + .from(chatPublications) + .where(eq(chatPublications.id, publication.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) return publication; + const currentPayload = current.payload as SafeChatPublicationPayload; + if ( + currentPayload.transportPart || + currentPayload.card || + currentPayload.interactionId || + currentPayload.attachmentIds?.length || + !["pending", "retry"].includes(current.state) || + !telegramMarkdownRequiresAttachment(currentPayload.text) + ) { + return current; + } + + const batchId = current.id; + const updatedAt = new Date(); + const transportPart = ( + index: number, + count: number, + mode: "inline" | "telegram_markdown_attachment", + ) => ({ + batchId, + count, + index, + mode, + orderKey: `${batchId}:${String(index).padStart(4, "0")}`, + }); + if (!replacesProgressMessage) { + const attachmentPayload: SafeChatPublicationPayload = { + ...currentPayload, + transportPart: transportPart(0, 1, "telegram_markdown_attachment"), + }; + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: attachmentPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + return { ...current, payload: attachmentPayload, updatedAt }; + } + + const handoffPayload: SafeChatPublicationPayload = { + ...currentPayload, + text: "This response needs a separate attachment because it exceeds the message limit.", + transportPart: transportPart(0, 2, "inline"), + }; + delete handoffPayload.attachmentIds; + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: handoffPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + + const attachmentPayload: SafeChatPublicationPayload = { + ...currentPayload, + transportPart: transportPart(1, 2, "telegram_markdown_attachment"), + }; + delete attachmentPayload.progressState; + await notifyChatPublicationWork(tx); + await tx.insert(chatPublications).values({ + companyId: current.companyId, + endpointId: current.endpointId, + conversationId: current.conversationId, + issueId: current.issueId, + commentId: current.commentId, + idempotencyKey: `telegram-markdown-attachment:${current.id}`, + payload: attachmentPayload, + state: "pending", + createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, + updatedAt, + }); + return { ...current, payload: handoffPayload, updatedAt }; + }); + } + const parts = splitTelegramPublicationText(persisted.text); + if (parts.length === 1) return publication; + + return db.transaction(async (tx) => { + const current = await tx + .select() + .from(chatPublications) + .where(eq(chatPublications.id, publication.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) return publication; + const currentPayload = current.payload as SafeChatPublicationPayload; + if ( + currentPayload.transportPart || + currentPayload.card || + currentPayload.interactionId || + currentPayload.attachmentIds?.length || + !["pending", "retry"].includes(current.state) + ) { + return current; + } + const currentParts = splitTelegramPublicationText(currentPayload.text); + if (currentParts.length === 1) return current; + + const batchId = current.id; + const updatedAt = new Date(); + const transportPart = (index: number) => ({ + batchId, + count: currentParts.length, + index, + orderKey: `${batchId}:${String(index).padStart(4, "0")}`, + }); + const rootPayload: SafeChatPublicationPayload = { + ...currentPayload, + text: currentParts[0]!, + transportPart: transportPart(0), + }; + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ payload: rootPayload, updatedAt }) + .where(eq(chatPublications.id, current.id)); + + const { progressState: _progressState, ...nonProgressPayload } = + currentPayload; + await notifyChatPublicationWork(tx); + await tx.insert(chatPublications).values( + currentParts.slice(1).map((text, offset) => { + const index = offset + 1; + return { + companyId: current.companyId, + endpointId: current.endpointId, + conversationId: current.conversationId, + issueId: current.issueId, + commentId: current.commentId, + idempotencyKey: `${current.idempotencyKey}:telegram-part:${index}`, + payload: { + ...nonProgressPayload, + text, + transportPart: transportPart(index), + }, + state: "pending" as const, + createdAt: sql`(select ${chatPublications.createdAt} from ${chatPublications} where ${chatPublications.id} = ${current.id})`, + updatedAt, + }; + }), + ); + return { ...current, payload: rootPayload, updatedAt }; + });} + return { prepareTelegramPublication }; +} diff --git a/server/src/services/chat-channels/telegram/setup.ts b/server/src/services/chat-channels/telegram/setup.ts new file mode 100644 index 00000000000..5e297103629 --- /dev/null +++ b/server/src/services/chat-channels/telegram/setup.ts @@ -0,0 +1,110 @@ +import { createTelegramMaintenance } from "./maintenance.js"; +import type { EndpointRow, RuntimeContext } from "../runtime-types.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import type { Db } from "@paperclipai/db"; + +import type { + ConfigureChatEndpointInput, +} from "@paperclipai/shared"; + +import { + unprocessable, +} from "../../../errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type TelegramSetupOptions = { + fetchImpl: typeof globalThis.fetch; + getWebhookPublicBaseUrl: () => string | null; + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + recordTelegramStopSubscription: ReturnType["recordTelegramStopSubscription"]; + stageTelegramMaintenance: ReturnType["stageTelegramMaintenance"]; + processTelegramMaintenance: ReturnType["processTelegramMaintenance"]; +}; +export function createTelegramSetup(db: Db, options: TelegramSetupOptions) { + const { fetchImpl, getWebhookPublicBaseUrl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, runtimeContextForRecord, recordTelegramStopSubscription, stageTelegramMaintenance, processTelegramMaintenance } = options; + + async function registerTelegramWebhook(endpoint: EndpointRow, input: ConfigureChatEndpointInput, credentials: Record, credentialLease: CredentialMutationLeaseGuard, next: EndpointRecord) { + const webhookUrl = `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/telegram`; + const infoResponse = await fetchImpl( + `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getWebhookInfo`, + { signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) }, + ); + const infoResult = (await infoResponse.json()) as { + ok?: boolean; + description?: string; + result?: { url?: string }; + }; + if (!infoResponse.ok || !infoResult.ok) { + throw unprocessable( + `Telegram could not inspect the existing webhook: ${infoResult.description ?? infoResponse.status}`, + ); + } + await credentialLease.assertOwned(); + const response = await fetchImpl( + `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/setWebhook`, + { + method: "POST", + signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + url: webhookUrl, + secret_token: credentials.webhookSecret, + allowed_updates: [ + "message", + "edited_message", + "callback_query", + "stopped_message_generation", + "message_reaction", + "my_chat_member", + ], + // A first-time connection may inherit stale updates from a bot + // that was used before Paperclip owned it. Reconnect is + // different: Telegram may be holding legitimate updates while + // the old callback URL is unavailable. Preserve that backlog + // even when an operator is rotating the public origin. + drop_pending_updates: input.action === "configure", + }), + }, + ); + const result = (await response.json()) as { + ok?: boolean; + description?: string; + result?: unknown; + }; + if (!response.ok || !result.ok) + throw unprocessable( + `Telegram could not register the webhook: ${result.description ?? response.status}`, + ); + await credentialLease.assertOwned(); + if (result.ok === true && result.result === true) { + // Telegram's successful boolean receipt acknowledges the exact URL, + // secret and allowed_updates request above. GET alone cannot prove + // secret_token, which Telegram deliberately does not return. + await recordTelegramStopSubscription( + next.endpoint, + runtimeContextForRecord(next), + credentialLease, + ); + } + // Command discovery is optional for ingress, but its provider mutation + // is still a durable, auditable action. A transient or rate-limited + // failure is retried by the maintenance worker without blocking the + // otherwise-valid webhook connection. + const commandAction = await stageTelegramMaintenance( + endpoint, + "register_commands", + ); + if (commandAction) + await processTelegramMaintenance( + commandAction.id, + false, + credentialLease, + ); + } + + return { registerTelegramWebhook }; +} From fc3b15537b00f7cd50329926e24ea0c20487fad5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:34:15 -0500 Subject: [PATCH 05/85] fix(runner): match call_api to existing agent REST permissions (#16040) Route native call_api through existing agent REST permissions. Preserve admitted credentials across restarts, protect sensitive replies and private snapshots, and compare saved completion with provider history through the same redaction projection. Add permission, privacy and recovery regressions and update the access map and run-log guide. Co-Authored-By: Paperclip --- doc/run-log-events.md | 27 +- doc/runner-api-tools.md | 72 ++-- maps/README.md | 7 +- maps/agent-access-map/README.md | 40 ++ maps/agent-access-map/access.md | 8 +- maps/agent-access-map/agents.md | 12 +- maps/agent-access-map/chat-channels.md | 8 +- maps/agent-access-map/companies.md | 12 +- maps/agent-access-map/gateways-profiles.md | 17 +- maps/agent-access-map/pipelines.md | 9 +- maps/agent-access-map/recovery.md | 23 +- maps/agent-access-map/routines.md | 35 +- maps/agent-access-map/runs-adapters.md | 17 +- maps/agent-access-map/secrets.md | 29 +- maps/agent-access-map/skills.md | 13 +- maps/agent-access-map/steering.md | 8 +- maps/agent-access-map/task-coordination.md | 11 +- maps/agent-access-map/tasks.md | 23 +- maps/agent-access-map/workspaces.md | 13 +- .../capability/semantic-tool-contracts.json | 2 +- .../generated/semantic-action-catalog.json | 2 +- .../evals/native-execution-seeded.json | 2 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../src/backends/harness-driver-backend.ts | 1 + .../src/contracts/control-plane-port.ts | 1 + .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-session-backend.ts | 1 + .../codex/codex-app-server-driver-impl.ts | 2 + .../src/drivers/codex/codex-session-state.ts | 4 + .../drivers/codex/codex-session-terminal.ts | 21 +- .../src/native-session-runtime.test.ts | 53 +++ .../src/native-session-runtime.ts | 4 +- .../src/protocol-actions/call-api.ts | 4 +- .../src/__tests__/company-portability.test.ts | 56 ++- .../__tests__/heartbeat-task-context.test.ts | 2 + .../src/__tests__/runner-api-recovery.test.ts | 42 ++ server/src/routes/assets.ts | 41 +- server/src/services/asset-content-access.ts | 59 +++ server/src/services/companies.ts | 6 +- server/src/services/company-portability.ts | 9 +- server/src/services/heartbeat.ts | 2 +- server/src/services/heartbeat/task-context.ts | 11 + ...nfirmation-comment-api.integration.test.ts | 12 +- .../paperclip-control-plane-port.test.ts | 47 +++ .../paperclip-control-plane-port.ts | 10 +- .../paperclip-runner-tool-authority.ts | 53 ++- .../native-runtime/runner-api-catalog.ts | 14 +- .../native-runtime/runner-api-client.ts | 41 +- .../runner-api-log-redaction.test.ts | 35 ++ .../runner-api-log-redaction.ts | 40 ++ .../native-runtime/runner-api-policy.ts | 66 ---- .../runner-api-sensitive-response.ts | 8 + .../runner-api.integration.test.ts | 359 ++++++++++++++++-- .../native-runtime/runner-api.test.ts | 223 ++++------- 54 files changed, 1118 insertions(+), 502 deletions(-) create mode 100644 server/src/__tests__/runner-api-recovery.test.ts create mode 100644 server/src/services/asset-content-access.ts create mode 100644 server/src/services/native-runtime/runner-api-log-redaction.test.ts create mode 100644 server/src/services/native-runtime/runner-api-log-redaction.ts delete mode 100644 server/src/services/native-runtime/runner-api-policy.ts create mode 100644 server/src/services/native-runtime/runner-api-sensitive-response.ts diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 7c9080d96c6..28472e42ccf 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -16,15 +16,38 @@ a system warning in the task conversation. It remains local run-log data. The hidden native coordinator writes each validated PRP event to the bound run's existing event stream before it acknowledges the runner. The row keeps the PRP `eventType`, source instance, source event ID, source sequence, protocol -schema version, and a SHA-256 digest of the canonical source envelope. Its +schema version, and a SHA-256 digest of the canonical stored envelope. Its payload is `{ "prpEvent": }`. +Native API tool events omit HTTP request and response bodies from durable history. +This includes serialized provider `contentItems`, legacy HTTP receipts and +`call_api` arguments. Saved receipts retain the operation and call IDs, success +flag, HTTP status and content type when present. The body becomes +`***REDACTED***`, with `apiPayloadRedacted: true` and `apiPayloadSha256`, a SHA-256 +digest of the original payload. These digests distinguish duplicate events from +changed payloads; they cannot recover omitted bodies for debugging or replay. +The model receives the original authorized response. Credential-bearing mutation +receipts use encrypted storage separately from this run log. + +Checkpoints and completion records apply the same redaction. Completion retries +compare stored and expected payloads through the control-plane port's idempotent +`normalizeReplayPayload` projection, so a saved redacted result can be confirmed +without restoring API bodies. Ports without a projection compare their original +payloads. Repeated redaction preserves the original digest and detects changed +private payloads. + +The native runtime passes this projection through backend recovery to the Codex +driver. Recovery compares saved semantic results and turn fingerprints with +provider history through the same representation. A private body change retains +its distinct digest and fails recovery; an unchanged body does not require +restoring its plaintext into the checkpoint. + PostgreSQL JSONB cannot represent NUL (U+0000), which can occur in command output such as Vite virtual-module paths. The run-event payload column uses a lossless storage codec for these events: the JSONB projection renders NUL as the literal `\u0000`, and the reserved `$paperclipRunEventJsonV1` field contains the original serialized JSON as a doubly escaped string. Ordinary payloads -retain their existing representation. Drizzle reads restore the exact original +retain their existing representation. Drizzle reads restore the exact stored payload before replay, hash validation, redaction, or API presentation. SQL queries can still inspect ordinary routing fields in the projection; raw SQL readers of the whole payload must apply `decodeRunEventPayload`. The column diff --git a/doc/runner-api-tools.md b/doc/runner-api-tools.md index a8dd849d49e..9ad9cd7acac 100644 --- a/doc/runner-api-tools.md +++ b/doc/runner-api-tools.md @@ -3,7 +3,7 @@ `search_api` and `call_api` extend the native runner when an available dedicated 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. +task’s timer. The monitor workflow remains preferred; REST calls use the same route authorization as legacy agents. 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 @@ -28,7 +28,7 @@ preparing any files. Existing dedicated tools remain available. Operators must update the environment of each server process and restart it for deployment-level changes; this environment switch is not a live settings API. -All company, run, work-mode, credential, and lifecycle checks below still apply. +All company, run, work-mode, credential, and route authorization checks below still apply. ## Discovery and requests @@ -64,7 +64,10 @@ inspect its state rather than retrying it to obtain a smaller response. Connection setup and stalled response reads time out after 30 seconds. An active capture has a 10-minute total download deadline. Responses above 24 KiB stream -into a private temporary file, then into a company-owned asset. Memory stays +into a private temporary file, then into a company-owned asset scoped to the +issuing agent and responsible user (plus authorized board administrators). +Ordinary task or run visibility does not grant access to another actor’s API +snapshot. Explicit deliverables use the existing attachment/work-product flow. Memory stays bounded by the inline prefix and stream buffers. Binary responses also become assets; text previews are limited to 2,000 bytes. @@ -163,23 +166,25 @@ failed to return a valid response. The server revalidates the active native run, assigned task and actor, then creates a server-held agent JWT bound to that company and run. Requests go through the actual HTTP router with its authorization, validation and domain -audit behavior. An additional `runner.api_called` receipt attributes mutations +audit behavior. Skill-test runs retain the same task-scoped `skill_test` JWT +as legacy REST runs, including after later edits to the task mode. An additional `runner.api_called` receipt attributes mutations to the run even where older route audit events omit that field. The run and work mode are checked again after asynchronous file preparation, so a stopped run cannot dispatch an upload prepared under its earlier binding. -Ask and pre-acceptance Plan permit reads through the escape hatch. Existing -dedicated-tool exceptions are unchanged. Runner-owned checkout, completion, -status/assignment transitions, approval decisions and execution-control actions -cannot be bypassed through generic calls. Routine creation, schedule/trigger -changes and manual/public routine execution require the existing scheduling -clients. Direct workspace runtime commands, runtime-slot stop/restart, case -automation retries and skill test-run controls also require their existing -execution clients. Gateway session credentials cannot enter generic results. -Routine metadata remains readable; annotation threads, comments and thread -resolution remain available through the fallback. API-only ordinary fields, such as a -task's `billingCode`, remain accessible even when a dedicated tool covers other -fields on that endpoint. +`call_api` is an authenticated REST transport, with the same endpoint and payload +permissions as a legacy agent using REST. It does not maintain a second denylist +for routines, runtime services, skill tests, recovery, invitations, secrets, +exports, lifecycle fields or approval routes. Existing HTTP routes enforce their +company/resource scope, ownership, grants, redaction, run requirements and board +or approval gates. A board-only route still denies an agent call. Dedicated tools +remain preferred for common workflows, including native completion and monitors. + +Ask and pre-acceptance Plan retain their existing read restrictions and specific +mutation exceptions (task titles and conversational confirmation recording). +Protocol endpoints still require their existing clients. Active-run validation, +server-owned credentials, file containment, transfer bounds and replay handling +also remain in place. REST parity does not grant board identity or new permissions. Mutation call IDs reserve a durable receipt in the run's existing `resultJson` before dispatch. Replays return the recorded result. Reusing an ID with different @@ -190,13 +195,34 @@ run. No database migration is needed. Workspace uploads use the existing workspace resource containment checks, no-symlink file opens covering every path component, and bounded descriptor reads. Local uploads require Linux or macOS; authorized artifacts work on other hosts. -Lifecycle-sensitive endpoints require an inline JSON object, so a raw uploaded -JSON file cannot hide protected fields from policy checks. Artifacts must belong -to the bound company. Secret-value access, credential management, secret proposals -and company exports require their existing secure clients. Search describes these -operations as restricted. `call_api` rejects them before creating a replay receipt -or making an HTTP request. Safe secret metadata listing remains available. -Agent credentials are never returned to the model. Streaming, WebSocket, MCP and authentication +Artifacts must belong to the bound company and pass the same actor access checks +as downloads, before and after reading their bytes. Private API response snapshots +cannot be used as public company logos or bundled into company exports. An owner +can explicitly publish a copy through the existing attachment upload path. +Request bodies and uploads go +through the route's validation and authorization; the fallback does not reject +fields that an agent can supply through REST. Route-authorized responses normally use the bounded inline/artifact capture and +replay mechanism. Credential-bearing responses (secret values, issued keys, +invites, webhook secrets (including restored triggers) and gateway credentials) stay inline, with a 10 MiB +response bound. They never spill into plaintext files or saved response assets. +Their durable mutation receipts encrypt the response with the existing local +secret provider. Replays decrypt it only inside the authorized run; no HTTP +request repeats. A working secret store is checked before credential dispatch. +Other API responses retain the existing 24 KiB inline and 1 GiB capture limits. + +API request and response bodies are omitted from persisted native tool events, +checkpoints and completion results. Saved events retain operation identity, HTTP +status and a payload digest for replay conflict detection. The model still receives +the original authorized HTTP response; this redaction affects durable history. +Completion retries compare expected and persisted events through the same +idempotent redaction projection. Repeated projection preserves the original +payload digest. Provider recovery uses the same projection to compare saved +completion results with provider history. Changed private bodies still conflict. +Restart preparation preserves the run's admitted REST credential scope, even +when the task mode changes. See [the run-log guide](run-log-events.md) for stored fields. + +The server-issued REST credential for the bound run is never returned to the model. +Streaming, WebSocket, MCP and authentication handshakes are documented as protocol operations requiring their existing clients. ## Catalog maintenance diff --git a/maps/README.md b/maps/README.md index 5946460d2d4..d747280d101 100644 --- a/maps/README.md +++ b/maps/README.md @@ -90,14 +90,15 @@ Review: `operation-reviewed`. ### `create` API: `present` — POST /api/companies/{companyId}/routines. -Agent access: `partial` — agent REST and CLI routine create; default native -mutation binding missing, and call_api blocks routine mutations. +Agent access: `available` — agent REST, CLI routine create and native call_api +under the enabled rollout and active-run/mode gates. Policy: same company; assigneeAgentId must be the calling agent; cite the routine authorization and native API policy sources. Evidence: `source-only` — reviewed commit and source links; no live proof. Verification: `not-run` — create an owned routine; reject assignment to another agent; inspect saved configuration, attribution and retry behavior. -Gaps: default native create path; verify using the real permitted agent context. +Gaps: verify ownership and responsible-user resolution with a permitted agent +context; source presence alone does not qualify this operation. ``` ## Keeping the maps synchronized diff --git a/maps/agent-access-map/README.md b/maps/agent-access-map/README.md index 9b8a94ab47e..b8d6986494d 100644 --- a/maps/agent-access-map/README.md +++ b/maps/agent-access-map/README.md @@ -67,3 +67,43 @@ Use `verify-paperclip` and existing API/runner/MCP fixtures to collect the permitted-agent and denial evidence; report blocked/not-run coverage honestly. Run `node scripts/check-maps.mjs` after editing. The structural check does not certify permissions, API catalog coverage, tool admission or live behavior. + +## Native REST transport + +The `call_api` parity change reconciles affected baselines against +`58e16dbddfc9c8d0cf60867f9d9847f0feda055b` and the changes in this PR. Ordinary +REST operations use the same route authorization as legacy agent REST. Dedicated +tools remain optional for those operations; protocol endpoints still need their +existing clients. Board-only routes remain restricted for agent identity. + +Native `search_api` / `call_api` requires operator rollout admission, a running +authorized native run, current task assignment and execution-run binding, company +and budget authority, task privacy, and a permitted work mode. Ask and Plan retain +mutation restrictions, and runs retain their admitted JWT scope during restart +recovery even when task mode changes. A waiting +agent does not gain independent native control just because a REST route exists. + +Credential-bearing replies, including company exports and their legacy alias, +stay inline under a 10 MiB limit and use encrypted mutation receipts. Other saved +API snapshots are limited to the issuing actor and responsible user, plus authorized +administrators. Artifact uploads enforce the same access checks as downloads; +private snapshots are excluded from company exports and public company logos. +Explicit deliverables use the existing sharing path. Native event history, +checkpoints and completion records omit API bodies while preserving receipt +metadata and replay digests. Restored webhook secrets use the same encrypted +receipt and inline response policy as newly created webhook secrets. +Task image access requires the company boundary and the task permission; +skill-test and low-trust agents do not need company-wide browsing authority. +Completion replay applies the same idempotent projection to stored and expected +payloads; [the run-log guide](../../doc/run-log-events.md) describes its fields. + +Sources: [API client](../../server/src/services/native-runtime/runner-api-client.ts), +[catalog](../../server/src/services/native-runtime/runner-api-catalog.ts), +[native authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), +[credential storage classification](../../server/src/services/native-runtime/runner-api-sensitive-response.ts), +[snapshot access](../../server/src/services/asset-content-access.ts), +and [history redaction](../../server/src/services/native-runtime/runner-api-log-redaction.ts). The affected entries remain +`baseline` and `source-only`; this reconciliation does not certify every operation. +Targeted real-route proof lives in the +[API integration suite](../../server/src/services/native-runtime/runner-api.integration.test.ts), +including owned routines, skill tests, scoped secrets, proposals and private export replay. diff --git a/maps/agent-access-map/access.md b/maps/agent-access-map/access.md index 4382be3416c..c88ce491678 100644 --- a/maps/agent-access-map/access.md +++ b/maps/agent-access-map/access.md @@ -2,7 +2,9 @@ Product map: [Login, invitations, and access](../feature-map/access.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -43,8 +45,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/invites`. - `POST /api/invites/{token}/accept`. -Agent access: `partial` — REST with invite permission; CLI access; native call_api -blocks invite credentials. +Agent access: `partial` — REST / native `call_api` invite creation with the invite +permission; CLI access. Human acceptance and agent onboarding have distinct policies. Policy: Company access plus action-specific grants; instance administration requires a board administrator. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 46c3ee462a2..decd1fbe6d0 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -2,7 +2,9 @@ Product map: [Hiring, agent configuration, and organization](../feature-map/agents.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -97,8 +99,8 @@ API: `present` — representative operation IDs: - `POST /api/agents/{id}/resume`. - `POST /api/agents/{id}/terminate`. -Agent access: `partial` — REST resume under change grant; pause/terminate require board; -native call_api blocks all three. +Agent access: `partial` — REST / native `call_api` resume under agent_config:update +change-grant authorization; pause and termination remain board-only. Policy: Agent creation/configuration uses action grants, resource policies and change consent; some controls require a board actor. @@ -111,8 +113,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Resume is agent-eligible but lacks a default native binding. Pause and termination -are actor-boundary decisions. +Gaps: Resume has a generic native REST path. Pause and termination remain +enforced actor restrictions. ## `built-in` diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md index 70ad018cb28..58fe31ce533 100644 --- a/maps/agent-access-map/chat-channels.md +++ b/maps/agent-access-map/chat-channels.md @@ -2,7 +2,9 @@ Product map: [External chat and email conversations](../feature-map/chat-channels.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -53,8 +55,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: No impersonation path; native call_api additionally blocks identity-link -operations. +Gaps: Identity linking requires a signed-in board user, rollout admission and +provider checks at the HTTP route. Native `call_api` preserves these permissions. ## `conversation` diff --git a/maps/agent-access-map/companies.md b/maps/agent-access-map/companies.md index 0366a4cbf4e..85c41cefb79 100644 --- a/maps/agent-access-map/companies.md +++ b/maps/agent-access-map/companies.md @@ -2,7 +2,9 @@ Product map: [Companies and portability](../feature-map/companies.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -97,8 +99,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/imports/preview`. - `POST /api/companies/{companyId}/imports/apply`. -Agent access: `partial` — CEO REST / CLI company; native export restricted, eligible -same-company import uses fallback. +Agent access: `partial` — Same-company CEO REST / native `call_api` / CLI exports +and eligible same-company imports. Policy: Same-company CEO or board for identity/import operations; list/create/archive convenience endpoint is board-gated. @@ -111,5 +113,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Secure exports lack a default native binding; new-company/ZIP import flows have -different board and transport constraints. +Gaps: Exports use inline credential delivery and encrypted mutation receipts. +New-company/ZIP import flows retain their board and transport constraints. diff --git a/maps/agent-access-map/gateways-profiles.md b/maps/agent-access-map/gateways-profiles.md index de326b65acf..7ab04395a01 100644 --- a/maps/agent-access-map/gateways-profiles.md +++ b/maps/agent-access-map/gateways-profiles.md @@ -2,7 +2,9 @@ Product map: [Tool gateways and access profiles](../feature-map/gateways-profiles.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -65,8 +67,8 @@ API: `present` — representative operation IDs: - `GET /api/tool-gateway/gateways/{gatewayId}/mcp`. - `POST /api/tool-gateway/sessions`. -Agent access: `partial` — Existing scoped MCP client/session flow; assigned native app -tools. +Agent access: `partial` — Agent REST / native `call_api` for scoped session creation +and revocation; existing MCP client protocol and assigned native app tools. Policy: Named gateway/profile administration and credentials are board tools:admin; agent sessions/tool use use separate scoped authority. @@ -79,8 +81,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Agent tool consumption exists. Copying/managing the endpoint and its client -credentials remain management operations. +Gaps: Scoped sessions remain agent/run-bound. MCP requires its existing protocol +client; named gateways and gateway-token management retain board permissions. ## `tokens` @@ -103,8 +105,9 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Native call_api blocks credential management; a grant to use an app does not -create gateway-token authority. +Gaps: Gateway-token management requires board tools:admin at the HTTP route. +A grant to use an app does not create this authority; native `call_api` preserves +the denial. ## `activity` diff --git a/maps/agent-access-map/pipelines.md b/maps/agent-access-map/pipelines.md index b33ca8ce805..a9fea855343 100644 --- a/maps/agent-access-map/pipelines.md +++ b/maps/agent-access-map/pipelines.md @@ -2,7 +2,9 @@ Product map: [Pipelines, review queues, and learnings](../feature-map/pipelines.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -31,8 +33,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Configuration paths exist. Generic fallback blocks case automation execution -paths; do not conflate configuration and dispatch. +Gaps: Configuration and case-automation retry/rerun have native `call_api` REST +paths under company, active-agent-run, case and target-pipeline write checks. The +route and service retain retry/rerun eligibility and dispatch policy. ## `items` diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index db98fb8ef76..1b90fa083d4 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -2,7 +2,9 @@ Product map: [Recovery](../feature-map/recovery.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -42,7 +44,8 @@ API: `present` — representative operation IDs: - `POST /api/issues/{id}/recovery-actions/resolve`. -Agent access: `partial` — Eligible agent REST / CLI; native generic mutation restricted. +Agent access: `partial` — Eligible agent REST / native `call_api` / CLI under +source-bound recovery authority. Policy: Task/run visibility, run ownership and source/action policy; workspace export retry and some operator controls require board. @@ -55,8 +58,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Underlying route has agent checks; no corresponding default native recovery -mutation tool is advertised. +Gaps: Eligible resolutions use native `call_api`. The route retains owner and +management authority, active-run/source-trust checks, board-only outcomes and +reconciliation controls. ## `source-action` @@ -67,7 +71,8 @@ API: `present` — representative operation IDs: - `GET /api/issues/{id}/recovery-actions`. - `POST /api/issues/{id}/recovery-actions/resolve`. -Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state. +Agent access: `partial` — Eligible agent REST / native `call_api` / CLI; source-bound +recovery state. Policy: Task/run visibility, run ownership and source/action policy; workspace export retry and some operator controls require board. Completion or cancellation of a @@ -89,8 +94,8 @@ physical copyback, and cleanup after the receipt is cleared. Exercise this sequence in a disposable instance; automatic cleanup must use the system actor and must not create reader participation. No live recovery proof is claimed. -Gaps: Need operation-level eligibility and a real native binding for permitted recovery -actions; do not route around lifecycle authority. +Gaps: Permitted resolutions use native `call_api` under existing eligibility +and ownership checks. Source/action policy determines whether continuation is allowed. ## `exhaustion` @@ -139,8 +144,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Export retry/reconciliation APIs exist but require board. Native generic -execution-control mutations are also blocked. +Gaps: Export retry and branch reconciliation require board runtime:manage at +their HTTP routes. Native `call_api` preserves these denials. ## `unknown-outcome` diff --git a/maps/agent-access-map/routines.md b/maps/agent-access-map/routines.md index bb3ec03599a..8d0bb4ea889 100644 --- a/maps/agent-access-map/routines.md +++ b/maps/agent-access-map/routines.md @@ -2,7 +2,9 @@ Product map: [Routines, schedules, and triggers](../feature-map/routines.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -18,8 +20,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/routines`. - `PATCH /api/routines/{id}`. -Agent access: `partial` — Agent REST / CLI routine create/update; optional installed MCP -generic request. +Agent access: `available` — Agent REST / native `call_api` / CLI routine create/update; +optional installed MCP generic request. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -32,8 +34,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api rejects routine mutation; manage_routine is scenario-only/unbound. -Agent REST is implemented. +Gaps: No dedicated production manage_routine binding; generic `call_api` provides +the permitted REST path. Creation needs a responsible user resolved from the parent +issue or company default. ## `triggers` @@ -44,7 +47,8 @@ API: `present` — representative operation IDs: - `POST /api/routines/{id}/triggers`. - `PATCH /api/routine-triggers/{id}`. -Agent access: `partial` — Owned-routine agent REST / CLI routine trigger commands. +Agent access: `available` — Owned-routine agent REST / native `call_api` / CLI trigger +commands. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -57,8 +61,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: No production default native trigger-management binding. Webhook creation/rotation -also returns credential material. +Gaps: No dedicated production trigger-management binding; generic `call_api` +provides the permitted REST path. Credential replies use inline delivery and encrypted +mutation receipts rather than shared response assets. ## `run-now` @@ -68,7 +73,8 @@ API: `present` — representative operation IDs: - `POST /api/routines/{id}/run`. -Agent access: `partial` — Owned-routine agent REST / CLI routine run. +Agent access: `partial` — Owned-routine agent REST / CLI run; native `call_api` from +an authorized active run. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -81,8 +87,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: A scheduled/waiting native agent cannot use call_api for this eligible operation; -a real control binding is missing. +Gaps: Scheduled/waiting native agents cannot independently call this operation: +native authority requires a running bound run. A separate control binding remains absent. ## `history` @@ -117,8 +123,7 @@ API: `present` — representative operation IDs: - `PATCH /api/routines/{id}`. -Agent access: `partial` — Owned-routine agent REST / CLI; normal native fallback can -manage separate folder metadata where permitted. +Agent access: `available` — Owned-routine agent REST / native `call_api` / CLI. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -131,5 +136,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Pause/archive routine mutation lacks a production native binding. Plugin-managed -routine rules remain separate. +Gaps: Pause/archive mutations have a generic native REST path under routine +ownership rules. Plugin-managed routine rules remain separate. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 93c0adc6bd7..9316af9c19c 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -2,7 +2,9 @@ Product map: [Runs, harnesses, and model accounts](../feature-map/runs-adapters.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -23,8 +25,8 @@ API: `present` — representative operation IDs: - `GET /api/adapters`. - `GET /api/companies/{companyId}/adapters/{type}/models`. -Agent access: `partial` — Agent company-model API and bound runtime context; board -adapter/account inventories. +Agent access: `partial` — Agent company-model REST / native `call_api` discovery and +bound runtime context; adapter/account inventories remain board-only. Policy: Run/cost visibility and agent configuration policy; adapter installation/account login/session control require board/admin. @@ -48,8 +50,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/adapters/{type}/test-environment`. -Agent access: `partial` — Permitted agent-creation validation path / REST / CLI; -operator account login. +Agent access: `partial` — Permitted agent-creation validation through REST / native +`call_api` / CLI; account login requires its operator. Policy: Run/cost visibility and agent configuration policy; adapter installation/account login/session control require board/admin. @@ -112,8 +114,9 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Execution context reset is not a permitted generic native API mutation or an -ordinary agent management path. +Gaps: Task-session listing and execution-context reset require board agent +management at their HTTP routes. Native `call_api` preserves these denials; native +continuation remains server-owned. Scoped Evidence: `source-only` — the [native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) diff --git a/maps/agent-access-map/secrets.md b/maps/agent-access-map/secrets.md index 083c56da74c..de4a5426219 100644 --- a/maps/agent-access-map/secrets.md +++ b/maps/agent-access-map/secrets.md @@ -2,7 +2,9 @@ Product map: [Secrets, personal credentials, and proposals](../feature-map/secrets.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -18,8 +20,8 @@ API: `present` — representative operation IDs: - `GET /api/companies/{companyId}/secrets/catalog`. - `POST /api/companies/{companyId}/secrets`. -Agent access: `partial` — Agent metadata catalog; board secret administration; agent -secret proposal API. +Agent access: `partial` — Agent REST / native `call_api` metadata catalog and secret +proposals; company secret administration remains board-only. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -32,8 +34,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Metadata and requesting a change exist; generic native API blocks secret -management/value transport. +Gaps: Native transport preserves board-only administration. Bound value retrieval +is a separate scoped operation described under runtime-delivery. ## `user-secrets` @@ -69,8 +71,8 @@ API: `present` — representative operation IDs: - `POST /api/agents/me/secret-proposals`. - `GET /api/agents/me/secret-proposals`. -Agent access: `partial` — Active-run agent REST with redaction; board -approval/rejection. +Agent access: `partial` — Active-run agent REST / native `call_api` with redaction; +approval/rejection remains board-only. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -83,8 +85,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api blocks secret-proposals and no dedicated production proposal tool -is in its default authority set. +Gaps: No dedicated production proposal tool is required for the REST path. +Native calls retain active-run, scope and skill-test JWT restrictions. ## `vault-import` @@ -120,8 +122,8 @@ API: `present` — representative operation IDs: - `GET /api/agents/me/secrets`. - `POST /api/agents/me/secrets/{key}/value`. -Agent access: `partial` — Authorized environment/broker delivery; eligible legacy agent -REST access. +Agent access: `available` — Authorized environment/broker delivery and scoped agent +REST / native `call_api` value retrieval. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -134,5 +136,6 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native metadata reads exist; value call is blocked and scenario read_secret_value -is unbound. Do not add raw secret-returning fallback tools. +Gaps: Values remain restricted to the bound agent and active run. Credential +responses stay inline and use encrypted mutation receipts; they are not saved as +plaintext response assets. Dedicated scenario read_secret_value remains unbound. diff --git a/maps/agent-access-map/skills.md b/maps/agent-access-map/skills.md index b4ce67184c8..2231414834f 100644 --- a/maps/agent-access-map/skills.md +++ b/maps/agent-access-map/skills.md @@ -2,7 +2,9 @@ Product map: [Skill discovery, authoring, and policy](../feature-map/skills.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -122,8 +124,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/skills/{skillId}/test-runs`. -Agent access: `partial` — Permitted agent REST; native read fallback for history; no -dedicated CLI test command found. +Agent access: `available` — Permitted agent REST / native `call_api` creation, control +and history; no dedicated CLI test command found. Policy: Company skill policy by action/resource plus platform invariants; test execution additionally requires task-assignment authority. @@ -137,5 +139,6 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api rejects test-run creation/control while the route can authorize -agents. No default native orchestration binding. +Gaps: No dedicated native orchestration binding; generic REST calls retain +skills.test policy, task-assignment authorization, responsible-user intersection and +admitted skill-test JWT scope. diff --git a/maps/agent-access-map/steering.md b/maps/agent-access-map/steering.md index 0b872acd931..58035be531e 100644 --- a/maps/agent-access-map/steering.md +++ b/maps/agent-access-map/steering.md @@ -2,7 +2,9 @@ Product map: [Steering and queued messages](../feature-map/steering.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -80,8 +82,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Generic native API blocks this execution control; no authority to impersonate the -steering user. +Gaps: Steering requires a board user at the HTTP route. Native `call_api` +preserves this denial and cannot impersonate the steering user. ## `interrupt` diff --git a/maps/agent-access-map/task-coordination.md b/maps/agent-access-map/task-coordination.md index c32d5849d2c..20b33cb299b 100644 --- a/maps/agent-access-map/task-coordination.md +++ b/maps/agent-access-map/task-coordination.md @@ -3,7 +3,9 @@ Product map: [Delegation, dependencies, and signoff](../feature-map/task-coordination.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -43,8 +45,8 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. -Agent access: `available` — Native set_dependencies for current task; agent REST under -mutation/checkout policy. +Agent access: `available` — Native set_dependencies for the current task; agent REST +/ native `call_api` under mutation and checkout policy. Policy: Task visibility/mutation, task-assignment and ownership policy; review audience independent; tree controls board-only. @@ -58,7 +60,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Generic native PATCH rejects blockedByIssueIds so use its real dedicated tool. +Gaps: Dedicated set_dependencies is current-task scoped. Generic `call_api` +forwards blockedByIssueIds to the REST route and retains its ownership checks. ## `signoff` diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index b3272d30a74..cf040965c7f 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -2,7 +2,9 @@ Product map: [Task creation and lifecycle](../feature-map/tasks.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -67,8 +69,8 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. - `PUT /api/issues/{id}/title`. -Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies; fallback -for permitted non-lifecycle fields; REST. +Agent access: `available` — Native set_task_title/reassign_task/set_dependencies and +`call_api` for REST-authorized fields; agent REST. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership and lifecycle rules. A legacy run can assign its own task to a human without @@ -89,8 +91,8 @@ regressions in [ownership tests](../../server/src/__tests__/issue-agent-mutation cover a human handoff and stop enforcement for other actors, runtimes and targets; they use mocked services and do not claim live execution proof. -Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools -differ in scope and supported fields. +Gaps: Dedicated tools differ in scope and fields. Generic `call_api` forwards +status, assignment and dependency fields to existing route authorization. ## `discussion` @@ -115,8 +117,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Other-task comments and active-task progress have separate scopes; explicit -reopen/interrupt is not a generic comment capability. +Gaps: Other-task comments and active-task progress have separate scopes. Generic +`call_api` forwards explicit reopen/resume/interrupt fields to the existing route +checks; it cannot impersonate a user. ## `completion` @@ -127,7 +130,7 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. Agent access: `available` — Native paperclip_finish/paperclip_block through runner -protocol; legacy agent REST disposition. +protocol; agent REST / native `call_api` disposition under route permissions. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership and lifecycle rules. @@ -140,5 +143,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Server governs whether a reported result becomes done/review/blocked. A generic -lifecycle PATCH is intentionally rejected for native runs. +Gaps: The server governs whether a reported result becomes done/review/blocked. +Generic lifecycle PATCH uses existing REST authorization, checkout and approval rules. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 8cbd031ab22..bf3cafe1ed2 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -2,7 +2,9 @@ Product map: [Execution workspaces, services, and files](../feature-map/workspaces.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -119,8 +121,8 @@ API: `present` — representative operation IDs: - `POST /api/execution-workspaces/{id}/runtime-services/{action}`. -Agent access: `partial` — Authorized agent REST / CLI workspace runtime-service / -existing stdio MCP controls. +Agent access: `available` — Authorized agent REST / native `call_api` / CLI runtime +service controls and existing stdio MCP controls. Policy: Company/project/workspace visibility and runtime authority; active run, trust preset and task ownership where required. @@ -134,8 +136,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api blocks runtime-service mutation; control_workspace_service is not -in the production authority set. Optional MCP availability must be recorded. +Gaps: No dedicated production control_workspace_service binding. Generic native +calls retain runtime trust and workspace/task scope checks. Optional MCP availability +must be recorded. ## `files-git` diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index f9dc67e83f9..4231409b10d 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":{"not":{"required":["initialText"]},"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"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"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"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","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"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with 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; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","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"}},{"annotations":{"exposure":"optional","operationId":"get_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_workspaces","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_workspaces","outputSchema":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},{"annotations":{"exposure":"optional","operationId":"select_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.","inputSchema":{"additionalProperties":false,"properties":{"expectedBindingRevision":{"minimum":0,"type":"integer"},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"},"selection":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"task_directory"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"existing"},"workspaceId":{"description":"Existing authorized workspace ID.","minLength":1,"type":"string"}},"required":["kind","workspaceId"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"configured_source"},"mode":{"enum":["shared","managed_isolated"]},"projectWorkspaceId":{"description":"Configured source workspace ID.","minLength":1,"type":"string"}},"required":["kind","projectWorkspaceId","mode"],"type":"object"}]}},"required":["selection","expectedBindingRevision","requestKey"],"type":"object"},"name":"select_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"prepare_repository","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.","inputSchema":{"additionalProperties":false,"properties":{"ref":{"description":"Optional Git branch, tag, or commit. Default HEAD is pinned once.","minLength":1,"type":"string"},"repository":{"oneOf":[{"additionalProperties":false,"properties":{"id":{"description":"Authorized ID from list_project_repositories.","minLength":1,"type":"string"},"kind":{"const":"catalog"}},"required":["kind","id"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"url"},"url":{"description":"Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.","minLength":1,"type":"string"}},"required":["kind","url"],"type":"object"}]},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"}},"required":["repository","requestKey"],"type":"object"},"name":"prepare_repository","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":{"not":{"required":["initialText"]},"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"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"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"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","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"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with 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 the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. 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; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","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"}},{"annotations":{"exposure":"optional","operationId":"get_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_workspaces","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_workspaces","outputSchema":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},{"annotations":{"exposure":"optional","operationId":"select_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.","inputSchema":{"additionalProperties":false,"properties":{"expectedBindingRevision":{"minimum":0,"type":"integer"},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"},"selection":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"task_directory"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"existing"},"workspaceId":{"description":"Existing authorized workspace ID.","minLength":1,"type":"string"}},"required":["kind","workspaceId"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"configured_source"},"mode":{"enum":["shared","managed_isolated"]},"projectWorkspaceId":{"description":"Configured source workspace ID.","minLength":1,"type":"string"}},"required":["kind","projectWorkspaceId","mode"],"type":"object"}]}},"required":["selection","expectedBindingRevision","requestKey"],"type":"object"},"name":"select_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"prepare_repository","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.","inputSchema":{"additionalProperties":false,"properties":{"ref":{"description":"Optional Git branch, tag, or commit. Default HEAD is pinned once.","minLength":1,"type":"string"},"repository":{"oneOf":[{"additionalProperties":false,"properties":{"id":{"description":"Authorized ID from list_project_repositories.","minLength":1,"type":"string"},"kind":{"const":"catalog"}},"required":["kind","id"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"url"},"url":{"description":"Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.","minLength":1,"type":"string"}},"required":["kind","url"],"type":"object"}]},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"}},"required":["repository","requestKey"],"type":"object"},"name":"prepare_repository","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 e45555acc0e..5e979e4447c 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -112,7 +112,7 @@ "planning", "skill_test" ], - "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.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "effect": "write", "inputSchema": { "additionalProperties": false, 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 7a12f8c3b22..7063601981d 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:73f19996d9e1b46e957ba732354eb24daf4e96776ae121ea059acfaf01f56ef6", + "catalogSha256": "sha256:2de31fff50aed0a7b28e222084695cae5d7ba04612737881114edff0dbc2781c", "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 234c6e49139..1660a1367e1 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -175,7 +175,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef", + "sha256": "a564abe2d4dd502913250c93a0bbdd6dc06f2556efff6d93d577fcb9011e4a70", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index fcdb43e16b7..abda538f49b 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -154,6 +154,7 @@ export class HarnessDriverBackend implements NativeSessionBackend { }; const recoveryOptions: HarnessSessionRecoveryOptions = { signal: options.signal, + normalizeReplayPayload: options.normalizeReplayPayload, }; const recovered = await this.#driver.recoverSession( persisted, diff --git a/packages/paperclip-runner/src/contracts/control-plane-port.ts b/packages/paperclip-runner/src/contracts/control-plane-port.ts index d8773168683..b219c207b3a 100644 --- a/packages/paperclip-runner/src/contracts/control-plane-port.ts +++ b/packages/paperclip-runner/src/contracts/control-plane-port.ts @@ -73,6 +73,7 @@ export interface CompleteControlPlaneRunInput { * runner package never imports the server, UI, or database that sits behind it. */ export interface ControlPlanePort { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; openRun(input: OpenControlPlaneRunInput): Promise; loadSessionCheckpoint?(): Promise; checkpointSession?( diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index a520a146431..88340f94b80 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -57,6 +57,7 @@ export interface OpenHarnessSessionInput { } export interface HarnessSessionRecoveryOptions { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; /** Abort provider recovery and release any not-yet-returned provider state. */ signal: AbortSignal; } diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index 251d10748e7..94921f3e247 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -40,6 +40,7 @@ export interface OpenNativeSessionInput { } export interface NativeSessionRecoveryOptions { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; /** Abort provider recovery and release any not-yet-returned provider state. */ signal: AbortSignal; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 6f3b5ad8a76..a4f2ae1aa6e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -623,6 +623,7 @@ export class CodexAppServerDriver implements HarnessDriver { resumed: true, activeTurnId: recoveredActiveTurnId, semanticResult: snapshot.semanticResult ?? null, + normalizeReplayPayload: options.normalizeReplayPayload, terminalTurns: snapshot.terminalTurns ?? [], codexUsageBaseline: snapshot.codexUsageBaseline, dispositionOnlyRecoveryConsumed, @@ -923,6 +924,7 @@ export class CodexAppServerDriver implements HarnessDriver { resumed: boolean; activeTurnId?: string | null; semanticResult?: PersistedHarnessSemanticResult | null; + normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; terminalTurns?: PersistedHarnessTurnTerminal[]; codexUsageBaseline?: PersistedHarnessSession["codexUsageBaseline"]; dispositionOnlyRecoveryConsumed?: boolean; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts index 318ad4cc7b5..116516d9450 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts @@ -1,6 +1,7 @@ import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js"; import type { HarnessRuntimeRequest, + HarnessSessionRecoveryOptions, HarnessThreadGoal, HarnessThreadLineageEntry, PersistedHarnessSemanticResult, @@ -112,6 +113,7 @@ export class CodexSessionState { codexUsageBaseline: CodexUsageBaseline | null = null; result: PrpStructuredRunResult | null = null; resultFingerprint: string | null = null; + readonly normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; resultCallId: string | null = null; resultTurnId: string | null = null; turnStartPending = false; @@ -158,6 +160,7 @@ export class CodexSessionState { resumed: boolean; activeTurnId?: string | null; semanticResult?: PersistedHarnessSemanticResult | null; + normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; terminalTurns?: PersistedHarnessTurnTerminal[]; codexUsageBaseline?: CodexUsageBaseline; dispositionOnlyRecoveryConsumed?: boolean; @@ -214,6 +217,7 @@ export class CodexSessionState { structuredClone(input.opened.lineage), ); } + this.normalizeReplayPayload = input.normalizeReplayPayload; if (input.semanticResult) { const validation = validatePrpStructuredRunResult( input.semanticResult.result, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts index 47612b9267b..582730399aa 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts @@ -55,7 +55,10 @@ export function admitResult( ): SemanticResultAdmission { const fingerprint = canonicalJson(result); if (state.resultFingerprint !== null) { - return state.resultFingerprint === fingerprint ? "identical" : "conflict"; + const same = state.normalizeReplayPayload + ? replayFingerprint(state, JSON.parse(state.resultFingerprint)) === replayFingerprint(state, result) + : state.resultFingerprint === fingerprint; + return same ? "identical" : "conflict"; } state.result = structuredClone(result); state.resultFingerprint = fingerprint; @@ -178,6 +181,11 @@ export function mapTerminalTurn( } } +function replayFingerprint(state: CodexSessionState, value: unknown): string { + const payload = { value }; + return canonicalJson(state.normalizeReplayPayload?.(payload) ?? payload); +} + function terminalFingerprint( state: CodexSessionState, turn: Record, @@ -201,7 +209,9 @@ export function terminalReplayConflict( candidate !== null && state.resultFingerprint !== null && state.resultTurnId === turnId && - canonicalJson(candidate) !== state.resultFingerprint + (state.normalizeReplayPayload + ? replayFingerprint(state, candidate) !== replayFingerprint(state, JSON.parse(state.resultFingerprint)) + : canonicalJson(candidate) !== state.resultFingerprint) ) { return { code: "conflicting_semantic_result", @@ -211,9 +221,10 @@ export function terminalReplayConflict( const semanticResult = state.resultTurnId === turnId ? state.result : candidate; - if ( - terminalFingerprint(state, turn, semanticResult) !== expectedFingerprint - ) { + const actualFingerprint = terminalFingerprint(state, turn, semanticResult); + if (state.normalizeReplayPayload + ? replayFingerprint(state, JSON.parse(actualFingerprint)) !== replayFingerprint(state, JSON.parse(expectedFingerprint)) + : actualFingerprint !== expectedFingerprint) { return { code: "conflicting_turn_terminal", message: "changed from its committed terminal fingerprint", diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 610813ef9c6..ed0459c1231 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -7355,6 +7355,59 @@ describe("executeNativeSession recovery", () => { expect(completeRun).toHaveBeenCalledOnce(); }); + it.each([false, true])("confirms a redacted completion after a lost acknowledgement (conflict: %s)", async conflict => { + vi.useFakeTimers(); + try { + const completion = { ...result, summary: JSON.stringify({ operationId: "call_api", data: "private-fixture-value" }) }; + const history: PrpEvent[] = []; + const normalize = (payload: PrpEvent["payload"]): PrpEvent["payload"] => + JSON.parse(JSON.stringify(payload).replaceAll("private-fixture-value", "***REDACTED***")); + let committed!: () => void; + const saved = new Promise(resolve => { committed = resolve; }); + let lostAcknowledgement = false; + const completeRun = 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: "turn-recovery" }; }, + async *events() { yield runnerEvent(1, "turn.completed"); }, + async result() { return { result: completion, terminal, turnId: "turn-recovery" }; }, + async snapshot() { return { backendKind: "mock", sessionId: identity.sessionId, identity, activeTurnId: null }; }, + async close() {}, + }; + const port: ControlPlanePort = { + async openRun() {}, async checkpointSession() {}, normalizeReplayPayload: normalize, completeRun, + async appendEvent(event) { + const row = event as PrpEvent; + history.push({ ...row, payload: normalize(row.payload) }); + if (row.eventType === "run.result.accepted" && !lostAcknowledgement) { + lostAcknowledgement = true; + if (conflict) history.at(-1)!.payload = { ...history.at(-1)!.payload, changed: true }; + committed(); + return await new Promise(() => undefined); + } + return { cursor: history.length, highestContiguousSourceSeq: row.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 execution = executeNativeSession({ input, backend: { + async descriptor() { return { kind: "mock", name: "redacted-completion", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { return session; }, + }, controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 20 }); + const outcome = conflict ? expect(execution).rejects.toThrow("native_control_event_replay_conflict:1") + : expect(execution).resolves.toMatchObject({ result: completion }); + await saved; + await vi.advanceTimersByTimeAsync(25); + await outcome; + expect(completeRun).toHaveBeenCalledTimes(conflict ? 0 : 1); + expect(JSON.stringify(history)).not.toContain("private-fixture-value"); + expect(history.filter(event => event.eventType === "run.result.accepted")).toHaveLength(1); + } finally { vi.useRealTimers(); } + }); + it("keeps a reconstructed semantic result on its matched terminal turn", async () => { const semanticFingerprint = canonicalTestJson(result); const checkpoint: PersistedNativeSession = { diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index e01dd745e23..c2b223e3dfc 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2062,6 +2062,7 @@ export async function executeNativeSession( operation: (signal) => options.backend.recoverSession!(providerRecoveryCheckpoint, { signal, + normalizeReplayPayload: options.controlPlane.normalizeReplayPayload?.bind(options.controlPlane), }), onLateResolution: async (lateRecovery) => { if (lateRecovery.session) { @@ -2761,7 +2762,8 @@ export async function executeNativeSession( if ( expected === undefined || existing.eventType !== expected.eventType || - canonicalJson(existing.payload) !== canonicalJson(expected.payload) + canonicalJson(options.controlPlane.normalizeReplayPayload?.(existing.payload) ?? existing.payload) + !== canonicalJson(options.controlPlane.normalizeReplayPayload?.(expected.payload) ?? expected.payload) ) { throw new Error( `native_control_event_replay_conflict:${existing.sourceSeq}`, diff --git a/packages/paperclip-runner/src/protocol-actions/call-api.ts b/packages/paperclip-runner/src/protocol-actions/call-api.ts index 6ce1236ddb7..083d9131c10 100644 --- a/packages/paperclip-runner/src/protocol-actions/call-api.ts +++ b/packages/paperclip-runner/src/protocol-actions/call-api.ts @@ -28,7 +28,7 @@ export const callApiAction = { }, "documentation": { "title": "Call the Paperclip API", - "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.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "note": "Production HTTP fallback; does not grant privileges or replace dedicated tools." }, "examples": { @@ -51,7 +51,7 @@ export const callApiAction = { "operationId": "call_api", "version": 1, "title": "Call the Paperclip API", - "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.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "exposure": "optional", "requiredClaims": [ "api:call" diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 8d7f1ca34bf..bfd3cc30543 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; +import { runnerApiResponseReservations } from "@paperclipai/db"; const companySvc = { getById: vi.fn(), @@ -195,6 +196,13 @@ vi.mock("../routes/org-chart-svg.js", () => ({ const { companyPortabilityService, dedupeImportedCompanyName, parseGitHubSourceUrl, renderYamlBlock, renderFrontmatter } = await import("../services/company-portability.js"); +function responseAssetDb(reservations: Array<{ id: string }> = []) { + return { select: () => ({ from: (table: unknown) => ({ where: () => { + const rows = table === runnerApiResponseReservations ? reservations : []; + return Object.assign(Promise.resolve(rows), { limit: async () => rows }); + } }) }) }; +} + function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { expect(typeof entry).toBe("string"); return typeof entry === "string" ? entry : ""; @@ -1043,6 +1051,20 @@ describe("company portability", () => { expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined(); }); + it.each(["namespace", "reservation"])("excludes a private API snapshot used as a company logo (%s)", async kind => { + const storage = { getObject: vi.fn() }; + companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", issuePrefix: "PAP", logoAssetId: "private-logo" }); + assetSvc.getById.mockResolvedValue({ id: "private-logo", companyId: "company-1", + objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : "assets/companies/private-logo", contentType: "image/png" }); + const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []); + const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", { + include: { company: true, agents: false, projects: false, issues: false }, + }); + expect(storage.getObject).not.toHaveBeenCalled(); + expect(exported.files["images/company-logo.png"]).toBeUndefined(); + expect(exported.warnings).toContain("Skipped private API response snapshot used as a company logo."); + }); + it("exports the company logo into images/ and references it from .paperclip.yaml", async () => { const storage = { getObject: vi.fn().mockResolvedValue({ @@ -1066,7 +1088,7 @@ describe("company portability", () => { originalFilename: "logo.png", }); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const exported = await portability.exportBundle("company-1", { include: { @@ -3405,7 +3427,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const exported = await exportCompanyAndAgents(portability); exported.files["images/company-logo.png"] = { @@ -4622,7 +4644,7 @@ describe("company portability", () => { it("carries issue attachments as content-addressed blobs through export and import", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const sha = sha256Of("png-bytes"); @@ -4730,7 +4752,7 @@ describe("company portability", () => { it("skips all attachment imports with one warning when the target has no storage", async () => { const storage = fakeAttachmentStorage(); - const exporting = companyPortabilityService({} as any, storage as any); + const exporting = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const exported = await exporting.exportBundle("company-1", { include: { company: true, agents: false, projects: false, issues: true }, @@ -4756,7 +4778,7 @@ describe("company portability", () => { it("fails closed when a bundle blob does not match its declared sha256", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const sha = sha256Of("png-bytes"); @@ -4913,9 +4935,23 @@ describe("company portability", () => { )); } + it.each(["namespace", "reservation"])("excludes private API snapshots from embedded images (%s)", async kind => { + const storage = fakeAttachmentStorage(); + mockEmbeddedAssetExportSources(); + const asset = await assetSvc.getById(EMBEDDED_ASSET_ID); + assetSvc.getById.mockResolvedValue({ ...asset, objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : asset.objectKey }); + const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []); + const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", { + include: { company: true, agents: false, projects: false, issues: true }, + }); + expect(Object.keys(exported.files).filter(filePath => filePath.startsWith("blobs/"))).toEqual([]); + expect(storage.getObject).not.toHaveBeenCalled(); + expect(exported.warnings).toContainEqual(expect.stringContaining("private API response snapshot")); + }); + it("carries embedded asset images through export and import with rewritten references", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); @@ -4999,7 +5035,7 @@ describe("company portability", () => { it("skips embedded image references that are foreign or dangling with one aggregate warning", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const foreignAssetId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; const missingAssetId = "12345678-1234-4123-8123-123456789abc"; mockEmbeddedAssetExportSources( @@ -5033,13 +5069,13 @@ describe("company portability", () => { expect(asTextFile(exported.files[".paperclip.yaml"])).not.toContain("embeddedAssets:"); expect(exported.manifest.embeddedAssets).toEqual([]); expect(exported.warnings).toContain( - "2 embedded image references point at assets that do not belong to this company or no longer exist; their images were not exported.", + "2 embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.", ); }); it("leaves embedded image references untouched when their blob is missing at import", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); @@ -5073,7 +5109,7 @@ describe("company portability", () => { it("prunes embedded asset entries and blobs when the referencing files are excluded from the export selection", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); diff --git a/server/src/__tests__/heartbeat-task-context.test.ts b/server/src/__tests__/heartbeat-task-context.test.ts index bca00d2a403..e348b79ea71 100644 --- a/server/src/__tests__/heartbeat-task-context.test.ts +++ b/server/src/__tests__/heartbeat-task-context.test.ts @@ -89,6 +89,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => { wakeCommentId: commentId, wakeCommentIds: [commentId], paperclipTaskCommunicationGuidance: "FORGED caller guidance", paperclipSkillTest: { directive: "FORGED skill" }, paperclipContinuationSummary: { body: "FORGED summary" }, + paperclipAgentJwtScope: { kind: "skill_test", issueId: f.issueId }, }); expect(context.paperclipTaskMarkdown).toContain("Assignment description canary"); expect(context.paperclipTaskMarkdown).toContain("Wake comment canary"); @@ -103,6 +104,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => { } expect(context.paperclipTaskCommunicationGuidance).toBe(FAST_RESPONSE_AGENT_GUIDANCE); expect(context.paperclipSkillTest).toBeUndefined(); + expect(context.paperclipAgentJwtScope).toEqual({ kind: "standard" }); expect(context.paperclipContinuationSummary).toBeUndefined(); const digest = (value: string) => createHash("sha256").update(value).digest("hex"); expect(context.paperclipTurnContext).toMatchObject({ diff --git a/server/src/__tests__/runner-api-recovery.test.ts b/server/src/__tests__/runner-api-recovery.test.ts new file mode 100644 index 00000000000..fe45c3a6814 --- /dev/null +++ b/server/src/__tests__/runner-api-recovery.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { HarnessDriverBackend } from "../../../packages/paperclip-runner/src/backends/harness-driver-backend.js"; +import { FakeCodexTransport, makeDriver, result, WORKSPACE } from "../../../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.js"; +import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js"; + +describe("private API output in provider recovery", () => { + it.each([false, true])("compares active completion through the saved projection (changed=%s)", async changed => { + const first = new FakeCodexTransport(); + const second = new FakeCodexTransport(); + const backend = new HarnessDriverBackend(makeDriver([first, second])); + const identity = { runId: "api-recovery-run", sessionId: "api-recovery-session", companyId: "company", issueId: "issue", agentId: "agent" }; + const original = await backend.openSession({ identity, workingDirectory: WORKSPACE }); + await original.startTurn({ message: { role: "user", text: "Finish the task." } }); + const receipt = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "export", ok: true, status: 200, data: { credential: "private-recovery-value" } }; + const completion = { ...result, summary: JSON.stringify(receipt) }; + expect(await first.invoke({ id: "finish", method: "item/tool/call", params: { + threadId: "thread-1", turnId: "turn-1", callId: "finish", tool: "paperclip_finish", arguments: completion, + } })).toMatchObject({ success: true }); + const raw = await original.snapshot(); + expect(raw.activeTurnId).toBe("turn-1"); + expect(raw.semanticResult).toEqual(completion); + const checkpoint = redactRunnerApiPayloads(raw); + expect(JSON.stringify(checkpoint)).not.toContain("private-recovery-value"); + expect(JSON.stringify(checkpoint)).toContain("apiPayloadSha256"); + await original.close({ reason: "simulate restart before the terminal checkpoint" }); + const providerResult = changed ? { ...completion, summary: JSON.stringify({ ...receipt, data: { credential: "changed-value" } }) } : completion; + second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, + turns: [{ id: "turn-1", status: "completed", items: [{ id: "finish", type: "agentMessage", text: JSON.stringify(providerResult) }] }], + } }; + const recovered = await backend.recoverSession(checkpoint, { + signal: new AbortController().signal, normalizeReplayPayload: redactRunnerApiPayloads, + }); + if (changed) { + expect(recovered).toMatchObject({ recovered: false, reason: expect.stringContaining("conflicting") }); + } else { + expect(recovered.recovered).toBe(true); + expect((await recovered.session!.snapshot()).semanticResult).toEqual(checkpoint.semanticResult); + await recovered.session!.close({ reason: "verified recovery" }); + } + expect(second.calls.some(call => call.method === "turn/start")).toBe(false); + }); +}); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 11fe0eb8e68..5f48886baa3 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -1,13 +1,10 @@ -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 { canActorReadAssetContent } from "../services/asset-content-access.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, isUuidLike, createAssetImageMetadataSchema } from "@paperclipai/shared"; +import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { @@ -331,41 +328,9 @@ 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)) { + if (!(await canActorReadAssetContent(db, req.actor, asset))) { 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. diff --git a/server/src/services/asset-content-access.ts b/server/src/services/asset-content-access.ts new file mode 100644 index 00000000000..0fa83f066ed --- /dev/null +++ b/server/src/services/asset-content-access.ts @@ -0,0 +1,59 @@ +import { and, eq } from "drizzle-orm"; +import { assets, heartbeatRuns, issues, issueAttachments, runnerApiResponseReservations, type Db } from "@paperclipai/db"; +import { isUuidLike } from "@paperclipai/shared"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { canActorReadHeartbeatRun } from "./heartbeat-run-privacy.js"; + +type Asset = Pick; + +function namespace(asset: Asset) { + const parts = asset.objectKey.split("/"); + return parts[1] === "assets" ? parts[2] : parts[1]; +} + +export async function isRunnerApiResponseAsset(db: Pick, asset: Asset): Promise { + if (namespace(asset) === "runner-api") return true; + return !!(await db.select({ id: runnerApiResponseReservations.id }).from(runnerApiResponseReservations) + .where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId))).limit(1))[0]; +} + +export async function canActorReadAssetContent(db: Db, actor: AuthorizationActor, asset: Asset): Promise { + const access = authorizationService(db); + if (actor.type === "agent") { + if (actor.companyId !== asset.companyId) return false; + } else if (actor.type !== "board" || !(await access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId: asset.companyId } })).allowed) return false; + const parts = asset.objectKey.split("/"); + const isDraft = parts[1] === "assets" && parts[2] === "issues" && parts[3] === "drafts"; + const sourceIssueId = parts[1] === "assets" ? parts[3] : parts[2]; + if (namespace(asset) === "issues" && !isDraft) { + if (!sourceIssueId || !isUuidLike(sourceIssueId)) return false; + const [issue] = await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, sourceIssueId), eq(issues.companyId, asset.companyId))).limit(1); + if (!issue || !(await access.decide({ actor, action: "issue:read", resource: { + type: "issue", companyId: asset.companyId, issueId: sourceIssueId, + } })).allowed) return false; + } + 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(asset) === "runner-api" && !captures.length) return false; + for (const { run } of captures) { + const owns = run && (actor.type === "agent" + ? actor.agentId === run.agentId && (!run.responsibleUserId || actor.onBehalfOfUserId === run.responsibleUserId) + : actor.type === "board" && (actor.source === "local_implicit" || actor.isInstanceAdmin + || (Boolean(actor.userId) && actor.userId === run.responsibleUserId))); + if (!owns || !run || !(await canActorReadHeartbeatRun(db, access, actor, run))) return false; + } + const attachments = await db.select({ issueId: issueAttachments.issueId }).from(issueAttachments) + .where(and(eq(issueAttachments.assetId, asset.id), eq(issueAttachments.companyId, asset.companyId))); + if (isDraft && !attachments.length && !(actor.type === "board" + ? actor.source === "local_implicit" || actor.isInstanceAdmin || actor.userId === asset.createdByUserId + : actor.type === "agent" && actor.agentId === asset.createdByAgentId + && (!asset.createdByUserId || actor.onBehalfOfUserId === asset.createdByUserId))) return false; + for (const attachment of attachments) { + if (!(await access.decide({ actor, action: "issue:read", resource: { + type: "issue", companyId: asset.companyId, issueId: attachment.issueId, + } })).allowed) return false; + } + return true; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 149b30b4fe9..06df82c6ee0 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -1,3 +1,4 @@ +import { isRunnerApiResponseAsset } from "./asset-content-access.js"; import { deleteCompany } from "./company-deletion.js"; import { createAgentLifecycle } from "./agent-lifecycle.js"; import { publishAccountingActivities } from "./accounting-transaction.js"; @@ -316,11 +317,14 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (logoAssetId !== undefined && logoAssetId !== null) { const nextLogoAsset = await tx - .select({ id: assets.id, companyId: assets.companyId }) + .select() .from(assets) .where(eq(assets.id, logoAssetId)) .then((rows) => rows[0] ?? null); if (!nextLogoAsset) throw notFound("Logo asset not found"); + if (await isRunnerApiResponseAsset(tx, nextLogoAsset)) { + throw unprocessable("API response snapshots cannot be company logos; upload a logo asset"); + } if (nextLogoAsset.companyId !== existing.id) { throw unprocessable("Logo asset must belong to the same company"); } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1d7b983f347..8dca564153b 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -80,6 +80,7 @@ import { accessService } from "./access.js"; import { agentService } from "./agents.js"; import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; import { assetService } from "./assets.js"; +import { isRunnerApiResponseAsset } from "./asset-content-access.js"; import { generateReadme } from "./company-export-readme.js"; import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js"; import { companySkillService } from "./company-skills.js"; @@ -4116,6 +4117,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const logoAsset = await assetRecords.getById(company.logoAssetId); if (!logoAsset) { warnings.push(`Skipped company logo ${company.logoAssetId} because the asset record was not found.`); + } else if (await isRunnerApiResponseAsset(db, logoAsset)) { + warnings.push("Skipped private API response snapshot used as a company logo."); } else { try { const object = await storage.getObject(company.id, logoAsset.objectKey); @@ -4659,7 +4662,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // Embedded references only pull bytes for assets owned by the // exporting company: a crafted URL naming another company's asset id // must not leak that asset's bytes into the bundle. - if (!asset || asset.companyId !== companyId) { + if (!asset || asset.companyId !== companyId || await isRunnerApiResponseAsset(db, asset)) { unownedEmbeddedAssetRefCount += 1; continue; } @@ -4690,8 +4693,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (unownedEmbeddedAssetRefCount > 0) { warnings.push(unownedEmbeddedAssetRefCount === 1 - ? "1 embedded image reference points at an asset that does not belong to this company or no longer exists; its image was not exported." - : `${unownedEmbeddedAssetRefCount} embedded image references point at assets that do not belong to this company or no longer exist; their images were not exported.`); + ? "1 embedded image reference points at an asset that is unavailable for export (missing, outside this company, or a private API response snapshot); its image was not exported." + : `${unownedEmbeddedAssetRefCount} embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.`); } const paperclipExtensionPath = ".paperclip.yaml"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c8df58d270a..4a6d6a32611 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2576,7 +2576,7 @@ export function heartbeatService( } = await prepareHeartbeatTaskContext(db, { run, agent, task: { issueRef, issueContext, wakeCommentContext, context, taskSession }, - policy: { trustPreset, experimentalInstanceSettings }, + policy: { trustPreset, experimentalInstanceSettings, recovering: Boolean(runOptions.nativeRestartRecovery || runOptions.nativeLeaseOwner) }, services: { issuesSvc, getPinnedSkillTestContext }, }); const sourceWorkspaceConfig = buildExecutionWorkspaceAdapterConfig({ diff --git a/server/src/services/heartbeat/task-context.ts b/server/src/services/heartbeat/task-context.ts index 56210bd00e1..d346d0eb17f 100644 --- a/server/src/services/heartbeat/task-context.ts +++ b/server/src/services/heartbeat/task-context.ts @@ -34,6 +34,7 @@ export interface HeartbeatTaskContextInput { taskSession: Pick | null; }; policy: { + recovering?: boolean; trustPreset: Pick; experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean }; }; @@ -79,6 +80,16 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo } else { delete context.paperclipContinuationSummary; } + const admittedScope = parseObject(context.paperclipAgentJwtScope); + if (!input.policy.recovering || (admittedScope.kind !== "standard" && admittedScope.kind !== "skill_test")) { + const savedIssue = parseObject(context.paperclipIssue); + const skillTest = input.policy.recovering + ? savedIssue.workMode === "skill_test" || Boolean(context.paperclipSkillTest) + : issueRef?.workMode === "skill_test"; + context.paperclipAgentJwtScope = skillTest && issueRef + ? { kind: "skill_test", issueId: issueRef.id } + : { kind: "standard" }; + } const pinnedSkillTestContext = issueRef?.workMode === "skill_test" ? await getPinnedSkillTestContext(agent.companyId, issueRef.id) diff --git a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts index fd497938cce..a85d6dd5277 100644 --- a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts +++ b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts @@ -6,7 +6,6 @@ import { issueComments, issueThreadInteractions, issues } from "@paperclipai/db" import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; -import { runnerApiMutationRestriction } from "./runner-api-policy.js"; describe("confirmation reply through the native API tool", () => { let server: Awaited>; @@ -52,10 +51,11 @@ describe("confirmation reply through the native API tool", () => { const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId); expect((await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ ...call.arguments.body, actorUserId: f.userId }) })).status).toBe(400); }); - it("does not open the ordinary interaction or governance mutation routes", () => { - for (const operation of ["accept", "reject", "respond", "verdicts", "withdraw"]) { - expect(runnerApiMutationRestriction(`/api/issues/{id}/interactions/{interactionId}/${operation}`)).not.toBeNull(); - } - expect(runnerApiMutationRestriction("/api/issues/{id}/interactions/{interactionId}/resolve-from-comment")).toBeNull(); + it("preserves formal approval authority in the HTTP route", async () => { + const { f } = await seed(); + expect(await f.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { + operationId: "POST /api/approvals/{id}/approve", pathParams: { id: f.approvalId }, body: {}, + } })).toMatchObject({ ok: false, status: 403 }); + expect((await f.snapshot()).approvals.find(row => row.id === f.approvalId)?.status).toBe("pending"); }); }); 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 9acb7e6a091..ff6807c6bfa 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 @@ -361,6 +361,53 @@ describe("PaperclipControlPlanePort conformance", () => { terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL })).resolves.toBeUndefined(); }); + it("omits API credentials from persisted tool events and checkpoints without changing wire results", async () => { + 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 observed: PrpEvent[] = []; + const port = new PaperclipControlPlanePort(db, { + ...identity, completionContractId: contractId, completionContractSha256: contractSha, + sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `api-${identity.runId}`, + }, { onCommittedEvent: async event => { observed.push(event); } }); + await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + const result = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "private-export", + status: 200, ok: true, data: { credentials: "private-history-fixture-value" } }; + const wireResult = { success: true, contentItems: [{ type: "inputText", text: JSON.stringify(result) }] }; + const event: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, eventType: "item.completed", schemaVersion: 1, + priority: 0, emittedAt: new Date().toISOString(), + payload: { kind: "dynamicToolCall", item: { type: "tool_result", id: "private-export", tool_use_id: "private-export", result: wireResult } }, + }; + await port.appendEvent(event); + expect((await port.appendEvent(event)).disposition).toBe("duplicate"); + expect(port.normalizeReplayPayload(event.payload)).toEqual(observed[0]?.payload); + expect(port.normalizeReplayPayload(observed[0]!.payload)).toEqual(observed[0]?.payload); + await expect(port.appendEvent({ ...event, payload: { ...event.payload, item: { + ...(event.payload.item as object), result: { success: true, contentItems: [{ type: "inputText", + text: JSON.stringify({ ...result, data: { credentials: "different-history-fixture-value" } }), + }] }, + } } })).rejects.toThrow(); + await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity, + semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: JSON.stringify(result) }, + }); + const persisted = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId)); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)); + for (const saved of [persisted, observed, run.runnerProfileJson]) { + expect(JSON.stringify(saved)).not.toContain("private-history-fixture-value"); + expect(JSON.stringify(saved)).toContain("apiPayloadRedacted"); + } + expect(result.data.credentials).toBe("private-history-fixture-value"); + }); + 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 1e7937edd6e..ecdc639e74c 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -27,6 +27,7 @@ import { import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { publishChatPublicationCommitSignal } from "../chat-publication-reconciliation.js"; import { nativeSha256 } from "./canonical.js"; +import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js"; export interface PaperclipControlPlaneBinding { companyId: string; @@ -172,9 +173,13 @@ export class PaperclipControlPlanePort implements ControlPlanePort { return structuredClone(snapshot as PersistedNativeSession); } + normalizeReplayPayload(payload: PrpEvent["payload"]): PrpEvent["payload"] { + return redactRunnerApiPayloads(this.#identityRedactor.redact(payload)); + } + async checkpointSession(snapshot: PersistedNativeSession, options?: { signal: AbortSignal }): Promise { this.#assertActive(options); - snapshot = this.#identityRedactor.redact(snapshot); + snapshot = redactRunnerApiPayloads(this.#identityRedactor.redact(snapshot)); const identity = snapshot.identity; if ( identity.companyId !== this.#binding.companyId @@ -251,6 +256,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } } } + event = redactRunnerApiPayloads(event); const persisted = await this.#db.transaction(async tx => { this.#assertActive(options); const receipt = await appendHeartbeatRunEvent(tx as unknown as Db, { @@ -331,7 +337,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput, options?: { signal: AbortSignal }): Promise { this.#assertActive(options); - value = this.#identityRedactor.redact(value); + value = redactRunnerApiPayloads(this.#identityRedactor.redact(value)); if (!isCompleteInput(value)) throw new Error("native_structured_result_required"); // Capture compatibility diagnostics before canonical validation removes // legacy/non-actionable attention payloads. They are operator evidence, 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 058500e8d8a..1e4c7cab6b8 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -42,6 +42,9 @@ import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; import { getStorageService } from "../../storage/index.js"; import type { StorageService } from "../../storage/types.js"; import { assetService } from "../assets.js"; +import { canActorReadAssetContent } from "../asset-content-access.js"; +import { getSecretProvider } from "../../secrets/provider-registry.js"; +import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { workspaceFileResourceService } from "../workspace-file-resources.js"; import { badRequest, forbidden, notFound, HttpError } from "../../errors.js"; import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js"; @@ -498,13 +501,13 @@ export class PaperclipRunnerToolAuthority { } case "create_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable"); return callCreateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId }); } case "update_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable"); return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId }); } @@ -521,7 +524,7 @@ export class PaperclipRunnerToolAuthority { case "list_project_repositories": case "list_projects": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable"); return callProjectTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId, @@ -703,14 +706,35 @@ export class PaperclipRunnerToolAuthority { return result; } + #apiScope(context: { + issue: { workMode: string }; + run: { contextSnapshot: unknown }; + }) { + const snapshot = record(context.run.contextSnapshot); + const admittedKind = record(snapshot.paperclipAgentJwtScope).kind; + const skillTest = admittedKind === "skill_test" || (admittedKind !== "standard" + && (Boolean(snapshot.paperclipSkillTest) || context.issue.workMode === "skill_test")); + return skillTest ? { kind: "skill_test" as const, issueId: this.binding.issueId } : { kind: "standard" as const }; + } + + #apiToken(context: { + actor: { adapterType: string }; + issue: { workMode: string }; + run: { responsibleUserId: string | null; contextSnapshot: unknown }; + }) { + return createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, + this.binding.runId, context.run.responsibleUserId, this.#apiScope(context)); + } + async #callApi(callId: string, value: unknown): Promise { const bound = await this.#boundContext(); const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode }; const { input, operation } = validateRunnerApiCall(value, context); + const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path); await this.#assertDotApiFileAccess(operation.path, input); const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; if (!apiUrl) throw new Error("Paperclip API origin is unavailable"); - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId); + const token = this.#apiToken(bound); if (!token) throw new Error("Paperclip run authentication is unavailable"); const execute = async () => { let reservationId: string | undefined; @@ -764,6 +788,8 @@ export class PaperclipRunnerToolAuthority { }; if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute(); if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required"); + const provider = sensitiveResponse ? getSecretProvider("local_encrypted") : null; + if (provider) await provider.createSecret({ value: "runner-api-receipt-key-check" }); // Reserve durably before HTTP dispatch, without holding a DB lock over an // HTTP route that itself needs DB locks. A crash leaves an explicit unknown // outcome rather than replaying a possibly committed external mutation. @@ -776,6 +802,10 @@ export class PaperclipRunnerToolAuthority { const existing = record(receipts[key]); if (receipts[key]) { if (existing.digest !== digest) throw badRequest("API call id was reused with different arguments"); + if (existing.encryptedResult) { + const text = await getSecretProvider("local_encrypted").resolveVersion({ material: record(existing.encryptedResult), externalRef: null }); + return { result: JSON.parse(text) as unknown }; + } return { result: existing.result ?? { ok: false, status: null, error: "api_outcome_unknown", outcome: "unknown", guidance: "Inspect state before issuing another mutation." } }; } if (Object.keys(receipts).length >= 512) throw badRequest("Run API mutation limit reached"); @@ -785,6 +815,9 @@ export class PaperclipRunnerToolAuthority { }); if (prior) return prior.result; const result = await execute(); + const storedResult = provider + ? { encryptedResult: (await provider.createSecret({ value: JSON.stringify(result) })).material } + : { result }; // Some older routes attribute the agent but omit runId. Retain their domain // event and add the run-bound HTTP receipt, without logging request bodies. const apiActivity = await persistActivity(this.db, { @@ -798,7 +831,7 @@ export class PaperclipRunnerToolAuthority { const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, this.binding.runId)).for("update"); const resultJson = record(run?.resultJson); const receipts = record(resultJson.apiToolReceipts); - receipts[key] = { digest, operationId: operation.operationId, state: "completed", result }; + receipts[key] = { digest, operationId: operation.operationId, state: "completed", ...storedResult }; await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId)); }); return result; @@ -914,6 +947,12 @@ export class PaperclipRunnerToolAuthority { if (file.artifactId) { const asset = await assetService(this.db).getById(file.artifactId); if (!asset || asset.companyId !== this.binding.companyId) throw forbidden("Artifact is not available in this company"); + const assertArtifactAccess = async () => { + const current = await this.#boundContext(); + if (!(await canActorReadAssetContent(this.db, { ...this.#privacyActor(current.run), keyScope: this.#apiScope(current) }, asset))) + throw forbidden("Artifact is not available to this actor"); + }; + await assertArtifactAccess(); if (asset.byteSize > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit"); const object = await (this.binding.storage ?? getStorageService()).getObject(this.binding.companyId, asset.objectKey); const chunks: Buffer[] = []; @@ -926,6 +965,7 @@ export class PaperclipRunnerToolAuthority { chunks.push(bytes); } } finally { object.stream.destroy(); } + await assertArtifactAccess(); return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType }; } const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" }); @@ -1027,8 +1067,7 @@ export class PaperclipRunnerToolAuthority { return { interactionId: review.interaction.id, status: expectedStatus, deduplicated: true }; } const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, - context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Review tool authentication is unavailable"); // Use the existing resolution route so authorization, activity, dependency // wakes and request-changes continuation have one implementation. diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index 4dddb822a8d..fb3bf8767a7 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { buildOpenApiDocument } from "../../routes/openapi.js"; import { badRequest, notFound } from "../../errors.js"; import { runnerApiReference } from "./runner-api-reference.js"; -import { runnerApiRestriction } from "./runner-api-policy.js"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js"; type Json = Record; @@ -24,7 +23,7 @@ export interface RunnerApiOperation { dedicatedToolGuidance: string; dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[]; runnerRestrictions?: string[]; - callPolicy: "rest" | "restricted" | "protocol"; + callPolicy: "rest" | "protocol"; } const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); @@ -82,7 +81,6 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): for (const [verb, operation] of Object.entries(item)) { if (!METHODS.has(verb)) continue; const method = verb.toUpperCase(); - const restriction = runnerApiRestriction(method, path); const conversationalConfirmation = method === "POST" && /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path); const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path); @@ -99,19 +97,19 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): responses: dereference(operation.responses ?? {}), authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" }, transport: protocol ? "protocol" : "rest", - callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest", + callPolicy: protocol ? "protocol" : "rest", dedicatedTools: dedicatedTools(method, path), dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => { const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name); return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : []; }), - runnerRestrictions: [...(restriction ? [restriction] : []), "Active run and assignment must remain authorized.", + runnerRestrictions: ["Active run and assignment must remain authorized.", conversationalConfirmation ? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user." - : "Cannot replace checkout, completion, task status/ownership changes, governed approval decisions, or runner execution control. Dedicated tools retain their existing permissions."], + : "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."], dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent") - ? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration." - : "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.", + ? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions." + : "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.", allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"] : conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"], ...(skillReference ? { skillReference } : {}), diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 0b8110c0de8..457d3141999 100644 --- a/server/src/services/native-runtime/runner-api-client.ts +++ b/server/src/services/native-runtime/runner-api-client.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { badRequest, forbidden, unprocessable } from "../../errors.js"; +import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { runnerApiOperation, type RunnerApiOperation } from "./runner-api-catalog.js"; import { captureRunnerApiResponse, type RunnerApiResponseBody } from "./runner-api-response.js"; -import { runnerApiRestriction } from "./runner-api-policy.js"; import { RunnerApiResponseLimitError, RunnerApiResponseCleanupError, RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js"; /** Request/upload bound only. Response capture has a separate streamed limit. */ @@ -54,37 +54,8 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext) const operation = runnerApiOperation(input.operationId); if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only"); if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation"); - const restriction = runnerApiRestriction(operation.method, operation.path); - if (restriction) throw forbidden(restriction); if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools"); if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company"); - const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method); - if (mutation) { - const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(operation.path); - const body = input.body && typeof input.body === "object" && !Array.isArray(input.body) ? input.body as Record : {}; - // Raw uploads must not hide lifecycle fields from the structured policy - // check and subsequently become JSON inside Express's body parser. - const guardedBody = /^\/api\/(issues|agents)\/\{[^}]+\}$/.test(operation.path) - || (issueRoute && /\/comments$/.test(operation.path)); - if (guardedBody && (input.files?.length || (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))))) { - throw forbidden("Lifecycle-sensitive API updates require an inline JSON object"); - } - if (/^\/api\/agents\/\{[^}]+\}$/.test(operation.path) && ["status", "runtimeState"].some(key => Object.hasOwn(body, key))) { - throw forbidden("API agent updates cannot bypass runner execution-control authority"); - } - if (issueRoute && /\/comments$/.test(operation.path) && ["reopen", "resume", "interrupt"].some(key => body[key] === true)) { - throw forbidden("API comments cannot change runner execution state"); - } - if (issueRoute && /\/issues\/\{[^}]+\}$/.test(operation.path)) { - const lifecycle = ["status", "workMode", "executionRunId", "checkoutRunId", "executionState", "executionPolicy", "executionAgentNameKey", "executionLockedAt", "completedAt", "cancelledAt", "startedAt", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "reopen", "resume", "interrupt"]; - if (lifecycle.some((key) => Object.hasOwn(body, key))) throw forbidden("Use dedicated completion, review, delegation or dependency tools for task lifecycle changes"); - // The real issue router normalizes identifiers and PostgreSQL accepts - // uppercase UUIDs. Apply the same identity comparison before dispatch. - const target = Object.values(input.pathParams ?? {}).map(id => id.trim().toLowerCase()); - const activeIds = [context.issueId, context.issueIdentifier].filter((id): id is string => Boolean(id)).map(id => id.toLowerCase()); - if (operation.method === "DELETE" && target.some(id => activeIds.includes(id))) throw forbidden("The active runner task cannot delete itself"); - } - } const contentType = (input.contentType ?? "application/json").split(";", 1)[0].trim().toLowerCase(); if (input.body !== undefined && !input.files?.length) { const requestSchema = operation.requestBody?.content?.[contentType]?.schema as { type?: string } | undefined; @@ -151,6 +122,7 @@ export async function readBoundedResponse(response: Response, maxBytes = RUNNER_ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) { const { operation, input: validated } = validateRunnerApiCall(input, context); input = validated; + const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path); const url = runnerApiUrl(operation, input, context, io.apiUrl); if (!io.token) throw new Error("Paperclip run authentication is unavailable"); const headers = new Headers({ Authorization: `Bearer ${io.token}`, "X-Paperclip-Run-Id": context.runId }); @@ -215,7 +187,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC try { response = await (io.fetch ?? fetch)(url, { method: operation.method, headers, body, redirect: "manual", signal: controller.signal }); clearTimeout(timeout); - if (assetTextRange && response.ok) { + if (sensitiveResponse) { + timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS); + bytes = await readBoundedResponse(response); + await io.beforeDispatch?.(); + } else if (assetTextRange && response.ok) { timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS); bytes = await readBoundedResponse(response, textLimit + 2); } else { @@ -294,10 +270,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC guidance: "Read this asset with responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null." }; } if (!totalBytes) return { ...base, data: null }; - if (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) { + if (!sensitiveResponse && (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type))) { return { ...base, artifact: await saveResponse(captured?.body ?? bytes, type), byteSize: totalBytes, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null, ...(/json|^text\//i.test(type) ? { guidance: "Read the saved artifact with GET /api/assets/{assetId}/content and responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null. Do not repeat a mutation to read its response." } : {}) }; } + if (sensitiveResponse && !/json|^text\//i.test(type)) return { ...base, ok: false, error: "credential_response_requires_text_content" }; const text = bytes.toString("utf8"); if (/json/i.test(type)) { try { return { ...base, data: JSON.parse(text) as unknown }; } diff --git a/server/src/services/native-runtime/runner-api-log-redaction.test.ts b/server/src/services/native-runtime/runner-api-log-redaction.test.ts new file mode 100644 index 00000000000..eae4316583e --- /dev/null +++ b/server/src/services/native-runtime/runner-api-log-redaction.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js"; + +describe("API payload persistence redaction", () => { + it.each(["POST /api/agents/me/secrets/{key}/value", "POST /api/companies/{companyId}/exports", "GET /api/projects/{id}"])("preserves private HTTP results on the wire, but omits %s from history", operationId => { + const result = { operationId: "call_api", apiOperationId: operationId, status: 200, ok: true, data: { value: "private-fixture-value" } }; + const event = { item: { type: "tool_result", result } }; + const saved = redactRunnerApiPayloads(event); + expect(saved.item.result).toMatchObject({ operationId: "call_api", apiOperationId: operationId, status: 200, apiPayloadRedacted: true }); + expect(JSON.stringify(saved)).not.toContain("private-fixture-value"); + expect(event.item.result.data.value).toBe("private-fixture-value"); + expect(redactRunnerApiPayloads(event)).toEqual(saved); + expect(redactRunnerApiPayloads(saved)).toEqual(saved); + expect((redactRunnerApiPayloads({ ...result, data: { value: "another-fixture-value" } }) as any).apiPayloadSha256) + .not.toBe((saved.item.result as any).apiPayloadSha256); + }); + it("redacts serialized legacy receipts and API request arguments", () => { + const value = { text: JSON.stringify({ result: { operationId: "POST /api/agents/me/secrets/{key}/value", data: { value: "private-fixture-value" } } }), + input: { operationId: "POST /api/agents/me/secret-proposals", body: { value: "proposal-fixture-value" } } }; + const saved = redactRunnerApiPayloads(value); + expect(JSON.stringify(saved)).not.toContain("private-fixture-value"); + expect(JSON.stringify(saved)).not.toContain("proposal-fixture-value"); + expect(JSON.parse(saved.text).result.apiPayloadRedacted).toBe(true); + }); + it("preserves unrelated tool output and JSON text formatting", () => { + const value = { result: { operationId: "get_task_context", data: { title: "Work" } }, text: '{ "ordinary": "output" }' }; + expect(redactRunnerApiPayloads(value)).toEqual(value); + }); + it("omits malformed API arguments and semantic result envelopes", () => { + const value = { item: { type: "tool_use", name: "call_api", input: { operationId: "invalid", body: { value: "private-fixture-value" } } }, + result: { operationId: "call_api", result: { data: "private-fixture-value" } } }; + expect(JSON.stringify(redactRunnerApiPayloads(value))).not.toContain("private-fixture-value"); + expect(redactRunnerApiPayloads(redactRunnerApiPayloads(value))).toEqual(redactRunnerApiPayloads(value)); + }); +}); diff --git a/server/src/services/native-runtime/runner-api-log-redaction.ts b/server/src/services/native-runtime/runner-api-log-redaction.ts new file mode 100644 index 00000000000..f06ad10266a --- /dev/null +++ b/server/src/services/native-runtime/runner-api-log-redaction.ts @@ -0,0 +1,40 @@ +import { REDACTED_EVENT_VALUE } from "../../redaction.js"; +import { nativeSha256 } from "./canonical.js"; + +const RECEIPT_FIELDS = new Set(["operationId", "apiOperationId", "callId", "ok", "status", "contentType"]); +const REDACTED_FIELDS = new Set([...RECEIPT_FIELDS, "data", "apiPayloadRedacted", "apiPayloadSha256"]); + +function redactedPayload(value: unknown) { + const entry = value && typeof value === "object" ? value as Record : {}; + const wasRedacted = entry.data === REDACTED_EVENT_VALUE && entry.apiPayloadRedacted === true + && typeof entry.apiPayloadSha256 === "string" && /^[a-f0-9]{64}$/.test(entry.apiPayloadSha256) + && Object.keys(entry).every(key => REDACTED_FIELDS.has(key)); + return { + ...Object.fromEntries(Object.entries(entry).filter(([key, item]) => RECEIPT_FIELDS.has(key) + && ["string", "number", "boolean"].includes(typeof item))), + data: REDACTED_EVENT_VALUE, + apiPayloadRedacted: true, + apiPayloadSha256: wasRedacted ? entry.apiPayloadSha256 : nativeSha256(value), + }; +} + +export function redactRunnerApiPayloads(value: T): T { + if (Array.isArray(value)) return value.map(redactRunnerApiPayloads) as T; + if (typeof value === "string" && /^[\s]*[\[{]/.test(value)) { + try { + const parsed = JSON.parse(value); + const redacted = redactRunnerApiPayloads(parsed); + return (JSON.stringify(parsed) === JSON.stringify(redacted) ? value : JSON.stringify(redacted)) as T; + } catch { return value; } + } + if (!value || typeof value !== "object") return value; + const entry = value as Record; + const operation = entry.apiOperationId ?? entry.operationId; + if (operation === "call_api" || (typeof operation === "string" && /^(GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE) \/api\//.test(operation))) { + return redactedPayload(value) as T; + } + if (entry.type === "tool_use" && entry.name === "call_api") { + return { ...entry, input: redactedPayload(entry.input) } as T; + } + return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, redactRunnerApiPayloads(item)])) as T; +} diff --git a/server/src/services/native-runtime/runner-api-policy.ts b/server/src/services/native-runtime/runner-api-policy.ts deleted file mode 100644 index 2663d17f174..00000000000 --- a/server/src/services/native-runtime/runner-api-policy.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Ordinary API results and replay receipts are not a secret-value channel. */ -export function runnerApiRestriction( - method: string, - path: string, -): string | null { - const metadataRead = - method === "GET" && - [ - "/api/agents/me/secrets", - "/api/companies/{companyId}/secrets/catalog", - ].includes(path); - if ( - (/^\/api\/tool-gateway\/sessions(\/|$)/.test(path) && method !== "GET") || - /^\/api\/chat-identity-links(\/|$)/.test(path) || - /\/chat-endpoints\/\{[^}]+\}\/principals\/\{[^}]+\}\/link(?:-intent)?$/.test( - path, - ) || - (!metadataRead && - (/\/(secrets|setup-secret|secret-proposals|secret-provider-configs|user-secrets|user-secret-definitions|keys|board-api-keys|credentials|setup-token-login-sessions|board-claim|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test( - path, - ) || - /^\/api\/companies\/\{companyId\}\/exports?(\/|$)/.test(path))) - ) { - return "Use the existing credential broker or secure management client: call_api cannot return secret values, manage credentials, or export company credential configuration."; - } - return ["GET", "HEAD", "OPTIONS"].includes(method) - ? null - : runnerApiMutationRestriction(path); -} - -/** Runner-owned transitions cannot be reached by the generic HTTP escape hatch. */ -export function runnerApiMutationRestriction(path: string): string | null { - // This endpoint only records an eligible responding agent's interpretation - // of a same-task user message. It checks the live run, resolver policy and - // session under lock, and cannot execute governed approvals or enqueue work. - if (/^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path)) return null; - const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(path); - const routineAnnotation = - /^\/api\/routines\/\{[^}]+\}\/description\/annotations(?:\/\{[^}]+\}(?:\/comments)?)?$/.test( - path, - ); - if ( - (!routineAnnotation && /\/(routines|routine-triggers)(\/|$)/.test(path)) || - /\/(runtime-commands|runtime-services)\/\{action\}$/.test(path) || - /\/(?:tool-gateway|tools)\/runtime-slots\/\{[^}]+\}\/(restart|stop)$/.test( - path, - ) || - /^\/api\/cases\/\{[^}]+\}\/(automation|automations)\//.test(path) || - /\/skills\/\{[^}]+\}\/test-runs(\/|$)/.test(path) || - /^\/api\/heartbeat-runs\//.test(path) || - /^\/api\/agents\/\{[^}]+\}\/(heartbeat|wakeup|pause|resume|terminate|approve|clear-error|runtime-state)(\/|$)/.test( - path, - ) || - /^\/api\/(approvals|decisions)\/\{[^}]+\}\/(approve|reject|decide|cancel|dismiss|request-revision|resubmit)$/.test( - path, - ) || - (issueRoute && /\/queued-comments\/\{[^}]+\}\/steer$/.test(path)) || - (issueRoute && - /\/(interactions|accepted-plan-decompositions|stalled-review-decision|tree-holds|watchdog|recovery-actions|scheduled-retry|monitor|admin|checkout|release|cancel|resume|wake|run|retry|recover|tree-control)(\/|$)/.test( - path, - )) - ) { - return "Use the dedicated tools and existing clients: call_api cannot bypass runner lifecycle, execution-control or approval authority"; - } - return null; -} diff --git a/server/src/services/native-runtime/runner-api-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts new file mode 100644 index 00000000000..1920d27e239 --- /dev/null +++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts @@ -0,0 +1,8 @@ +export function runnerApiSensitiveResponse(method: string, path: string): boolean { + if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true; + return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path) + || /^\/api\/companies\/\{[^}]+\}\/exports?(?:\/|$)/.test(path) + || (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/triggers$/.test(path)) + || (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/revisions\/\{[^}]+\}\/restore$/.test(path)) + || /\/(keys|board-api-keys|credentials|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(path); +} diff --git a/server/src/services/native-runtime/runner-api.integration.test.ts b/server/src/services/native-runtime/runner-api.integration.test.ts index ceb6e4784a6..764cbcc27ad 100644 --- a/server/src/services/native-runtime/runner-api.integration.test.ts +++ b/server/src/services/native-runtime/runner-api.integration.test.ts @@ -3,8 +3,10 @@ import { randomUUID } from "node:crypto"; import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises"; import { join, dirname } from "node:path"; import { eq } from "drizzle-orm"; -import { assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations } from "@paperclipai/db"; +import { agents, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { secretService } from "../secrets.js"; +import { authorizationService } from "../authorization.js"; import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js"; import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js"; import { runnerApiCatalog } from "./runner-api-catalog.js"; @@ -12,10 +14,12 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js"; import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; import { RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES } from "./runner-api-response-limits.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; +import { prepareHeartbeatTaskContext } from "../heartbeat/task-context.js"; describe("runner API against real HTTP routes", () => { let server: Awaited>; const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + const oldMasterKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; beforeEach(() => { vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", undefined); vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS", undefined); @@ -24,9 +28,12 @@ describe("runner API against real HTTP routes", () => { beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "master.key"); }, 60_000); afterAll(async () => { await server?.close(); + if (oldMasterKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = oldMasterKeyFile; if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret; }); @@ -108,24 +115,302 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); } finally { delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED; } }); - it("rejects credential calls before any durable receipt or secret result exists", async () => { - const fixture = await server.fixture(); - for (const operationId of ["POST /api/agents/me/secrets/{key}/value", "POST /api/agents/{id}/keys"]) { - await expect(fixture.authority.execute({ tool: "call_api", callId: operationId, arguments: { operationId, pathParams: operationId.includes("{key}") ? { key: "EXAMPLE_SECRET" } : { id: fixture.agentId } } })).rejects.toThrow("credential broker"); - } - const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); - expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined(); - expect((await fixture.snapshot()).activity.filter(row => row.action === "runner.api_called")).toEqual([]); + it("uses route permissions for lifecycle payloads instead of fallback-only denials", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true }); + const result = await fixture.authority.execute({ tool: "call_api", callId: "block-via-rest", arguments: { + operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, + body: { status: "blocked", billingCode: "REST-PARITY", blockedByIssueIds: [fixture.issueId] }, + } }); + expect(result).toMatchObject({ ok: true, status: 200, data: { status: "blocked", billingCode: "REST-PARITY" } }); + expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)) + .toMatchObject({ status: "blocked", billingCode: "REST-PARITY" }); }); - it("rejects issue lifecycle intents before dispatch or receipt creation", async () => { + it("manages owned routines and triggers with durable replay, attribution and owner denials", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true }); + await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId)); + const args = { operationId: "POST /api/companies/{companyId}/routines", body: { + title: "REST parity routine", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused", + } }; + const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise; + const created = await call("routine-create", args); + expect(created).toMatchObject({ ok: true, status: 201, data: { title: "REST parity routine", assigneeAgentId: fixture.agentId } }); + expect(await call("routine-create", args)).toEqual(created); + const routineId = created.data.id; + const trigger = await call("routine-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId }, body: { kind: "schedule", cronExpression: "0 9 * * *", timezone: "UTC", enabled: false } }); + expect(trigger).toMatchObject({ ok: true, status: 201, data: { trigger: { routineId, enabled: false } } }); + expect(await call("routine-update", { operationId: "PATCH /api/routines/{id}", pathParams: { id: routineId }, body: { title: "Updated through fallback" } })) + .toMatchObject({ ok: true, status: 200, data: { title: "Updated through fallback" } }); + const [other] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Other routine owner", status: "active" }).returning(); + expect(await call("routine-other-owner", { ...args, body: { ...args.body, assigneeAgentId: other.id } })) + .toMatchObject({ ok: false, status: 403 }); + const snapshot = await fixture.snapshot(); + expect(snapshot.activity).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "routine.created", agentId: fixture.agentId, runId: fixture.runId }), + expect.objectContaining({ action: "runner.api_called", agentId: fixture.agentId, runId: fixture.runId, details: expect.objectContaining({ operationId: args.operationId, status: 201 }) }), + ])); + expect(await server.db.select().from(routines).where(eq(routines.companyId, fixture.companyId))).toHaveLength(1); + }); + + it("starts a permitted skill test through the fallback using the existing assignment grants", async () => { + const fixture = await server.fixture({ conversation: true, disableWakeOnDemand: true }); + await server.db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, status: "active", membershipRole: "member" }); + await server.db.insert(principalPermissionGrants).values([ + { companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, permissionKey: "tasks:assign" }, + { companyId: fixture.companyId, principalType: "user", principalId: fixture.responsibleUserId!, permissionKey: "tasks:assign" }, + ]); + const skillDir = join(server.root, `parity-skill-${randomUUID()}`); + await mkdir(skillDir); + await writeFile(join(skillDir, "SKILL.md"), "# Parity skill\nReturn the supplied fixture input.\n"); + const [skill] = await server.db.insert(companySkills).values({ + companyId: fixture.companyId, key: `company/${fixture.companyId}/parity`, slug: "parity", name: "Parity skill", + markdown: "# Parity skill\n", sourceType: "local_path", sourceLocator: skillDir, + trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" }, + }).returning(); + const call = { tool: "call_api", callId: "skill-test-create", arguments: { + operationId: "POST /api/companies/{companyId}/skills/{skillId}/test-runs", pathParams: { skillId: skill.id }, + body: { agentId: fixture.agentId, content: "Fixture input" }, + } }; + const result = await fixture.authority.execute(call) as any; + expect(result).toMatchObject({ ok: true, status: 201, data: { skillId: skill.id, agentId: fixture.agentId } }); + expect(await fixture.authority.execute(call)).toEqual(result); + expect((await fixture.snapshot()).issues.some(issue => issue.id === result.data.issueId)).toBe(true); + }); + + it("keeps saved API responses within the issuing actor's scope", async () => { const fixture = await server.fixture(); - for (const field of ["reopen", "resume", "interrupt"]) { - await expect(fixture.authority.execute({ tool: "call_api", callId: field, arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, body: { [field]: true } } })).rejects.toThrow("lifecycle changes"); + const body = "Private API response\n".repeat(2000); + const saved = await fixture.authority.execute({ tool: "call_api", callId: "private-snapshot", arguments: { + operationId: "PUT /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "private-api-result" }, + body: { title: "Private API result", body, format: "markdown", baseRevisionId: null }, + } }) as any; + expect(saved).toMatchObject({ ok: true, status: 201, artifact: { artifactId: expect.any(String) } }); + const url = `${fixture.apiUrl}/api/assets/${saved.artifact.artifactId}/content`; + const owner = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId); + expect((await fetch(url, { headers: { Authorization: `Bearer ${owner}` } })).status).toBe(200); + const [peer] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Peer snapshot reader", status: "active", role: "ceo", adapterType: "paperclip_runner" }).returning(); + const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: peer.id, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning(); + await server.db.update(issues).set({ assigneeAgentId: peer.id, status: "in_progress", executionRunId: peerRun.id, + description: `![private](/api/assets/${saved.artifact.artifactId}/content)`, + }).where(eq(issues.id, fixture.blockerId)); + const token = createLocalAgentJwt(peer.id, fixture.companyId, "codex_local", peerRun.id); + expect((await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404); + const peerAuthority = new PaperclipRunnerToolAuthority(server.db, { + ...fixture.authority.binding, agentId: peer.id, runId: peerRun.id, issueId: fixture.blockerId, + }); + await expect(peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.blockerId }, + files: [{ artifactId: saved.artifact.artifactId }], + } })).rejects.toThrow("Artifact is not available to this actor"); + const exported = await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-export", arguments: { + operationId: "POST /api/companies/{companyId}/exports", body: { include: { issues: true }, issues: [fixture.blockerId] }, + } }) as any; + expect(exported).toMatchObject({ ok: true, status: 200 }); + for (const [path, file] of Object.entries(exported.data.files)) { + if (!path.startsWith("blobs/")) continue; + expect(Buffer.from((file as { data: string }).data, "base64").toString("utf8")).not.toContain("Private API response"); } + expect(await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-logo", arguments: { + operationId: "PATCH /api/companies/{companyId}/branding", body: { logoAssetId: saved.artifact.artifactId }, + } })).toMatchObject({ ok: false, status: 422 }); + const ownUpload = await fixture.authority.execute({ tool: "call_api", callId: "own-snapshot-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, + files: [{ artifactId: saved.artifact.artifactId }], + } }); + expect(ownUpload).toMatchObject({ ok: true, status: 201 }); + }); + + it.each(["skill_test", "low_trust_review"])("allows task image reads without company browsing (%s)", async scope => { + const fixture = await server.fixture(); + if (scope === "skill_test") { + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + } else { + await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { + trustBoundary: { mode: "low_trust_review", companyId: fixture.companyId, issueIds: [fixture.issueId], allowedAgentIds: [fixture.agentId] }, + } } }).where(eq(agents.id, fixture.agentId)); + } + const keyScope = scope === "skill_test" ? { kind: "skill_test" as const, issueId: fixture.issueId } : { kind: "standard" as const }; + const actor = { type: "agent" as const, agentId: fixture.agentId, companyId: fixture.companyId, runId: fixture.runId, keyScope }; + expect((await authorizationService(server.db).decide({ actor, action: "company_scope:read", + resource: { type: "company", companyId: fixture.companyId }, + })).allowed).toBe(false); + const imageIds: string[] = []; + for (const issueId of [fixture.issueId, fixture.blockerId]) { + const stored = await server.storage.putFile({ companyId: fixture.companyId, namespace: `issues/${issueId}`, + originalFilename: "image.png", contentType: "image/png", body: Buffer.from("task-image-fixture"), + }); + const [image] = await server.db.insert(assets).values({ companyId: fixture.companyId, ...stored, createdByAgentId: fixture.agentId }).returning(); + imageIds.push(image.id); + } + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, keyScope); + expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[0]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(200); + expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[1]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404); + expect(await fixture.authority.execute({ tool: "call_api", callId: "scoped-image", arguments: { + operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId: imageIds[0] }, + } })).toMatchObject({ ok: true, status: 206 }); + await expect(fixture.authority.execute({ tool: "call_api", callId: "scoped-image-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, + files: [{ artifactId: imageIds[1] }], + } })).rejects.toThrow("Artifact is not available to this actor"); + }); + + it("keeps restored webhook secrets inline and encrypts their replay receipts", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true }); + vi.stubEnv("PAPERCLIP_API_URL", fixture.apiUrl); + await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId)); + const call = (callId: string, args: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: args }) as Promise; + const created = await call("webhook-routine", { operationId: "POST /api/companies/{companyId}/routines", body: { + title: "Restored webhook", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused", + } }); + expect(created).toMatchObject({ status: 201 }); + const routineId = created.data.id; + await server.db.update(routines).set({ description: "Restored routine description\n".repeat(2000) }).where(eq(routines.id, routineId)); + const trigger = await call("webhook-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId }, + body: { kind: "webhook", signingMode: "bearer", replayWindowSec: 300 }, + }); + expect(trigger).toMatchObject({ status: 201 }); + expect(await call("delete-webhook", { operationId: "DELETE /api/routine-triggers/{id}", pathParams: { id: trigger.data.trigger.id } })) + .toMatchObject({ status: 204 }); + const args = { operationId: "POST /api/routines/{id}/revisions/{revisionId}/restore", + pathParams: { id: routineId, revisionId: trigger.data.revision.id }, + }; + const restored = await call("restore-webhook", args); + expect(restored).toMatchObject({ ok: true, status: 200, data: { secretMaterials: [expect.objectContaining({ webhookSecret: expect.any(String) })] } }); + expect(restored).not.toHaveProperty("artifact"); + expect(await call("restore-webhook", args)).toEqual(restored); + const value = restored.data.secretMaterials[0].webhookSecret; const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); - expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined(); - expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)?.status).toBe("todo"); + expect(JSON.stringify(run.resultJson)).not.toContain(value); + expect(Object.values((run.resultJson as any).apiToolReceipts).find((receipt: any) => receipt.operationId === args.operationId)) + .toHaveProperty("encryptedResult"); + expect((await fixture.snapshot()).assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + }); + + it("returns and replays scoped secret values without plaintext receipts or response assets", async () => { + const fixture = await server.fixture(); + const value = "disposable-private-credential-".repeat(1500); + const secrets = secretService(server.db); + const secret = await secrets.create(fixture.companyId, { name: "parity/private-value", key: "PARITY_TOKEN", provider: "local_encrypted", value }); + await secrets.createBinding({ companyId: fixture.companyId, secretId: secret.id, targetType: "agent", targetId: fixture.agentId, configPath: "env.PARITY_TOKEN" }); + const call = { tool: "call_api", callId: "secret-value", arguments: { operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: secret.key } } }; + const result = await fixture.authority.execute(call); + expect(result).toMatchObject({ ok: true, status: 200, data: { key: secret.key, value } }); + expect(await fixture.authority.execute(call)).toEqual(result); + const snapshot = await fixture.snapshot(); + expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(value); + expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult"); + expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1); + const otherAgentId = randomUUID(); + await server.db.insert(agents).values({ id: otherAgentId, companyId: fixture.companyId, name: "Unbound peer", status: "active" }); + const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: otherAgentId, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning(); + await server.db.update(issues).set({ assigneeAgentId: otherAgentId, status: "in_progress", executionRunId: peerRun.id }).where(eq(issues.id, fixture.blockerId)); + const token = createLocalAgentJwt(otherAgentId, fixture.companyId, "paperclip_runner", peerRun.id); + expect((await fetch(`${fixture.apiUrl}/api/agents/me/secrets/${secret.key}/value`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": peerRun.id } })).status).toBe(403); + }); + + it.each([ + ["POST /api/companies/{companyId}/exports", 1], + ["POST /api/companies/{companyId}/export", 2500], + ])("keeps authorized company export credentials private and replayable for %s", async (operationId, repeats) => { + const fixture = await server.fixture(); + await server.db.update(agents).set({ role: "ceo" }).where(eq(agents.id, fixture.agentId)); + const credential = "disposable-export-credential-".repeat(repeats); + const [httpAgent] = await server.db.insert(agents).values({ + companyId: fixture.companyId, name: "Exported HTTP fixture", adapterType: "http", + adapterConfig: { url: "https://fixture.invalid", headers: { Authorization: credential } }, + }).returning(); + const call = { tool: "call_api", callId: "private-export", arguments: { + operationId, body: { include: { agents: true }, agents: [httpAgent.id] }, + } }; + const result = await fixture.authority.execute(call) as any; + expect(result).toMatchObject({ ok: true, status: 200 }); + expect(JSON.stringify(result.data)).toContain(credential); + expect(result).not.toHaveProperty("artifact"); + expect(await fixture.authority.execute(call)).toEqual(result); + const snapshot = await fixture.snapshot(); + expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(credential); + expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult"); + }); + + async function reprepareRun(fixture: { runId: string; issueId: string; agentId: string }) { + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId)); + const [agent] = await server.db.select().from(agents).where(eq(agents.id, fixture.agentId)); + const context = structuredClone(run.contextSnapshot ?? {}); + await prepareHeartbeatTaskContext(server.db, { run, agent, + task: { issueRef: issue, issueContext: null, wakeCommentContext: null, context, taskSession: null }, + policy: { recovering: true, trustPreset: { kind: "standard" }, experimentalInstanceSettings: {} }, + services: { issuesSvc: { getAncestors: async () => [] }, getPinnedSkillTestContext: async () => null }, + }); + await server.db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, fixture.runId)); + } + + it("preserves the REST skill-test credential scope after a task mode edit and restart preparation", async () => { + const fixture = await server.fixture(); + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId)); + await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, fixture.issueId)); + await reprepareRun(fixture); + const body = { kind: "secret", name: "parity/skill-test", value: "disposable-skill-test-value", justification: "Must remain denied" }; + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, + fixture.responsibleUserId, { kind: "skill_test", issueId: fixture.issueId }); + const direct = await fetch(`${fixture.apiUrl}/api/agents/me/secret-proposals`, { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Run-Id": fixture.runId }, body: JSON.stringify(body), + }); + expect(direct.status).toBe(403); + const result = await fixture.authority.execute({ tool: "call_api", callId: "skill-test-secret-denied", arguments: { + operationId: "POST /api/agents/me/secret-proposals", body, + } }); + expect(result).toMatchObject({ ok: false, status: direct.status, data: await direct.json() }); + }); + + it("retains standard REST credential scope after a task becomes a skill test and restart preparation", async () => { + const fixture = await server.fixture(); + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "standard" }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId)); + await reprepareRun(fixture); + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, { kind: "standard" }); + const direct = await fetch(`${fixture.apiUrl}/api/companies/${fixture.companyId}/projects`, { headers: { Authorization: `Bearer ${token}` } }); + expect(direct.status).toBe(200); + expect(await fixture.authority.execute({ tool: "call_api", callId: "standard-scope", arguments: { + operationId: "GET /api/companies/{companyId}/projects", + } })).toMatchObject({ ok: true, status: 200, data: await direct.json() }); + }); + + it("creates and reads secret proposals while preserving redaction and board review", async () => { + const fixture = await server.fixture(); + const keyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "proposal-master.key"); + try { + const args = { operationId: "POST /api/agents/me/secret-proposals", body: { + kind: "secret", name: "parity/example", value: "disposable-parity-secret", justification: "Test existing agent REST permission", + } }; + const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise; + const created = await call("proposal-create", args); + expect(created).toMatchObject({ ok: true, status: 201, data: { status: "pending", kind: "secret" } }); + expect(await call("proposal-create", args)).toEqual(created); + const listed = await call("proposal-list", { operationId: "GET /api/agents/me/secret-proposals" }); + expect(listed).toMatchObject({ ok: true, status: 200, data: { proposals: [expect.objectContaining({ id: created.data.id })] } }); + expect(JSON.stringify([created, listed])).not.toContain(args.body.value); + expect(await call("proposal-approve-denied", { operationId: "POST /api/companies/{companyId}/secret-proposals/{id}/approve", pathParams: { id: created.data.id } })) + .toMatchObject({ ok: false, status: 403 }); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(args.body.value); + } finally { + if (keyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile; + } }); it("supports routine annotation collaboration without changing scheduling", async () => { @@ -147,15 +432,31 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); expect((await fixture.snapshot()).activity).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_thread_created", agentId: fixture.agentId, runId: fixture.runId })])); }); - it("rejects every restricted REST mutation before dispatch or durable receipt", async () => { - const fixture = await server.fixture(); - const operations = runnerApiCatalog().filter(operation => operation.transport === "rest" && !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.callPolicy === "restricted"); - expect(operations.length).toBeGreaterThan(5); - for (const operation of operations) { - await expect(fixture.authority.execute({ tool: "call_api", callId: operation.operationId, arguments: { operationId: operation.operationId } })).rejects.toThrow(/cannot bypass|credential broker/); + it("matches direct agent REST authorization for formerly restricted routes", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true }); + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId); + for (const [operationId, path, body] of [ + ["POST /api/agents/{id}/pause", `/api/agents/${fixture.agentId}/pause`, {}], + ["POST /api/agents/{id}/resume", `/api/agents/${fixture.agentId}/resume`, {}], + ["POST /api/companies/{companyId}/exports", `/api/companies/${fixture.companyId}/exports`, {}], + ["POST /api/companies/{companyId}/invites", `/api/companies/${fixture.companyId}/invites`, {}], + ["POST /api/approvals/{id}/approve", `/api/approvals/${fixture.approvalId}/approve`, {}], + ["POST /api/issues/{id}/recovery-actions/resolve", `/api/issues/${fixture.blockerId}/recovery-actions/resolve`, {}], + ["POST /api/execution-workspaces/{id}/runtime-services/{action}", `/api/execution-workspaces/${randomUUID()}/runtime-services/stop`, {}], + ["POST /api/companies/{companyId}/skills/{skillId}/test-runs", `/api/companies/${fixture.companyId}/skills/${randomUUID()}/test-runs`, { agentId: fixture.agentId, content: "Test input" }], + ] as const) { + const direct = await fetch(`${fixture.apiUrl}${path}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": fixture.runId, "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const names = [...operationId.matchAll(/\{([^}]+)\}/g)].map(([, name]) => name); + const template = operationId.slice(5).split("/"); + const actual = path.split("/"); + const values = template.flatMap((segment, index) => segment.startsWith("{") ? [actual[index]] : []); + const result = await fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { + operationId, pathParams: Object.fromEntries(names.map((name, index) => [name, values[index]])), body, + } }) as any; + expect(result.status, operationId).toBe(direct.status); + expect(result.ok, operationId).toBe(direct.ok); + if (!direct.ok) expect(result.data, operationId).toEqual(await direct.json()); } - const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); - expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined(); }); it("preserves route validation, authorization and audit; replays mutations once", async () => { @@ -187,20 +488,6 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); } }); - it("allows API-only options while protecting task completion", async () => { - const fixture = await server.fixture(); - const call = (body: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.issueId }, body } }); - await expect(call({ billingCode: "API-EXTRA" })).resolves.toMatchObject({ status: 200 }); - await expect(call({ status: "done" })).rejects.toThrow("lifecycle"); - const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId)); - expect(issue.billingCode).toBe("API-EXTRA"); - expect(issue.status).toBe("in_progress"); - for (const id of [fixture.issueId.toUpperCase(), issue.identifier!.toLowerCase(), ` ${issue.identifier!.toLowerCase()} `]) { - await expect(fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "DELETE /api/issues/{id}", pathParams: { id } } })).rejects.toThrow("cannot delete itself"); - } - expect((await fixture.snapshot()).issues.some(row => row.id === fixture.issueId)).toBe(true); - }); - it("revokes advertised API tools without disabling dedicated operations", async () => { const fixture = await server.fixture(); expect(fixture.authority.definitions().some(tool => tool.name === "call_api")).toBe(true); diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index cb75b0cfb3d..cab4698c71f 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -75,7 +75,7 @@ describe("runner API catalog", () => { expect(operation.allowedModes).toContain("planning"); expect(operation.allowedModes).not.toContain("ask"); expect(operation.runnerRestrictions?.join(" ")).toContain("conversational confirmation"); - expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("restricted"); + expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("rest"); expect(runnerApiOperation(createProject).allowedModes).not.toContain("planning"); }); it("accounts for unique operations with resolved request contracts", () => { @@ -138,21 +138,25 @@ describe("runner API catalog", () => { }); describe("runner API request boundary", () => { - it.each([ - "POST /api/execution-workspaces/{id}/runtime-commands/{action}", - "POST /api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}", - "POST /api/tool-gateway/runtime-slots/{slotId}/restart", - "POST /api/cases/{caseId}/automation/current-stage/rerun", - "POST /api/companies/{companyId}/skills/{skillId}/test-runs", - "POST /api/tool-gateway/sessions", - ])( - "keeps execution and gateway control %s out of generic dispatch", - async (operationId) => { - const request = vi.fn(); - await expect( - executeRunnerApi({ operationId }, context, io(request)), - ).rejects.toThrow(/cannot bypass|credential broker/); - expect(request).not.toHaveBeenCalled(); + it.each(runnerApiCatalog().filter(operation => operation.transport === "rest"))( + "dispatches $operationId with agent identity and preserves route authorization", + async operation => { + const request = vi.fn(async () => + Response.json({ error: "Route permission required" }, { status: 403 }), + ); + const pathParams = Object.fromEntries( + [...operation.path.matchAll(/\{([^}]+)\}/g)].map(([, name]) => + [name, name === "companyId" ? context.companyId : "fixture"]), + ); + const result = await executeRunnerApi( + { operationId: operation.operationId, pathParams }, context, io(request), + ); + expect(operation.callPolicy).toBe("rest"); + expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Route permission required" } }); + expect(request).toHaveBeenCalledOnce(); + const headers = new Headers(request.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toBe("Bearer private-agent-token"); + expect(headers.get("X-Paperclip-Run-Id")).toBe(context.runId); }, ); it.each([ @@ -171,28 +175,6 @@ describe("runner API request boundary", () => { expect(request).not.toHaveBeenCalled(); }, ); - it.each( - runnerApiCatalog().filter( - (operation) => - !["GET", "HEAD", "OPTIONS"].includes(operation.method) && - /\/(routines|routine-triggers)(\/|$)/.test(operation.path) && - !operation.path.includes("/description/annotations"), - ), - )( - "keeps scheduled execution $operationId behind its existing client", - async (operation) => { - const request = vi.fn(); - await expect( - executeRunnerApi( - { operationId: operation.operationId }, - context, - io(request), - ), - ).rejects.toThrow(/cannot bypass|credential broker/); - expect(request).not.toHaveBeenCalled(); - expect(operation.callPolicy).toBe("restricted"); - }, - ); it.each( runnerApiCatalog().filter( (operation) => @@ -226,51 +208,16 @@ describe("runner API request boundary", () => { ).operation.callPolicy, ).toBe("rest"); }); - it.each(["reopen", "resume", "interrupt"])( - "cannot hide lifecycle intent %s in an ordinary issue patch", - async (field) => { - const request = vi.fn(); - await expect( - executeRunnerApi( - { - operationId: "PATCH /api/issues/{id}", - pathParams: { id: "other-issue" }, - body: { [field]: true, billingCode: "safe-extra-field" }, - }, - context, - io(request), - ), - ).rejects.toThrow("lifecycle changes"); - expect(request).not.toHaveBeenCalled(); - }, - ); it.each([ - "POST /api/agents/me/secrets/{key}/value", - "POST /api/agents/{id}/keys", - "DELETE /api/agents/{id}/keys/{keyId}", - "POST /api/companies/{companyId}/secret-proposals/{id}/approve", - "POST /api/agents/me/secret-proposals", - "PATCH /api/secrets/{id}", - "POST /api/companies/{companyId}/exports", - "GET /api/secret-provider-configs/{id}", - "POST /api/chat-endpoints/{endpointId}/setup-secret", - "POST /api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent", - "DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link", - "POST /api/chat-identity-links/confirm", - "GET /api/chat-identity-links/preview", - ])( - "keeps sensitive operation %s out of model results and receipts", - async (operationId) => { - const request = vi.fn(); - await expect( - executeRunnerApi({ operationId }, context, io(request)), - ).rejects.toThrow("credential broker"); - expect(request).not.toHaveBeenCalled(); - expect(searchRunnerApi({ query: operationId }).results[0]).toMatchObject({ - callPolicy: "restricted", - }); - }, - ); + ["PATCH /api/issues/{id}", { status: "blocked", assigneeAgentId: "teammate", blockedByIssueIds: ["dependency"], reopen: true }], + ["PATCH /api/agents/{id}", { status: "idle" }], + ["POST /api/issues/{id}/comments", { body: "Continue", resume: true }], + ])("passes REST payload fields to route authorization for %s", async (operationId, body) => { + const request = vi.fn(async () => Response.json({ accepted: true })); + expect(await executeRunnerApi({ operationId: operationId as string, pathParams: { id: "fixture" }, body }, context, io(request))) + .toMatchObject({ ok: true, status: 200 }); + expect(JSON.parse(request.mock.calls[0][1]?.body as string)).toEqual(body); + }); it("retains safe secret metadata discovery", () => { for (const operationId of [ "GET /api/agents/me/secrets", @@ -341,39 +288,34 @@ describe("runner API request boundary", () => { ).toBe("GET"); } }); - it("retains API-only issue options while guarding lifecycle fields", () => { - const input = { - operationId: "PATCH /api/issues/{id}", - pathParams: { id: context.issueId }, - body: { billingCode: "cost-center" }, - }; - expect(validateRunnerApiCall(input, context).operation.method).toBe( - "PATCH", - ); - expect(() => - validateRunnerApiCall({ ...input, body: { status: "done" } }, context), - ).toThrow("dedicated"); - expect(() => - validateRunnerApiCall( - { - operationId: "POST /api/issues/{id}/checkout", - pathParams: { id: context.issueId }, - }, - context, - ), - ).toThrow("lifecycle"); + it.each([ + ["POST /api/agents/me/secrets/{key}/value", { key: "FIXTURE_TOKEN" }], + ["POST /api/companies/{companyId}/exports", {}], + ["POST /api/companies/{companyId}/export", {}], + ])("returns credential responses for %s above the inline limit without plaintext artifacts", async (operationId, pathParams) => { + const value = "fixture-credential-".repeat(3000); + const transport = io(async () => Response.json({ value })); + const save = vi.fn(transport.saveResponse); + const reserve = vi.fn(async () => vi.fn(async () => {})); + const result = await executeRunnerApi({ operationId, pathParams }, context, { ...transport, saveResponse: save, reserveResponseCapture: reserve }); + expect(result).toMatchObject({ ok: true, status: 200, data: { value } }); + expect(result).not.toHaveProperty("artifact"); + expect(save).not.toHaveBeenCalled(); + expect(reserve).not.toHaveBeenCalled(); + }); + it("withholds a credential response when the run is revoked during HTTP", async () => { + let authorized = true; + const request = vi.fn(async () => { + authorized = false; + return Response.json({ value: "private-fixture-value" }); + }); + const result = await executeRunnerApi({ operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: "fixture-key" } }, context, { + ...io(request), beforeDispatch: async () => { if (!authorized) throw new Error("Run revoked"); }, + }); + expect(result).toMatchObject({ ok: false, error: "api_transport_failure", outcome: "unknown" }); + expect(JSON.stringify(result)).not.toContain("private-fixture-value"); + expect(request).toHaveBeenCalledOnce(); }); - it.each(["issue-a", "ISSUE-A", "API-1", "api-1", " api-1 "])( - "cannot delete its active task using the route identity alias %s", - (id) => { - expect(() => - validateRunnerApiCall( - { operationId: "DELETE /api/issues/{id}", pathParams: { id } }, - context, - ), - ).toThrow("cannot delete itself"); - }, - ); it("forwards only server-owned authentication and preserves API denials", async () => { const request = vi.fn(async () => Response.json({ error: "Board access required" }, { status: 403 }), @@ -559,29 +501,11 @@ describe("runner API request boundary", () => { ), ).rejects.toThrow(); }); - it("classifies protocols and prevents execution control through alternate routes", () => { - const protocols = runnerApiCatalog().filter( - (operation) => operation.transport === "protocol", - ); - expect( - protocols.some((operation) => operation.path.endsWith("/events/ws")), - ).toBe(true); - for (const operation of protocols) - expect(() => - validateRunnerApiCall({ operationId: operation.operationId }, context), - ).toThrow("protocol client"); - for (const operationId of [ - "POST /api/issues/{id}/tree-holds", - "POST /api/issues/{id}/stalled-review-decision", - "POST /api/agents/{id}/runtime-state/reset-session", - "POST /api/approvals/{id}/resubmit", - ]) { - expect(() => - validateRunnerApiCall( - { operationId, pathParams: { id: "fixture" } }, - context, - ), - ).toThrow("cannot bypass"); + it("keeps protocol clients separate from REST dispatch", () => { + const protocols = runnerApiCatalog().filter(operation => operation.transport === "protocol"); + expect(protocols.some(operation => operation.path.endsWith("/events/ws"))).toBe(true); + for (const operation of protocols) { + expect(() => validateRunnerApiCall({ operationId: operation.operationId }, context)).toThrow("protocol client"); } }); it("keeps skill-test mode consistent with the advertised tool contract", () => { @@ -592,24 +516,13 @@ describe("runner API request boundary", () => { ).operation.method, ).toBe("POST"); }); - it("cannot hide lifecycle mutations in a raw uploaded JSON body", () => { - for (const operationId of [ - "PATCH /api/issues/{id}", - "PATCH /api/agents/{id}", - "POST /api/issues/{id}/comments", - ]) { - expect(() => - validateRunnerApiCall( - { - operationId, - pathParams: { id: "fixture" }, - files: [{ path: "hidden-status.json" }], - contentType: "application/json", - }, - context, - ), - ).toThrow("inline JSON object"); - } + it("passes uploaded JSON bodies through the same route validation as REST", async () => { + const request = vi.fn(async () => Response.json({ error: "Board access required" }, { status: 403 })); + const transport = io(request); + transport.readFile = async () => ({ bytes: Buffer.from('{"status":"idle"}'), filename: "update.json", contentType: "application/json" }); + expect(await executeRunnerApi({ operationId: "PATCH /api/agents/{id}", pathParams: { id: "fixture" }, files: [{ path: "update.json" }], contentType: "application/json" }, context, transport)) + .toMatchObject({ ok: false, status: 403 }); + expect(Buffer.from(request.mock.calls[0][1]?.body as Uint8Array).toString()).toBe('{"status":"idle"}'); }); it("reads text windows from a live response and supplies stable snapshots", async () => { const text = "prefix\n" + "🧭é\n".repeat(6000) + "END-OF-EVIDENCE"; From df06df2306405a1a7add6a48df032d8ac4a05959 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:40:46 -0500 Subject: [PATCH 06/85] fix(slack): allow direct messages without at-mentions (#16151) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connections let people start and continue agent tasks. > - The Require at-mention setting currently applies to channels and direct messages. > - A person who sends a direct message has already selected the agent. > - This pull request exempts direct messages from mention checks, including queued work. > - People can talk normally in DMs while channel messages keep the configured mention rule. ## Linked Issues or Issue Description Refs #15612, which introduced the Require at-mention setting. Searches found no duplicate PR for the DM exemption. **What happened?** With Require at-mention enabled, a plain Slack DM does not start or continue agent work. New Slack connections enable the setting by default. **Expected behavior** Direct messages do not need an at-mention. Top-level DM replies remain top-level. Channel messages and channel thread replies still follow the saved mention setting. **Steps to reproduce** 1. Enable Allow direct messages and Require at-mention on a Slack connection. 2. Send a plain DM from an authorized sender. 3. Confirm the agent responds without an at-mention. Send a plain follow-up and confirm it also works. 4. Confirm unmentioned channel messages remain filtered when Require at-mention is enabled. 5. Disable Allow direct messages and confirm new DMs remain blocked. **Paperclip version or commit** Source baseline: `eb06c9fedd`. **Deployment mode** Built from source. Automated tests use disposable local Postgres and provider fixtures. This change has not been deployed or retested in live Slack. ## What Changed - Exempt DMs at inbound admission, task mutation, work authorization, and heartbeat queue claim. - Keep channel mention checks and all existing sender, endpoint, and destination checks. - Update regressions for new connections, follow-ups, deferred delivery, and coalesced queued messages. - Clarify the Slack settings copy, connection documentation, and verification maps. - Refresh the affected Storybook review references after source inspection. ## Verification - Passed 55 Slack and durable scheduler tests with Vitest on the current rebased commit, including admission through the extracted inbound module. - Passed `pnpm -r typecheck` and `pnpm build` on the rebased PR commit. - Passed token gates and map checks. - Passed Storybook catalog validation after rebase: 223 files and 2,210 stories. - Full local `pnpm test:run` was stopped before completion after three failures in the unchanged tool-access suite. All 418 tests in that suite passed in a standalone rerun. The focused regressions passed; full GitHub CI remains the merge gate. - Greptile reviewed commit `b0293fa6571403c5e01c30bb7b5fcc5e91b88c97`: 5/5, with no findings or unresolved review threads. All 55 GitHub checks passed on this commit; two optional Storybook jobs were skipped. - No live Slack messages were sent during implementation verification. ## Risks Existing Slack connections now accept authorized plain DMs even when Require at-mention is enabled. Allow direct messages and sender authorization still apply. Queued DM work follows the same exemption. Channel behavior and DM reply routing are unchanged. No schema or migration change is required. ## Model Used OpenAI GPT-6, running as Codex, with reasoning, tool use, and code execution. 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 - [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/connections/SLACK-TASK-TOOLS.md | 20 +++++--- maps/agent-access-map/chat-channels.md | 8 +++ maps/feature-map/chat-channels.md | 6 +++ maps/storybook-map/REVIEW.md | 20 ++++++++ maps/storybook-map/catalog.json | 48 +++++++++--------- .../chat-channels.integration.test.ts | 50 +++++++++++-------- .../src/__tests__/durable-chat-wakeup.test.ts | 15 +++--- server/src/services/chat-channels.ts | 2 + .../chat-channels/inbound/admission.ts | 1 + server/src/services/durable-chat-wakeup.ts | 4 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 2 +- 11 files changed, 114 insertions(+), 62 deletions(-) diff --git a/doc/connections/SLACK-TASK-TOOLS.md b/doc/connections/SLACK-TASK-TOOLS.md index 02b54daf82c..892426ac34c 100644 --- a/doc/connections/SLACK-TASK-TOOLS.md +++ b/doc/connections/SLACK-TASK-TOOLS.md @@ -198,16 +198,20 @@ people's bot DMs remain inaccessible. Scheduling uses ordinary Paperclip routine not a Slack-specific timer. Routine results are sent explicitly through the tool; ordinary task finals are not automatically broadcast to Slack. -## Require an at-mention for inbound messages +## Require an at-mention for inbound channel messages Slack app settings include **Require at-mention** directly below **Allow direct messages**. It defaults to on for new Slack connections. Existing connections -keep their saved setting and thread and DM behavior. -When enabled, each inbound message must explicitly @mention the connected bot, -including replies in existing channel threads and direct messages. DMs must also -be enabled with **Allow direct messages**. Unmentioned messages do not create -or continue a task. Queued messages are checked again before starting agent work. -If a queued run combines several messages, an unmentioned input cancels that -queued run when this setting is enabled. Send a new mention to start it again. +keep their saved setting; the setting applies only to channels. +When enabled, each inbound channel message must explicitly @mention the connected +bot, including replies in existing channel threads. Direct messages never require +an @mention, but must be enabled with **Allow direct messages** and pass the usual +sender authorization checks. Top-level DMs remain a linear conversation with +top-level replies; use a private channel for conversations organized into threads. +Unmentioned channel messages do not create or continue a task. Queued messages +are checked again before starting agent work. If a queued run combines several +channel messages, an unmentioned input cancels that queued run when this setting +is enabled. Send a new mention to start it again. Unmentioned DM inputs do not +trigger this cancellation. This setting does not cancel work that has already started or revoke its Slack tool access. diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md index 58fe31ce533..cc852db37d8 100644 --- a/maps/agent-access-map/chat-channels.md +++ b/maps/agent-access-map/chat-channels.md @@ -83,6 +83,14 @@ remaining operations before qualifying the whole capability. Gaps: Normal agent replies work through server delivery. Explicit endpoint publication APIs are board management paths. +Slack's **Require at-mention** policy applies to channel messages and channel +thread replies, including queued work. DMs are exempt from mention checks; +endpoint availability, **Allow direct messages**, and sender authorization still +apply. Top-level DM replies retain their linear conversation destination. Source: +[admission](../../server/src/services/chat-channels.ts) and +[queued run policy](../../server/src/services/durable-chat-wakeup.ts). Live Slack +verification of this exemption remains separate from provider-fixture coverage. + New conversations resolve typed project/workspace defaults from their endpoint and destination under the task-creation transaction. Defaults do not authorize private sources: admission checks the linked user's authority and assigned agent. diff --git a/maps/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md index a5437be5639..edc4337e531 100644 --- a/maps/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -38,6 +38,12 @@ Automated: [channel integration](../../server/src/__tests__/chat-channels.integr Manual: For each supported provider being changed, send a unique message, verify the intended agent/company receives it, and inspect the actual external reply in the original thread. Repeat with an unlinked/unauthorized sender, duplicate inbound delivery, and one supported attachment. Record provider-specific results separately. +For Slack, enable **Require at-mention** and **Allow direct messages**. Send a +plain DM and follow up without a mention: both should reach the agent, with +top-level replies to top-level DMs. Channel messages and thread replies still +require a mention when that setting is enabled. Disable direct messages and +confirm a plain DM is rejected. Use a private channel for threaded conversations. + ### `endpoint-management` Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index 68802f3ab20..90aa33a7744 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -331,3 +331,23 @@ All 223 authored suites and 2,210 authored story IDs remain. Generated feature pages still cover 35 areas and 171 capabilities. Catalog/generator tests (16), catalog validation, map checks and token gates pass. This is source reconciliation, not a new visual or live production acceptance claim. + +## Slack DM mention exemption — 2026-10-13 + +Scoped source review: Slack DMs no longer require an at-mention, while channel +messages and channel thread replies retain the saved mention policy. Top-level +DM replies remain linear. The production `ChatEndpointDetail` settings copy and +the feature-map manual recipe now state this distinction. + +The Chat management story mounts that production settings page and merges +endpoint PATCH values in its local fixture. GitHub bot management and Photon +also reference the page, but do not render its Slack-only toggle. No story UI, +fixture behavior, or story identity changed, and no browser proof is claimed. + +After rebasing onto master `60bc0d5fee`, the upstream catalog reconciliation +supplies the reviewed baseline for the previously stale entries. Refreshed the +fourteen entries affected by this change: all reference the updated chat recipe, +and three also reference the Slack-only settings copy. Other source bytes and +all classifications, fixture boundaries, story IDs and sidebar titles remain +unchanged from that baseline. Catalog checks validate these references and +fingerprints, not live Slack behavior. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index a609ad0a97c..55388ec6fea 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -637,7 +637,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -646,7 +646,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx" ], - "fingerprint": "e25771274378d2ff2f2447b2841ff20b91319dba4e50f697c5b2c6cc74698b4b", + "fingerprint": "22c79d5f6a714efbaeaaa0023284707b696aa73cddbb66067d8c5a3dde116859", "stableId": "connections-agentmail-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup" }, @@ -660,13 +660,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/EmailEndpointSetup.tsx", "ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx" ], - "fingerprint": "a57256936459ba14d7ad8e0a73ed494c0f269c294240a67c0d27af555e0c736d", + "fingerprint": "2fb050825b457f876fa18d08cd101093d19187f47d7cd8b5a51c871d28d7553e", "stableId": "connections-agentmail-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings" }, @@ -690,7 +690,7 @@ "ui/src/components/task-chat/presentation-mode.tsx", "ui/storybook/stories/chat-channels/agentmail.stories.tsx" ], - "fingerprint": "55ba1a896505c14b5f7531c5dcc2d870f981344a23fe806d25a90350a61a7f33", + "fingerprint": "5f4cd880d1c6baa0c88dc65bf5919cf451ea3c4bdd8b479ee2e3d2906ce1bd1c", "stableId": "connections-agentmail-tasks", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks" }, @@ -1391,13 +1391,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/Browse.tsx", "ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx" ], - "fingerprint": "b5c4a820f42d7b3d5e665161b95056b80b5591f6100e6847488eea49b876d36d", + "fingerprint": "bd604a05920f1015bac52ede82ba83726bf4d79126b7f8f8c8afcd5aa4aca630", "stableId": "connections-chat-connection-actions", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions" }, @@ -1459,7 +1459,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx" ], - "fingerprint": "2fab3391785dd764f6c17819a6ac9f2a2388ffc4af0cbc2e4590580e3de21a09", + "fingerprint": "1b095845d727d6703d3cfb3b27120927c8ac8b8fd4f2950770c7c5f4aed44d3b", "stableId": "connections-chat-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management" }, @@ -2551,7 +2551,7 @@ "ui/storybook/support/github-bot-management/fixture-api.tsx", "ui/storybook/support/github-bot-management/fixtures.ts" ], - "fingerprint": "8dd50a9e507f68818ee119efaa9c90ea01ecf0ec82721abcf0e6f7d489de6cbb", + "fingerprint": "bc53bda552a9a7f4835d2c78ec0dc66492b5804db227b9adce191f4d6dc27c53", "stableId": "connectors-github-bot-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management" }, @@ -2566,7 +2566,7 @@ "implementation": "prototype", "boundary": "fixtures", "notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/components/GitHubAgentTrustWarning.tsx", @@ -2578,7 +2578,7 @@ "ui/storybook/stories/chat-channels/github-chat.stories.tsx" ], "replacement": "ui/storybook/stories/chat-channels/github-connections.stories.tsx", - "fingerprint": "1b98e4a1e023630f9d27105f47ee8e04c7e49e31f1eb77430e55cf0518bca033", + "fingerprint": "efb2ea9851242dfd79874e3a3c9bc46b17b7728e2b10f6b2127a2437638f0499", "stableId": "historical-github-chat-prototypes", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes" }, @@ -2593,7 +2593,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -2604,7 +2604,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/github-connections.stories.tsx" ], - "fingerprint": "1db66005c3710754f307003ce6907073e8eca0319074c323d57992c0b26d8b23", + "fingerprint": "53ef79816b0825618e15a4bbb501df31efea9a1fb62ccdf066a81e26c71a838c", "stableId": "connections-github-and-code-review-bot", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot" }, @@ -2756,7 +2756,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" ], - "fingerprint": "002c59e80ccd20170b8ed432e8343395414df94b724c858006447735b0afc680", + "fingerprint": "c8ba561728c0c418b6b2083cf72cb09712b96415a66285267ec46abc9d7c26bc", "stableId": "connections-imessage-photon", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" }, @@ -4832,7 +4832,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -4842,7 +4842,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx" ], - "fingerprint": "df0600372c76d7ff7eb15ccc14a7a5bf7f0b5f5d64a366013686b6aaf888ee00", + "fingerprint": "6d8c13f5ebed08a43b9f146f49a2506c9c5e1ac9305121b94e9cd5bb9c060476", "stableId": "connections-slack-automatic-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup" }, @@ -4856,7 +4856,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", @@ -4864,7 +4864,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx" ], - "fingerprint": "725869cfc3280b8384899e0f692bcf17663368f389c38fe1a16ec34c27faf20d", + "fingerprint": "c6688ca3e157bbbd52554dff87cbe1842db9f993b78d07e810492372835d2f98", "stableId": "connections-slack-avatar-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings" }, @@ -4878,7 +4878,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", @@ -4886,7 +4886,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx" ], - "fingerprint": "f0d8df504be6e6e5a1ea09b868ee8a75e35d76041a792f48955dc1e834297cb6", + "fingerprint": "176a29291c7a696273dbbe50622c0dc845015adc119c9ae162dc1c957943a177", "stableId": "connections-slack-communication-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings" }, @@ -4901,7 +4901,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -4911,7 +4911,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx" ], - "fingerprint": "24e3f986568fad11d604fddc48db890e2c75091b8b7c0ff916c6d609dd4c033d", + "fingerprint": "59d4e5ebb276a491e22d6461fe9c9bab36778097d15ab2203d6cd95a4151e371", "stableId": "connections-slack-managed-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup" }, @@ -4925,13 +4925,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/SlackToolSettings.tsx", "ui/storybook/stories/chat-channels/slack-tools.stories.tsx" ], - "fingerprint": "9eaae8672c83f1be3d6bdea09e6a77fd62f2828ab410addfe82f9c08c3c34a4a", + "fingerprint": "20189b2032c2aecf3242e8d086fed9ca0b02d7adaa80aff9a69b60f1b23bce18", "stableId": "connections-slack-task-tools", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools" }, diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 142707f41d7..0b5fe88772b 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -8020,7 +8020,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.shutdown(); }); - it.each([false, true])("requires at-mentions by default for new Slack connections (DM=%s)", async (isDM) => { + it.each([false, true])("requires at-mentions by default only in Slack channels (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { requireAtMention: true }); // The returned endpoint is the original creation result, before fixture updates. @@ -8030,12 +8030,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const send = (id: string, mentioned: boolean) => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, message: makeMessage({ id, text: mentioned ? "@maya start" : "Start", mentioned }), trigger: isDM ? "direct_message" : mentioned ? "mention" : "subscribed_message" }); await send("7500.1", false); - expect(wakeup).not.toHaveBeenCalled(); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0); await send("7500.2", true); - expect(wakeup).toHaveBeenCalledTimes(1); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 2 : 1); await service.update(endpoint.id, { requireAtMention: false }); await send("7500.3", false); - expect(wakeup).toHaveBeenCalledTimes(2); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2); const other = await service.create(fixture.companyId, { provider: "github", assignedAgentId: fixture.assignedAgentId }); expect(other.requireAtMention).toBe(false); expect((await service.get(endpoint.id)).requireAtMention).toBe(false); @@ -8063,11 +8063,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ slackUserId: "UBOARD" }); }); - it.each([false, true])("requires Slack at-mentions for every message when enabled (DM=%s)", async (isDM) => { + it.each([false, true])("applies Slack mention settings to channel replies while exempting DMs (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture); expect((await service.get(endpoint.id)).requireAtMention).toBe(false); - const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: `slack:${isDM ? "D-MENTION" : "C-MENTION"}:7100.1`, isDM }); + const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: isDM ? "slack:D-MENTION:" : "slack:C-MENTION:7100.1", isDM }); const send = (id: string, mentioned: boolean, trigger: ChatSdkMessageTrigger = isDM ? "direct_message" : "subscribed_message") => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, message: makeMessage({ id, text: mentioned ? "@maya continue" : "Continue", mentioned }), trigger }); @@ -8076,47 +8076,53 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await send("7100.2", false); expect(wakeup).toHaveBeenCalledTimes(2); expect((await service.update(endpoint.id, { requireAtMention: true })).requireAtMention).toBe(true); - await send("7100.3", false); - expect(wakeup).toHaveBeenCalledTimes(2); + await send("7100.3", false, "subscribed_message"); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2); const [filtered] = await db.select().from(chatDeliveries).where(and( eq(chatDeliveries.endpointId, endpoint.id), eq(chatDeliveries.state, "filtered"))); - expect(filtered.redactedError).toBe("Message did not @mention the bot"); - expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); - expect(filtered.normalizedEvent).not.toHaveProperty("message.text"); + if (isDM) { + expect(filtered).toBeUndefined(); + } else { + expect(filtered.redactedError).toBe("Message did not @mention the bot"); + expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); + expect(filtered.normalizedEvent).not.toHaveProperty("message.text"); + } // Both Slack callback kinds can carry an explicit mention. Retries cannot // produce a second wakeup for the same provider message. await send("7100.4", true); await send("7100.4", true, "mention"); - expect(wakeup).toHaveBeenCalledTimes(3); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 4 : 3); const conversations = await readChatConversationsByEndpointId(endpoint.id); expect(conversations).toHaveLength(1); const comments = await readIssueCommentsByIssueId(conversations[0].issueId); - expect(comments).toHaveLength(3); + expect(comments).toHaveLength(isDM ? 4 : 3); + expect(conversations[0].externalThreadId).toBe(thread.thread.id); await service.update(endpoint.id, { requireAtMention: false }); await send("7100.5", false); - expect(wakeup).toHaveBeenCalledTimes(4); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 5 : 4); if (isDM) { await service.update(endpoint.id, { requireAtMention: true, allowDirectMessages: false }); - await send("7100.6", true); - expect(wakeup).toHaveBeenCalledTimes(4); + await send("7100.6", false); + expect(wakeup).toHaveBeenCalledTimes(5); } }); - it("filters a queued Slack message when Require at-mention is enabled before drain", async () => { + it.each([false, true])("rechecks queued Slack channel mentions but exempts DMs before drain (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { deferWebhookProcessing: true, scheduleDeferredWork: () => undefined, }); - const thread = makeThread({ channelId: "D-QUEUED-MENTION", id: "slack:D-QUEUED-MENTION:7300.1", isDM: true }); + const channelId = isDM ? "D-QUEUED-MENTION" : "C-QUEUED-MENTION"; + const thread = makeThread({ channelId, id: `slack:${channelId}:${isDM ? "" : "7300.1"}`, isDM }); await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, - message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: "direct_message" }); + message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: isDM ? "direct_message" : "subscribed_message" }); await service.update(endpoint.id, { requireAtMention: true }); const [delivery] = await readChatDeliveriesByEndpointId(endpoint.id); await db.update(chatDeliveries).set({ nextAttemptAt: new Date(0) }).where(eq(chatDeliveries.id, delivery.id)); await service.processPendingDeliveries(25, delivery.id); - expect(wakeup).not.toHaveBeenCalled(); - expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(0); - expect((await readChatDeliveriesById(delivery.id))[0].state).toBe("filtered"); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0); + expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(isDM ? 1 : 0); + expect((await readChatDeliveriesById(delivery.id))[0].state).toBe(isDM ? "processed" : "filtered"); }); it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => { diff --git a/server/src/__tests__/durable-chat-wakeup.test.ts b/server/src/__tests__/durable-chat-wakeup.test.ts index 3afa386bfef..a979df891c2 100644 --- a/server/src/__tests__/durable-chat-wakeup.test.ts +++ b/server/src/__tests__/durable-chat-wakeup.test.ts @@ -196,9 +196,12 @@ describe("durable inbound chat scheduler receipts", () => { }; } - it.each(["unmentioned", "mentioned", "coalesced", "recovery", "deferred"])("checks Slack mentions after a real heartbeat queue wait (%s)", async (scenario) => { + it.each([ + ...["unmentioned", "mentioned", "coalesced", "recovery", "deferred"].map((scenario) => ({ scenario, isDM: false })), + ...["unmentioned", "coalesced"].map((scenario) => ({ scenario, isDM: true })), + ])("checks Slack channel mentions after a real heartbeat queue wait ($scenario, DM=$isDM)", async ({ scenario, isDM }) => { const mentioned = scenario === "mentioned" || scenario === "coalesced"; - const allowed = scenario === "mentioned" || scenario === "recovery"; + const allowed = isDM || scenario === "mentioned" || scenario === "recovery"; const f = await fixture(); await db.update(agents).set({ adapterType: "durable_chat_retry_test" }).where(eq(agents.id, f.agentId)); const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID(); @@ -211,8 +214,8 @@ describe("durable inbound chat scheduler receipts", () => { await db.insert(issueComments).values({ id: request.commentId, companyId: f.companyId, issueId: f.issueId, authorUserId: "board-user", body: mentioned ? "@bot work" : "Work" }); const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, - providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed", - normalizedEvent: { trigger: "direct_message", message: { mentionedBot: mentioned } } }).returning(); + providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: mentioned } } }).returning(); await db.insert(chatActions).values({ id: request.id, companyId: f.companyId, endpointId, deliveryId: delivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: request.commentId } }); @@ -233,8 +236,8 @@ describe("durable inbound chat scheduler receipts", () => { await db.insert(issueComments).values({ id: second.commentId, companyId: f.companyId, issueId: f.issueId, authorUserId: "board-user", body: scenario === "deferred" ? "@bot follow-up" : "Unmentioned follow-up" }); const [secondDelivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, - providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed", - normalizedEvent: { trigger: "direct_message", message: { mentionedBot: scenario === "deferred" } } }).returning(); + providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: scenario === "deferred" } } }).returning(); await db.insert(chatActions).values({ id: second.id, companyId: f.companyId, endpointId, deliveryId: secondDelivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: second.commentId } }); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index f4fd375d29e..729af208be0 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -2729,6 +2729,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if ( current.endpoint.provider === "slack" && current.endpoint.requireAtMention && + !current.conversation.isDirectMessage && current.delivery.normalizedEvent.trigger !== "mention" && (current.delivery.normalizedEvent.message as { mentionedBot?: boolean } | undefined)?.mentionedBot !== true ) { @@ -4227,6 +4228,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const mentionStillAllowed = currentEndpoint.provider !== "slack" || !currentEndpoint.requireAtMention || + thread.isDM || trigger === "mention" || message.isMention === true; if ( diff --git a/server/src/services/chat-channels/inbound/admission.ts b/server/src/services/chat-channels/inbound/admission.ts index 13fd1c0ae61..69ea9cdac5e 100644 --- a/server/src/services/chat-channels/inbound/admission.ts +++ b/server/src/services/chat-channels/inbound/admission.ts @@ -168,6 +168,7 @@ export function createInboundAdmission(db: Db, options: InboundAdmissionOptions) const mentionRequired = endpoint.provider === "slack" && currentEndpoint.requireAtMention && + !thread.isDM && trigger !== "mention" && message.isMention !== true; // GitHub sends the same comment to every installed App. Subscribed diff --git a/server/src/services/durable-chat-wakeup.ts b/server/src/services/durable-chat-wakeup.ts index ed248f579dc..c00ad561b18 100644 --- a/server/src/services/durable-chat-wakeup.ts +++ b/server/src/services/durable-chat-wakeup.ts @@ -322,6 +322,8 @@ export async function slackMentionAllowsRunStart( sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${run.wakeupRequestId}`, ), )); - return inputs.every(({ event }) => event.trigger === "mention" || + return inputs.every(({ event }) => + (event.conversation as { isDirectMessage?: boolean } | undefined)?.isDirectMessage === true || + event.trigger === "mention" || (event.message as { mentionedBot?: boolean } | undefined)?.mentionedBot === true); } diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 2c07fc4d894..b2055f78f75 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -590,7 +590,7 @@ function Settings({ {endpoint.provider === "slack" && ( From 2ad36f4ff270a7e8e7171735f91c0468ea548832 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:13 -0500 Subject: [PATCH 07/85] Fix skill source removal with a keep-or-delete choice (#16150) 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. > - GitHub sources import skills and keep their updates together. > - Disconnect only disabled a source and kept its read-only imports. > - A disabled source could not be removed or imported again. > - Removing a source needs an explicit decision about its imported skills. > - This pull request removes the source after the user keeps editable copies or deletes the imports. > - Existing disabled sources use the same removal flow. ## Linked Issues or Issue Description Refs #15731, #14713. **What happened?** Disconnect left the repository on Sources. Its Disconnect action was then disabled. The saved repository still blocked a new import. **Expected behavior** Remove the repository from Sources. Require a choice to keep editable copies or delete its imports. Allow removal of sources that are already disconnected. **Steps to reproduce** 1. Import a repository in Skills → Sources. 2. Choose Disconnect source. 3. Observe the disconnected row and disabled removal action. 4. Try to import the repository again. **Paperclip version or commit** d67e3507b23041212fa26302ce6de078c5ebcc05. ## What Changed - Add one confirmation with a required keep-or-delete choice. - Remove the source and entries, including legacy disabled sources. - Keep complete editable copies in My Skills, including shared repository files and executable modes. Preserve skill IDs, keys, versions, and agent assignments. - Delete imported skills and source in one transaction with its activity record. Check policy for every linked skill, including deselected entries. Reject deletion of skills still used by agents. - Fence removal with the source revision. Reload stale confirmations and require a new choice. Prevent an in-flight refresh from restoring removed imports. - Reject incomplete legacy copies with a recovery message. Lock copied skill rows against concurrent deletion and check publication succeeded. - Add regression tests and production-page Storybook examples. Make an initially unselected radio-card group keyboard accessible. ## Verification - Workspace typecheck and build passed. Final affected server/UI typechecks passed. - Focused server, UI, and API suites passed: 114 tests initially. After review fixes, all 21 source persistence and 58 route authorization tests passed, including the complete repository copy regression; affected server/UI typechecks and token gates passed again. - Design token gates passed. - Real isolated app: imported a public repository, assigned bro to the CEO, verified that deletion reports the assignment, kept an editable copy, observed the source disappear, opened the copy in My Skills, saved an edit, and refreshed. The assignment remained. Re-imported the same repository, deleted its new unused import, and verified the original editable copy and assignment remained. No model tasks ran. - Full local Vitest was attempted but terminated with exit 137 before completion; it is not counted as a pass. Current-head sharded CI is pending. Greptile reviewed the current head at 5/5 with no new actionable comments. The final workspace build passed again. - Storybook catalog aligned. The legacy removal example was inspected in the browser; the Delete choice enables removal. - Storybook: Skills / GitHub-synced skills / Sources · Disconnect source and Sources · Remove disconnected source. ## Risks - DELETE of a skill source now requires the saved revision and an explicit copy/delete choice. Old callers must update their request body. - Deletion removes imported skills and their records. Assigned skills must be detached first. - Incomplete legacy versions must be refreshed before keeping copies; removal does not silently discard supporting files. - Copies intentionally stop receiving upstream updates. Their IDs and keys remain stable for assigned agents. - No database migration. Copy writes use new managed directories and clean up on failure. Delete restores quarantined files before releasing publication locks on failure. ## Model Used OpenAI GPT-6 through Codex, configured model `gpt-6-astra`. The runtime does not expose the context-window size. Used shell tools, code editing, tests, and browser interaction. ## 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 | 6 +- doc/SPEC-implementation.md | 11 +- maps/storybook-map/INDEX.md | 4 +- maps/storybook-map/catalog.json | 22 +-- packages/shared/src/types/skill-source.ts | 10 ++ .../shared/src/validators/skill-source.ts | 5 + .../__tests__/company-skills-routes.test.ts | 8 + server/src/__tests__/skill-sources.test.ts | 138 +++++++++++++++++- server/src/routes/company-skills.ts | 9 +- server/src/services/company-skills.ts | 137 +++++++++++------ server/src/services/skill-sources.ts | 62 +++++--- ui/src/api/skillSources.test.ts | 10 ++ ui/src/api/skillSources.ts | 4 +- ui/src/components/ui/radio-card.tsx | 8 +- ui/src/pages/SkillSources.test.tsx | 71 +++++++++ ui/src/pages/SkillSources.tsx | 65 ++++++++- ui/storybook/fixtures/githubSkillSources.ts | 10 +- .../skills/github-synced-skills.stories.tsx | 26 +++- 18 files changed, 505 insertions(+), 101 deletions(-) diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 27b2b1accd6..c95adef49b7 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -85,7 +85,11 @@ skills through their existing GitHub connection and manually refresh installed snapshots. The source records repository, branch, path, and installed commit; the connection supplies caller-authorized access. Originals remain viewable and testable, with independent editable copies. New upstream skills require selection, -and deselection, source disconnection, or upstream removal retains installed content. +and deselection or upstream removal retains installed content. Disconnecting removes +the source after an explicit choice: keep independent editable copies in My Skills, +or delete its imported skills. Copies preserve agent assignments; skills still +used by agents must be detached before deletion. Already-disconnected sources +can be removed through the same flow. An explicit restricted policy may deny selected operations or switch to a default-deny preset with explicit allow rules. Core exposes a stable versioned policy API so EE and other administrative clients configure and simulate the same evaluator used by skill mutation routes. Core Skill Studio only needs to perform normal skill work, explain an explicit denial, and point administrators to EE when its richer policy UI is available; it must not recreate a partial enterprise permission editor. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 29f20621639..9d029961800 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2077,8 +2077,17 @@ unavailable. Preserve current ownership and newer-work fences. See runs use local installed snapshots; pinned/active runs retain their versions. - Additions require reviewed selection, with new candidates checked by default and explicit folder exclusions retained. Upstream deletion keeps installed content with - removed status; moves are removal plus discovery. Deselect/disconnect retains content + removed status; moves are removal plus discovery. Deselect retains content and assignments. Originals are read-only, with the existing independent fork workflow. +- Source disconnection requires a saved revision and an explicit copy/delete choice. + Remove the source and all entries to allow a fresh repository import, including + legacy disabled sources. Copy materializes complete local files in the caller's + My Skills folder, retaining skill IDs, keys, versions, and agent assignments. + Delete applies skills.remove to every linked skill (including deselected entries), + rejects skills still used by agents, and removes skills and source atomically with + the activity record. Copy applies skills.edit and skills.create to every linked + skill; failed writes or audit persistence leave the source and imported skills intact. + - Adopt recognizable existing GitHub imports without provider calls or content changes; resolve missing repository IDs and full snapshots on successful refresh. Exclude bundled, catalog, local/project, skills.sh, and unsupported hosts. Existing GitHub import and update diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index 43a39b5a4c9..eb190f50ce3 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -4,7 +4,7 @@ [Contract and maintenance](README.md) · [Review findings](REVIEW.md) -223 story files; 2210 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. +223 story files; 2212 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file. @@ -254,7 +254,7 @@ Discovery, sources, authoring, revisions, tests, and agent policies. | [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy](../../ui/storybook/stories/skills/fork-skill-flow.stories.tsx) | `skills/authoring` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/skills/frontmatter-panel.stories.tsx) | `skills/authoring` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 14 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 19 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 21 / 8 | Production Sources page with fixture APIs. Disconnect and legacy removal stories show a required keep-or-delete choice; the real copy, delete, and reimport flows were also tested locally. Other routes remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) | `skills/policy` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 55388ec6fea..6c7ca47655c 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -2619,7 +2619,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", "ui/src/components/Layout.tsx", @@ -2630,7 +2630,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/skills/github-skills-journey.stories.tsx" ], - "fingerprint": "dcb8e4ce80ddc71f25542b9d84cdf1ce20e552da2848404e62d61f0687e3fc3f", + "fingerprint": "04b12de08d13813c1332d46f9bed8164f329aaf73ea0c57cbf83ecb6d3a4c6b1", "stableId": "skills-github-sync-journey", "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey" }, @@ -2644,8 +2644,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Production Sources page with fixture APIs. Disconnect and legacy removal stories show a required keep-or-delete choice; the real copy, delete, and reimport flows were also tested locally. Other routes remain unreviewed.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", "ui/src/pages/SkillSources.tsx", @@ -2653,7 +2653,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/skills/github-synced-skills.stories.tsx" ], - "fingerprint": "f87c8ee2574a097ef4e4828589618ad196f170fb652db022bc7488b3fb7971e2", + "fingerprint": "c14509e4b57e7587f560824f417c8439dbb6bc3d303d37dec169ab819c867d36", "stableId": "skills-github-synced-skills", "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills" }, @@ -3669,7 +3669,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/ui/alert-dialog.tsx", "ui/src/components/ui/avatar.tsx", @@ -3685,7 +3685,7 @@ "ui/storybook/stories/foundations/primitives-coverage.stories.tsx" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "b8a0afd9a2fefb2e89e3215ca86a7dea3673f254d361f7d821d43c13c2a795bc", + "fingerprint": "2fba85501507d80dae7b3c202c083d9ac32c796ee8e234a4fecc6a80b33fa35c", "stableId": "foundations-primitive-coverage", "sidebarTitle": "Foundations/Primitive Coverage" }, @@ -4404,7 +4404,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", "ui/src/components/Layout.tsx", @@ -4416,7 +4416,7 @@ "ui/storybook/stories/skills/github-skills-journey.stories.tsx", "ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx" ], - "fingerprint": "a90fb6c9377034d3645b851711ddc84eb3b9945dce0cdea4496cb9a4050fb8ad", + "fingerprint": "d8af830b23aa96f0a92b898b16c090e76fe4e9b986517fbf72d9cdedf840e7e4", "stableId": "skills-repository-packages", "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages" }, @@ -4749,7 +4749,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", "ui/src/pages/skills/SkillImportProgress.tsx", @@ -4757,7 +4757,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/skills/skill-import-progress.stories.tsx" ], - "fingerprint": "55477a243eb0cd940e20e47b0c2e388af26268930be5ee516043a8812f3ab0e5", + "fingerprint": "4f517ad1f4e9a390e29d2f5f31bd78209179043f4253817b5a2788c8a4824a6c", "stableId": "skills-import-progress", "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress" }, diff --git a/packages/shared/src/types/skill-source.ts b/packages/shared/src/types/skill-source.ts index 06893575fbb..da457304fb1 100644 --- a/packages/shared/src/types/skill-source.ts +++ b/packages/shared/src/types/skill-source.ts @@ -141,3 +141,13 @@ export interface SkillSourceRefreshResult { unchanged: number; warnings: string[]; } + +export interface SkillSourceDisconnectRequest { + revision: number; + skills: 'copy' | 'delete'; +} +export interface SkillSourceDisconnectResult { + id: string; + copiedCount: number; + deletedCount: number; +} diff --git a/packages/shared/src/validators/skill-source.ts b/packages/shared/src/validators/skill-source.ts index cbff4cb5158..9f9c7714a6a 100644 --- a/packages/shared/src/validators/skill-source.ts +++ b/packages/shared/src/validators/skill-source.ts @@ -25,3 +25,8 @@ export const skillSourceSelectionSchema = z.object({ excludedFolders: z.array(repoPath).max(10000), connectionId: z.string().uuid().nullable().optional(), }); + +export const skillSourceDisconnectSchema = z.object({ + revision: z.number().int().nonnegative(), + skills: z.enum(['copy', 'delete']), +}); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 044595df762..4b2d670db53 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -1293,6 +1293,14 @@ describe("company skill mutation permissions", () => { } }); + it("requires an explicit skill disposition and revision when removing a source", async () => { + const app = createApp({ type: "board", userId: "member", companyIds: ["company-1"], source: "session" }); + const path = "/api/companies/company-1/skill-sources/source-id"; + for (const body of [{}, { revision: 0 }, { skills: "delete" }, { revision: 0, skills: "ignore" }]) { + await request(app).delete(path).send(body).expect(400); + } + }); + it("rejects cross-company source reads and mutations for board users and agents", async () => { for (const actor of [ { type: "board", userId: "member", companyIds: ["company-1"], isInstanceAdmin: true, source: "session" }, diff --git a/server/src/__tests__/skill-sources.test.ts b/server/src/__tests__/skill-sources.test.ts index 7440aaf4004..e0e7f8bd800 100644 --- a/server/src/__tests__/skill-sources.test.ts +++ b/server/src/__tests__/skill-sources.test.ts @@ -5,7 +5,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, createDb } from '@paperclipai/db'; +import { agents, folders, companySkillSources, companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, createDb } from '@paperclipai/db'; import { eq, sql } from 'drizzle-orm'; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from './helpers/embedded-postgres.js'; import { unprocessable } from '../errors.js'; @@ -81,8 +81,9 @@ describe.skipIf(!support.supported)('skill source persistence', () => { expect((await promisify(execFile)(path.join(staged.source, 'scripts/run.sh'))).stdout.trim()).toBe('hello'); } finally { fetchSpy.mockRestore(); } await expect(sources.refresh(companyId, result.source.id, context, { revision: 0, selectedPaths: [], excludedFolders: [] })).rejects.toThrow(/changed/); - const disconnected = await sources.disconnect(companyId, result.source.id, context); - expect(disconnected.enabled).toBe(false); + const disconnected = await sources.disconnect(companyId, result.source.id, context, { revision: (await sources.detail(companyId, result.source.id)).revision, skills: 'copy' }); + expect(disconnected.copiedCount).toBe(2); + await expect(sources.detail(companyId, result.source.id)).rejects.toThrow(/not found/); expect(await skills.getById(companyId, skill.id)).not.toBeNull(); await expect(sources.detail(randomUUID(), result.source.id)).rejects.toThrow(/not found/); }); @@ -186,12 +187,37 @@ describe.skipIf(!support.supported)('skill source persistence', () => { const provider = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Existing reference')); try { expect((await skills.readFile(legacyCompany, legacy!.id, 'reference.md'))?.content).toBe('Existing reference'); } finally { provider.mockRestore(); } + const revision = (await service.detail(legacyCompany, adopted[0]!.id)).revision; + await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete.*Refresh the source/); + expect((await skills.getById(legacyCompany, legacy!.id))?.sourceType).toBe('github'); + expect((await service.detail(legacyCompany, adopted[0]!.id)).revision).toBe(revision); + await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, legacy!.id)); + await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete/); + await db.update(companySkills).set({ currentVersionId: partial!.id }).where(eq(companySkills.id, legacy!.id)); files = { 'legacy/SKILL.md': md('updated name') }; const refreshed = await service.refresh(legacyCompany, adopted[0]!.id, context); expect(refreshed.updated[0]).toMatchObject({ id: legacy!.id, key: legacy!.key, slug: legacy!.slug }); expect((await db.select().from(companySkills).where(eq(companySkills.companyId, legacyCompany))).filter(skill => skill.key === legacy!.key)).toHaveLength(1); }); + it('does not report a copy when an imported skill is deleted before publication', async () => { + files = { 'SKILL.md': md('copy-race') }; commit = sha; + const service = skillSourceService(db), skills = companySkillService(db); + const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'copy-race', commitSha: sha, selectedPaths: ['SKILL.md'] }, context); + let release!: () => void, entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const blocked = new Promise(resolve => { release = resolve; }); + const pending = service.disconnect(companyId, created.source.id, { ...context, authorize: async action => { + if (action === 'skills.create') { entered(); await blocked; } + } }, { revision: created.source.revision, skills: 'copy' }); + await started; + await skills.deleteSkill(companyId, created.imported[0]!.id); + release(); + await expect(pending).rejects.toThrow(/imported skill changed/); + expect((await service.detail(companyId, created.source.id)).id).toBe(created.source.id); + expect(await skills.getById(companyId, created.imported[0]!.id)).toBeNull(); + }); + it('resolves legacy commit-only imports to the default branch without duplicating skills', async () => { const legacyCompany = randomUUID(); await db.insert(companies).values({ id: legacyCompany, name: 'Old imports', issuePrefix: 'OLD' }); @@ -411,4 +437,110 @@ describe.skipIf(!support.supported)('skill source persistence', () => { expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, temporaryCompany))).toHaveLength(0); }); + async function removalFixture(mode: 'skills' | 'repository' = 'repository') { + files = { 'one/SKILL.md': md('one') + '\nRead [reference](reference.md)', 'one/reference.md': 'Local reference', + 'two/SKILL.md': md('two'), 'shared/check.sh': '#!/bin/sh\necho preserved', + 'shared/image.png': Buffer.from([0, 255, 137, 80]) }; + commit = sha; + return skillSourceService(db).create(companyId, { repositoryUrl: 'https://github.com/acme/skills', + trackingRef: `remove-${randomUUID()}`, packageMode: mode, commitSha: sha, selectedPaths: ['one/SKILL.md', 'two/SKILL.md'] }, context); + } + + it('removes legacy disconnected sources and all linked imports, including deselected skills, and allows re-import', async () => { + const service = skillSourceService(db), skills = companySkillService(db); + const created = await removalFixture('skills'); + const changed = await service.refresh(companyId, created.source.id, context, { + revision: created.source.revision, selectedPaths: ['one/SKILL.md'], excludedFolders: [] }); + await db.update(companySkillSources).set({ enabled: false }).where(eq(companySkillSources.id, created.source.id)); + const audit = vi.fn(); + const result = await service.disconnect(companyId, created.source.id, { ...context, audit }, { revision: changed.source.revision, skills: 'delete' }); + expect(result).toMatchObject({ deletedCount: 2, copiedCount: 0 }); + await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/); + for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).toBeNull(); + expect(audit).toHaveBeenCalledWith(expect.anything(), created.source.id, 'company.skill_source_disconnected', expect.objectContaining({ deletedCount: 2 })); + const reimported = await service.create(companyId, { repositoryUrl: created.source.repositoryUrl, trackingRef: created.source.trackingRef, + commitSha: sha, selectedPaths: ['one/SKILL.md'] }, context); + expect(reimported.imported).toHaveLength(1); + expect(reimported.imported[0]!.id).not.toBe(created.imported[0]!.id); + }); + + it('keeps complete editable personal copies and assigned identities without requiring GitHub', async () => { + const service = skillSourceService(db), skills = companySkillService(db); + const created = await removalFixture(); + const original = created.imported[0]!; + const [agent] = await db.insert(agents).values({ companyId, name: 'Copy user', role: 'engineer', + adapterType: 'codex_local', adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: original.key }] } } }).returning(); + const result = await service.disconnect(companyId, created.source.id, { ...context, read: () => { throw new Error('GitHub unavailable'); } }, { revision: created.source.revision, skills: 'copy' }); + expect(result).toMatchObject({ copiedCount: 2, deletedCount: 0 }); + await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/); + const copy = (await skills.detail(companyId, original.id))!; + expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'local_path', sourceRef: null, editable: true }); + expect(copy.metadata?.skillSourceId).toBeUndefined(); + expect((await skills.readFile(companyId, original.id, '.paperclip-repository/one/reference.md'))?.content).toBe('Local reference'); + const [personal] = await db.select().from(folders).where(eq(folders.id, copy.folderId!)); + expect(personal?.systemKey).toBe('my:board'); + expect((await skills.readFile(companyId, original.id, '.paperclip-repository/shared/image.png'))?.content).toBe(Buffer.from([0,255,137,80]).toString('base64')); + const script = await skills.readFile(companyId, original.id, '.paperclip-repository/shared/check.sh'); + expect(script).toMatchObject({ content: '#!/bin/sh\necho preserved', executable: true }); + await skills.updateFile(companyId, original.id, '.paperclip-repository/shared/check.sh', '#!/bin/sh\necho edited', context.actor); + expect((await skills.readFile(companyId, original.id, '.paperclip-repository/shared/check.sh'))?.content).toBe('#!/bin/sh\necho edited'); + expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]?.adapterConfig).toEqual(agent!.adapterConfig); + await db.delete(agents).where(eq(agents.id, agent!.id)); + }); + + it('does not partly delete a source when an imported skill is assigned to an agent', async () => { + const service = skillSourceService(db), skills = companySkillService(db); + const created = await removalFixture(); + const [agent] = await db.insert(agents).values({ companyId, name: 'Needs this skill', role: 'engineer', adapterType: 'codex_local', + adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: created.imported[1]!.key }] } } }).returning(); + try { + await expect(service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' })).rejects.toThrow(/Needs this skill/); + expect((await service.detail(companyId, created.source.id)).enabled).toBe(true); + for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).not.toBeNull(); + } finally { await db.delete(agents).where(eq(agents.id, agent!.id)); } + }); + + it('rejects stale revisions, other-company targets, and removal denied for a deselected skill', async () => { + const service = skillSourceService(db); + const created = await removalFixture(); + const changed = await service.refresh(companyId, created.source.id, context, { revision: created.source.revision, selectedPaths: ['one/SKILL.md'], excludedFolders: [] }); + await expect(service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' })).rejects.toThrow(/changed/); + await expect(service.disconnect(randomUUID(), created.source.id, context, { revision: changed.source.revision, skills: 'delete' })).rejects.toThrow(/not found/); + const excluded = changed.source.entries.find(entry => entry.selection === 'excluded')!; + await expect(service.disconnect(companyId, created.source.id, { ...context, authorize: async (action, resource) => { + if (action === 'skills.remove' && resource.skillId === excluded.skillId) throw new Error('Protected skill'); + } }, { revision: changed.source.revision, skills: 'delete' })).rejects.toThrow('Protected skill'); + expect((await service.detail(companyId, created.source.id)).entries.filter(entry => entry.skillId)).toHaveLength(2); + }); + + it.each(['copy', 'delete'] as const)('rolls back source and skill changes when %s activity persistence fails', async choice => { + const service = skillSourceService(db), skills = companySkillService(db); + const created = await removalFixture(); + await expect(service.disconnect(companyId, created.source.id, { ...context, audit: async () => { throw new Error('Audit unavailable'); } }, + { revision: created.source.revision, skills: choice })).rejects.toThrow('Audit unavailable'); + expect((await service.detail(companyId, created.source.id)).revision).toBe(created.source.revision); + for (const skill of created.imported) { + expect(await skills.getById(companyId, skill.id)).toMatchObject({ sourceType: 'github', currentVersionId: skill.currentVersionId }); + expect(await skills.readFile(companyId, skill.id, '.paperclip-repository/shared/image.png')).not.toBeNull(); + } + }); + + it('removes a source while its refresh is in flight without resurrecting imports', async () => { + const service = skillSourceService(db), skills = companySkillService(db); + const created = await removalFixture(); + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const blocked = new Promise(resolve => { release = resolve; }); + const reader = context.read(null); + const refreshing = service.refresh(companyId, created.source.id, { ...context, read: () => Object.assign(async (request: string) => { + entered(); await blocked; return reader(request); + }, { openSnapshot: reader.openSnapshot }) }).then(() => null, error => error); + await started; + await service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' }); + release(); + expect(await refreshing).toBeInstanceOf(Error); + await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/); + for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).toBeNull(); + }); + }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index daa61597b8a..97b68b69f8d 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -1,7 +1,7 @@ import { skillSourceService, type SkillSourceContext } from "../services/skill-sources.js"; import { skillSourceGitHubReader } from "../services/skill-source-github-access.js"; import { toolAccessService } from "../services/tool-access.js"; -import { skillSourceCreateSchema, skillSourceDiscoverySchema, skillSourcePreviewSchema, skillSourceSelectionSchema } from "@paperclipai/shared"; +import { skillSourceDisconnectSchema, skillSourceCreateSchema, skillSourceDiscoverySchema, skillSourcePreviewSchema, skillSourceSelectionSchema } from "@paperclipai/shared"; import type { ActivityPublication } from "../services/activity-log.js"; import { once } from "node:events"; import type { SkillSourceDiscoveryEvent } from "@paperclipai/shared"; @@ -390,9 +390,12 @@ export function companySkillRoutes(db: Db) { const companyId = req.params.companyId as string; res.json(await sourceOperation(req, companyId, context => sourceSvc.refresh(companyId, req.params.sourceId as string, context))); }); - router.delete("/companies/:companyId/skill-sources/:sourceId", async (req, res) => { + router.delete("/companies/:companyId/skill-sources/:sourceId", (req, _res, next) => { + assertCompanyAccess(req, req.params.companyId as string); + next(); + }, validate(skillSourceDisconnectSchema), async (req, res) => { const companyId = req.params.companyId as string; - res.json(await sourceOperation(req, companyId, context => sourceSvc.disconnect(companyId, req.params.sourceId as string, context))); + res.json(await sourceOperation(req, companyId, context => sourceSvc.disconnect(companyId, req.params.sourceId as string, context, req.body))); }); router.get("/skills/catalog", async (req, res) => { diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index edfc6831079..8b3810722da 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -7159,45 +7159,87 @@ export function companySkillService(db: Db) { return { pruned: rows.length }; } - async function deleteSkill(companyId: string, skillId: string): Promise { - const initial = await getById(companyId, skillId); - if (!initial) return null; + async function makeImportedSkillEditable( + companyId: string, skillId: string, actor: SkillActor, folderId: string | null, + database: DbOrTransaction, createdDirectories: string[], + ) { + const [locked] = await database.select({ id: companySkills.id }).from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skillId))).for("update"); + if (!locked) throw conflict("An imported skill changed. Reload before disconnecting."); + const skill = (await getById(companyId, skillId, database))!; + const version = await getCurrentVersion(skill); + const recovery = `Cannot keep "${skill.name}" because its saved files are incomplete. Refresh the source before keeping editable copies, or choose Delete skills.`; + if (!version) throw unprocessable(recovery); + const files = await runtimeVersionInventory(companyId, version); + const savedPaths = new Set(files.map(file => file.path)); + const savedSkillPath = (relativePath: string) => version.repositorySnapshotId && version.repositorySkillPath + ? `${REPOSITORY_CONTENT_DIR}/${path.posix.join(path.posix.dirname(version.repositorySkillPath), relativePath)}` + : relativePath; + if (!savedPaths.has("SKILL.md") || skill.fileInventory.some(file => !savedPaths.has(savedSkillPath(file.path)))) { + throw unprocessable(recovery); + } + const managedRoot = resolveManagedSkillsRoot(companyId); + await fs.mkdir(managedRoot, { recursive: true }); + const directory = await fs.mkdtemp(path.join(managedRoot, `${skill.slug}-copy-`)); + createdDirectories.push(directory); + for (const file of files) { + assertSkillSnapshotPath(file.path); + const target = path.join(directory, file.path); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, skillFileBytes(file), { mode: file.executable ? 0o755 : 0o644 }); + } + const updated = await database.update(companySkills).set({ + sourceType: "local_path", sourceLocator: directory, sourceRef: null, folderId, + fileInventory: files.map(({ path, kind }) => ({ path, kind })), + metadata: { + sourceKind: "managed_local", + copiedFromRepository: { url: skill.sourceLocator, ref: skill.sourceRef, path: skill.metadata?.skillSourcePath }, + forkedByUserId: actor.type === "user" ? actor.userId ?? null : null, + forkedByAgentId: actor.type === "agent" ? actor.agentId ?? null : null, + }, + updatedAt: new Date(), + }).where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skillId))).returning({ id: companySkills.id }); + if (updated.length !== 1) throw conflict("An imported skill changed. Reload before disconnecting."); + } + + async function deleteSkills( + companyId: string, skillIds: string[], + onDelete?: (tx: DbTransaction, deleted: CompanySkill[]) => Promise, + beforeDelete?: (tx: DbTransaction) => Promise, + ): Promise { + const initial = (await Promise.all([...new Set(skillIds)].sort().map(id => getById(companyId, id)))) + .filter((skill): skill is CompanySkill => Boolean(skill)); async function assertUnused(skill: CompanySkill) { const usedByAgents = await usage(companyId, skill.key); if (usedByAgents.length === 0) return; - const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); throw unprocessable( - `Cannot delete skill "${skill.name}" while it is still used by ${agentNames.join(", ")}. Detach it from those agents first.`, - { - skillId: skill.id, skillKey: skill.key, - usedByAgents: usedByAgents.map((agent) => ({ - id: agent.id, name: agent.name, urlKey: agent.urlKey, adapterType: agent.adapterType, - })), - }, + `Cannot delete skill "${skill.name}" while it is still used by ${usedByAgents.map(agent => agent.name).sort().join(", ")}. Detach it from those agents first.`, + { skillId: skill.id, skillKey: skill.key, usedByAgents: usedByAgents.map(({ id, name, urlKey, adapterType }) => ({ id, name, urlKey, adapterType })) }, ); } - await assertUnused(initial); + for (const skill of initial) await assertUnused(skill); const managedRoot = resolveManagedSkillsRoot(companyId); - let movedSource: { source: string; quarantine: string } | undefined; - let deleted: CompanySkill | null = null; - async function restoreSource() { - if (!movedSource) return; - await fs.rename(movedSource.quarantine, movedSource.source); - movedSource = undefined; + const moved: Array<{ source: string; quarantine: string }> = []; + async function restoreSources() { + for (const item of moved.reverse()) await fs.rename(item.quarantine, item.source); + moved.length = 0; } - // Keep the cache lifecycle lock through database commit. A publisher must - // not see the old row after cache removal and republish a deleted skill. - await removeRuntimeSkillCache(managedRoot, initial.id, async () => { + async function withCacheLocks(index: number): Promise { + if (index < initial.length) { + let result: CompanySkill[] = []; + await removeRuntimeSkillCache(managedRoot, initial[index]!.id, async () => { result = await withCacheLocks(index + 1); }); + return result; + } try { - deleted = await db.transaction(async (tx) => { - // Share creation's name lock and re-read the ID, so a second delete - // cannot remove a newly recreated skill with the same name. - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${companyId}:${initial.slug}`}, 0))`); - const skill = await getById(companyId, skillId, tx); - if (!skill) return null; - if (skill.slug !== initial.slug) throw conflict("Skill changed during deletion. Retry the request."); - await assertUnused(skill); - try { + return await db.transaction(async tx => { + await beforeDelete?.(tx); + const deleted: CompanySkill[] = []; + for (const before of initial) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${companyId}:${before.slug}`}, 0))`); + const skill = await getById(companyId, before.id, tx); + if (!skill) continue; + if (skill.slug !== before.slug) throw conflict("Skill changed during deletion. Retry the request."); + await assertUnused(skill); await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); if (isPaperclipManagedRenameTarget(skill)) { const source = normalizeSkillDirectory(skill)!; @@ -7209,35 +7251,36 @@ export function companySkillService(db: Db) { const quarantineRoot = path.join(managedRoot, ".deleted"); await fs.mkdir(quarantineRoot, { recursive: true }); const quarantine = path.join(quarantineRoot, `${skill.id}-${randomUUID()}`); - // Do not follow symlinks or delete external/project sources. - // Retain bytes until the database deletion commits. await fs.rename(source, quarantine); - movedSource = { source, quarantine }; + moved.push({ source, quarantine }); } } - await tx.delete(companySkills).where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))); - } catch (error) { - // Restore before releasing the name lock on a failed write. - await restoreSource(); - throw error; + await tx.delete(companySkills).where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); + deleted.push(skill); } - return skill; + await onDelete?.(tx, deleted); + return deleted; }); } catch (error) { - await restoreSource(); + await restoreSources(); throw error; } - }); - // Only remove the unique quarantine path after commit. Cleanup must not - // remove a replacement skill or report a committed delete as failed. - if (movedSource) { - await fs.rm(movedSource.quarantine, { recursive: true, force: true }).catch((error) => { - logger.warn({ err: error, companyId, skillId }, "Skill deleted; quarantined source cleanup failed"); + } + let deleted: CompanySkill[]; + try { deleted = await withCacheLocks(0); } + catch (error) { await restoreSources(); throw error; } + for (const item of moved) { + await fs.rm(item.quarantine, { recursive: true, force: true }).catch(error => { + logger.warn({ err: error, companyId }, "Skill deleted; quarantined source cleanup failed"); }); } return deleted; } + async function deleteSkill(companyId: string, skillId: string): Promise { + return (await deleteSkills(companyId, [skillId]))[0] ?? null; + } + return { list, listFull, @@ -7277,6 +7320,8 @@ export function companySkillService(db: Db) { deleteFile, createLocalSkill, deleteSkill, + deleteSkills, + makeImportedSkillEditable, listTestInputs, createTestInput, updateTestInput, diff --git a/server/src/services/skill-sources.ts b/server/src/services/skill-sources.ts index 0b3e6df5a58..dae360449c2 100644 --- a/server/src/services/skill-sources.ts +++ b/server/src/services/skill-sources.ts @@ -1,8 +1,10 @@ import { createHash, randomUUID } from 'node:crypto'; import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { folderService } from './folders.js'; import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'; -import { companySkillSources as sources, companySkillSourceEntries as entries, companySkills, companySkillRepositorySnapshots, type Db } from '@paperclipai/db'; -import type { CompanySkill, SkillSource, SkillSourceCreateRequest, SkillSourceDiscoveryRequest, SkillSourceSelectionRequest, SkillSourceRefreshResult, SkillSourcePreviewRequest } from '@paperclipai/shared'; +import { companySkillSources as sources, companySkillSourceEntries as entries, companySkills, companySkillRepositorySnapshots, authUsers, type Db } from '@paperclipai/db'; +import type { CompanySkill, SkillSourceDisconnectRequest, SkillSourceDisconnectResult, SkillSource, SkillSourceCreateRequest, SkillSourceDiscoveryRequest, SkillSourceSelectionRequest, SkillSourceRefreshResult, SkillSourcePreviewRequest } from '@paperclipai/shared'; import { normalizeAgentUrlKey } from '@paperclipai/shared'; import { conflict, notFound, unprocessable } from '../errors.js'; import { companySkillService, parseSkillImportSourceInput } from './company-skills.js'; @@ -14,7 +16,7 @@ type SourceRow = typeof sources.$inferSelect; export type SkillSourceContext = { actor: { type: 'user' | 'agent' | 'system'; userId?: string | null; agentId?: string | null }; read: (connectionId: string | null) => GitHubRead; - authorize: (action: 'skills.import' | 'skills.update' | 'skills.edit', resource: { skillId?: string; skillKey?: string; sourceType: 'git'; sourceLocator: string }) => Promise; + authorize: (action: 'skills.import' | 'skills.update' | 'skills.edit' | 'skills.remove' | 'skills.create', resource: { skillId?: string; skillKey?: string; sourceType: 'git'; sourceLocator: string }) => Promise; audit: (tx: Tx, sourceId: string, action: string, details: Record) => Promise; }; const scope = (companyId: string, id: string) => and(eq(sources.companyId, companyId), eq(sources.id, id)); @@ -217,21 +219,47 @@ export function skillSourceService(db: Db) { throw error; } } - async function disconnect(companyId: string, id: string, context: SkillSourceContext) { - const source = await row(companyId, id); + async function disconnect(companyId: string, id: string, context: SkillSourceContext, input: SkillSourceDisconnectRequest): Promise { + const source = await detail(companyId, id); + if (source.revision !== input.revision) throw conflict('This source changed. Reload before disconnecting.'); await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl }); - await authorizeSelection(source, [], context); - return db.transaction(async tx => { - await tx.select().from(sources).where(scope(companyId, id)).for('update'); - await tx.update(sources).set({ enabled: false, revision: sql`${sources.revision} + 1`, leaseToken: null, leaseExpiresAt: null }).where(scope(companyId, id)); - for (const entry of (await detail(companyId, id, tx)).entries) { - if (!entry.skillId) continue; - const skill = await skills.getById(companyId, entry.skillId, tx); - if (skill) await tx.update(companySkills).set({ metadata: { ...skill.metadata, skillSourceId: id, skillSourceState: 'not_syncing' } }).where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skill.id))); - } - await context.audit(tx, id, 'company.skill_source_disconnected', {}); - return detail(companyId, id, tx); - }); + const skillIds = [...new Set(source.entries.flatMap(entry => entry.skillId ? [entry.skillId] : []))]; + for (const skillId of skillIds) { + const skill = await skills.getById(companyId, skillId); + if (!skill) continue; + const resource = { sourceType: 'git' as const, sourceLocator: source.repositoryUrl, skillId, skillKey: skill.key }; + await context.authorize(input.skills === 'delete' ? 'skills.remove' : 'skills.edit', resource); + if (input.skills === 'copy') await context.authorize('skills.create', resource); + } + async function lock(tx: Tx) { + const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update'); + if (!locked || locked.revision !== input.revision) throw conflict('This source changed. Reload before disconnecting.'); + } + async function remove(tx: Tx, copiedCount: number, deletedCount: number) { + await tx.delete(sources).where(scope(companyId, id)); + await context.audit(tx, id, 'company.skill_source_disconnected', { skills: input.skills, skillIds, copiedCount, deletedCount }); + } + if (input.skills === 'delete') { + const deleted = await skills.deleteSkills(companyId, skillIds, (tx, deleted) => remove(tx, 0, deleted.length), lock); + return { id, copiedCount: 0, deletedCount: deleted.length }; + } + let folderId: string | null = null; + if (skillIds.length && context.actor.type === 'user' && context.actor.userId) { + const [user] = await db.select({ name: authUsers.name }).from(authUsers).where(eq(authUsers.id, context.actor.userId)); + folderId = (await folderService(db).ensureMyFolder(companyId, context.actor.userId, user?.name ?? null)).id; + } + const directories: string[] = []; + try { + return await db.transaction(async tx => { + await lock(tx); + for (const skillId of skillIds) await skills.makeImportedSkillEditable(companyId, skillId, context.actor, folderId, tx, directories); + await remove(tx, skillIds.length, 0); + return { id, copiedCount: skillIds.length, deletedCount: 0 }; + }); + } catch (error) { + await Promise.all(directories.map(directory => fs.rm(directory, { recursive: true, force: true }))); + throw error; + } } async function importFromUrl(companyId: string, input: string, context: SkillSourceContext) { const parsed = parseSkillImportSourceInput(input); diff --git a/ui/src/api/skillSources.test.ts b/ui/src/api/skillSources.test.ts index c2fbca00ace..8e6d4742ce8 100644 --- a/ui/src/api/skillSources.test.ts +++ b/ui/src/api/skillSources.test.ts @@ -53,4 +53,14 @@ describe('live skill source discovery', () => { await expect(skillSourcesApi.discoverStream('company-1', input, updates, controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); expect(fetchMock.mock.calls[0]![1].signal).toBe(controller.signal); }); + it('sends the required disposition and source revision with the delete request', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ id: 'source-1', copiedCount: 0, deletedCount: 2 })); + vi.stubGlobal('fetch', fetchMock); + await skillSourcesApi.disconnect('company-1', 'source-1', { revision: 7, skills: 'delete' }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('/api/companies/company-1/skill-sources/source-1'); + expect(init.method).toBe('DELETE'); + expect(JSON.parse(init.body)).toEqual({ revision: 7, skills: 'delete' }); + }); + }); diff --git a/ui/src/api/skillSources.ts b/ui/src/api/skillSources.ts index 0bf1039957f..2cf2f8ca284 100644 --- a/ui/src/api/skillSources.ts +++ b/ui/src/api/skillSources.ts @@ -1,4 +1,4 @@ -import type { ProjectRepositoryOptions, SkillSourceFilePreview, SkillSourcePreviewRequest, SkillSource, SkillSourceCreateRequest, SkillSourceDiscovery, SkillSourceDiscoveryRequest, SkillSourceRefreshResult, SkillSourceSelectionRequest, SkillSourceScanUpdate, SkillSourceDiscoveryEvent } from '@paperclipai/shared'; +import type { ProjectRepositoryOptions, SkillSourceDisconnectRequest, SkillSourceDisconnectResult, SkillSourceFilePreview, SkillSourcePreviewRequest, SkillSource, SkillSourceCreateRequest, SkillSourceDiscovery, SkillSourceDiscoveryRequest, SkillSourceRefreshResult, SkillSourceSelectionRequest, SkillSourceScanUpdate, SkillSourceDiscoveryEvent } from '@paperclipai/shared'; import { api, ApiError, requestResponse } from './client'; const base = (companyId: string) => `/companies/${companyId}/skill-sources`; export const skillSourcesApi = { @@ -41,5 +41,5 @@ export const skillSourcesApi = { create: (companyId: string, input: SkillSourceCreateRequest) => api.post(base(companyId), input), select: (companyId: string, id: string, input: SkillSourceSelectionRequest) => api.patch(`${base(companyId)}/${id}`, input), refresh: (companyId: string, id: string) => api.post(`${base(companyId)}/${id}/refresh`, {}), - disconnect: (companyId: string, id: string) => api.delete(`${base(companyId)}/${id}`), + disconnect: (companyId: string, id: string, input: SkillSourceDisconnectRequest) => api.delete(`${base(companyId)}/${id}`, input), }; diff --git a/ui/src/components/ui/radio-card.tsx b/ui/src/components/ui/radio-card.tsx index f8515bc6017..7c3ca5e1e02 100644 --- a/ui/src/components/ui/radio-card.tsx +++ b/ui/src/components/ui/radio-card.tsx @@ -97,7 +97,7 @@ export function RadioCardGroup({ const handleKeyDown = (event: React.KeyboardEvent) => { if (disabled) return; const idx = options.findIndex((option) => option.value === value); - if (idx === -1) return; + let step: number | null = null; if (event.key === "ArrowDown" || event.key === "ArrowRight") { step = 1; @@ -110,9 +110,11 @@ export function RadioCardGroup({ // choice the viewer cannot make would select it. const len = options.length; for (let hop = 1; hop <= len; hop++) { - const candidate = options[(((idx + step * hop) % len) + len) % len]; + const next = idx === -1 ? (step === 1 ? hop - 1 : len - hop) : (((idx + step * hop) % len) + len) % len; + const candidate = options[next]; if (!candidate.disabled) { onValueChange(candidate.value); + event.currentTarget.querySelectorAll('[role="radio"]')[next]?.focus(); return; } } @@ -135,7 +137,7 @@ export function RadioCardGroup({ tooltip={option.tooltip} aria-label={option.accessibleLabel} disabled={disabled || option.disabled} - tabIndex={option.value === value ? 0 : -1} + tabIndex={option.value === value || (!options.some(item => item.value === value && !item.disabled) && option === options.find(item => !item.disabled)) ? 0 : -1} onClick={() => onValueChange(option.value)} /> ))} diff --git a/ui/src/pages/SkillSources.test.tsx b/ui/src/pages/SkillSources.test.tsx index cf8ec8d60be..7ac4ccda3d7 100644 --- a/ui/src/pages/SkillSources.test.tsx +++ b/ui/src/pages/SkillSources.test.tsx @@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SkillSources } from './SkillSources'; import { skillSourcesApi } from '@/api/skillSources'; +import { ApiError } from '@/api/client'; +import { sourceFixture } from '../../storybook/fixtures/githubSkillSources'; const context = vi.hoisted(() => ({ navigate: vi.fn(), breadcrumbs: vi.fn(), sourceId: 'new' })); vi.mock('@/context/CompanyContext', () => ({ useCompany: () => ({ selectedCompanyId: 'company-1', selectedCompany: { name: 'Acme' } }) })); @@ -241,4 +243,73 @@ describe('GitHub skill source import', () => { expect(document.body.textContent).not.toContain('Include repository files'); }); + async function openRemoval(disconnected = false) { + context.sourceId = ''; + const source = { ...sourceFixture(), companyId: 'company-1', enabled: !disconnected }; + vi.mocked(skillSourcesApi.list).mockResolvedValue([source]); + await mount(); + const trigger = document.querySelector(`button[aria-label="More actions for ${source.fullName}"]`)!; + await act(async () => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); + await flush(); + const menu = [...document.querySelectorAll('[role="menuitem"]')].find(el => el.textContent === (disconnected ? 'Remove source' : 'Disconnect source'))!; + await act(async () => menu.click()); await flush(); + return source; + } + it('requires a keep-or-delete choice and cancellation leaves the source intact', async () => { + await openRemoval(); + expect(button('Disconnect source').disabled).toBe(true); + expect(document.querySelector('[role="radio"][tabindex="0"]')?.textContent).toContain('Keep editable copies'); + expect(skillSourcesApi.disconnect).not.toHaveBeenCalled(); + await act(async () => button('Cancel').click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.textContent).toContain('acme/team-skills'); + }); + it('removes an already-disconnected source and sends the explicit delete choice and revision', async () => { + const source = await openRemoval(true); + vi.mocked(skillSourcesApi.disconnect).mockImplementation(async () => { + vi.mocked(skillSourcesApi.list).mockResolvedValue([]); + return { id: source.id, copiedCount: 0, deletedCount: 2 }; + }); + const choice = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Delete skills'))!; + await act(async () => choice.click()); + await act(async () => button('Remove source').click()); await flush(); await flush(); + expect(skillSourcesApi.disconnect).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, skills: 'delete' }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.textContent).not.toContain('acme/team-skills'); + }); + it('reloads a stale confirmation and requires a new choice for the updated skill count', async () => { + const source = await openRemoval(); + vi.mocked(skillSourcesApi.disconnect).mockRejectedValueOnce(new ApiError('Source changed. Reload before disconnecting.', 409, {})); + await act(async () => document.querySelector('[role="radio"]')!.click()); + await act(async () => button('Disconnect source').click()); await flush(); + expect(button('Disconnect source').disabled).toBe(true); + vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...source, revision: source.revision + 1, entries: source.entries.slice(0, 1) }]); + await act(async () => button('Reload source').click()); await flush(); await flush(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('1 imported skill'); + expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull(); + expect(button('Disconnect source').disabled).toBe(true); + await act(async () => document.querySelector('[role="radio"]')!.click()); + vi.mocked(skillSourcesApi.disconnect).mockImplementation(async () => { + vi.mocked(skillSourcesApi.list).mockResolvedValue([]); + return { id: source.id, copiedCount: 1, deletedCount: 0 }; + }); + await act(async () => button('Disconnect source').click()); await flush(); await flush(); + expect(skillSourcesApi.disconnect).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision + 1, skills: 'copy' }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + it('sends the keep-copy choice and keeps failures in the confirmation for retry', async () => { + const source = await openRemoval(); + vi.mocked(skillSourcesApi.disconnect).mockRejectedValue(new Error('Source changed. Reload before disconnecting.')); + const choice = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Keep editable copies'))!; + await act(async () => choice.click()); + await act(async () => button('Disconnect source').click()); await flush(); + expect(skillSourcesApi.disconnect).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, skills: 'copy' }); + expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('Source changed'); + expect(button('Disconnect source').disabled).toBe(false); + expect(document.querySelector('section[aria-label="acme/team-skills"]')).not.toBeNull(); + const other = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Delete skills'))!; + await act(async () => other.click()); + expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull(); + }); + }); diff --git a/ui/src/pages/SkillSources.tsx b/ui/src/pages/SkillSources.tsx index 5386e6439f7..bf6746e91c3 100644 --- a/ui/src/pages/SkillSources.tsx +++ b/ui/src/pages/SkillSources.tsx @@ -2,16 +2,18 @@ import { GithubIcon } from "@/components/icons/github-icon"; import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, Plus, ExternalLink, Check, Lock, GitBranch, FileText, MoreVertical } from 'lucide-react'; -import { parseGitHubSkillRepositoryUrl, type SkillSource, type SkillSourceDiscovery, type SkillSourceRefreshResult, type SkillSourceScanProgress } from '@paperclipai/shared'; +import { parseGitHubSkillRepositoryUrl, type SkillSourceDisconnectRequest, type SkillSource, type SkillSourceDiscovery, type SkillSourceRefreshResult, type SkillSourceScanProgress } from '@paperclipai/shared'; import { Link, useNavigate, useParams } from '@/lib/router'; import { useCompany } from '@/context/CompanyContext'; import { useBreadcrumbs } from '@/context/BreadcrumbContext'; import { queryKeys } from '@/lib/queryKeys'; import { skillSourcesApi } from '@/api/skillSources'; +import { ApiError } from '@/api/client'; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command'; import { appSourceConnectHref } from './apps/app-connect-policy'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { RadioCardGroup } from '@/components/ui/radio-card'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { SkillImportProgress, type FoundSkill } from './skills/SkillImportProgress'; @@ -29,6 +31,7 @@ export function SkillSources() { const navigate = useNavigate(); const client = useQueryClient(); const { setBreadcrumbs } = useBreadcrumbs(); + const [disconnectSource, setDisconnectSource] = useState(null); const [results, setResults] = useState>({}); const companyId = selectedCompanyId ?? ''; useEffect(() => { setBreadcrumbs([{ label: 'Skills', href: '/skills' }, { label: 'Sources' }]); }, [setBreadcrumbs]); @@ -39,7 +42,24 @@ export function SkillSources() { const refresh = useMutation({ mutationFn: (id: string) => skillSourcesApi.refresh(companyId, id), onSuccess: async result => { setResults(prev => ({ ...prev, [result.source.id]: '' })); await invalidate(); }, onError: (error, id) => { setResults(prev => ({ ...prev, [id]: error.message })); void invalidate(); } }); - const disconnect = useMutation({ mutationFn: (id: string) => skillSourcesApi.disconnect(companyId, id), onSuccess: invalidate }); + const disconnect = useMutation({ + mutationFn: ({ source, skills }: { source: SkillSource; skills: SkillSourceDisconnectRequest['skills'] }) => + skillSourcesApi.disconnect(companyId, source.id, { revision: source.revision, skills }), + onSuccess: async result => { + client.setQueryData(sourceKey(companyId), previous => previous?.filter(source => source.id !== result.id)); + await invalidate(); + await client.invalidateQueries({ queryKey: queryKeys.folders.list(companyId, 'skill') }); + setDisconnectSource(null); + }, + }); + const reloadRemoval = useMutation({ + mutationFn: async () => { + const result = await query.refetch({ throwOnError: true }); + return result.data?.find(source => source.id === disconnectSource?.id) ?? null; + }, + onSuccess: source => { setDisconnectSource(source); disconnect.reset(); }, + }); + const staleRemoval = disconnect.error instanceof ApiError && disconnect.error.status === 409; const activeSource = query.data?.find(source => source.id === sourceId); if (!companyId) return

    Select a company to manage skill sources.

    ; return
    @@ -50,7 +70,6 @@ export function SkillSources() { Installed skills {query.isPending &&

    Loading sources…

    } {query.error &&

    {query.error.message}

    } - {disconnect.error &&

    {disconnect.error.message}

    } {query.data?.length === 0 &&

    No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.

    }
    {query.data?.map(source => { @@ -71,7 +90,7 @@ export function SkillSources() { refresh.mutate(source.id)}>{refresh.isPending && refresh.variables === source.id ? 'Refreshing…' : 'Refresh'} navigate(`/skills/sources/${source.id}`)}>Select skills - disconnect.mutate(source.id)}>Disconnect source + { disconnect.reset(); reloadRemoval.reset(); setDisconnectSource(source); }}>{source.enabled ? 'Disconnect source' : 'Remove source'}
    @@ -92,6 +111,11 @@ export function SkillSources() { ; })}
    + {disconnectSource && reloadRemoval.mutate()} onChoiceChange={() => { if (!staleRemoval) disconnect.reset(); }} + onClose={() => setDisconnectSource(null)} + onConfirm={skills => disconnect.mutate({ source: disconnectSource, skills })} />} {sourceId && (sourceId === 'new' || activeSource) && navigate('/skills/sources')} onSaved={async result => { setResults(prev => ({ ...prev, [result.source.id]: '' })); await invalidate(); navigate('/skills/sources'); }} />} @@ -99,6 +123,39 @@ export function SkillSources() { ; } +function DisconnectSourceDialog({ source, pending, error, stale, onReload, onClose, onConfirm, onChoiceChange }: { + source: SkillSource; pending: boolean; error?: string; stale: boolean; onReload: () => void; onClose: () => void; + onConfirm: (skills: SkillSourceDisconnectRequest['skills']) => void; onChoiceChange: () => void; +}) { + const count = new Set(source.entries.flatMap(entry => entry.skillId ? [entry.skillId] : [])).size; + const [choice, setChoice] = useState(''); + const action = source.enabled ? 'Disconnect source' : 'Remove source'; + return { if (!open && !pending) onClose(); }}> + + + {source.enabled ? 'Disconnect' : 'Remove'} {source.fullName}? + {count ? `Remove this repository from Sources. What should happen to its ${count} imported ${count === 1 ? 'skill' : 'skills'}?` : 'Remove this repository from Sources.'} + + {count > 0 && { setChoice(value as SkillSourceDisconnectRequest['skills']); onChoiceChange(); }} disabled={pending} + options={[ + { value: 'copy', title: 'Keep editable copies', description: 'Move them to My Skills. Assigned agents keep using them.' }, + { value: 'delete', title: 'Delete skills', description: 'Remove them from the library. Detach skills used by agents first.' }, + ]} />} + + {error &&
    {error} + {stale && } +
    } +
    + + +
    +
    +
    ; +} + function SourceDialog({ companyId, source, onClose, onSaved }: { companyId: string; source?: SkillSource; onClose: () => void; onSaved: (result: SkillSourceRefreshResult) => Promise; }) { diff --git a/ui/storybook/fixtures/githubSkillSources.ts b/ui/storybook/fixtures/githubSkillSources.ts index f58c09651e8..3367d5a72c0 100644 --- a/ui/storybook/fixtures/githubSkillSources.ts +++ b/ui/storybook/fixtures/githubSkillSources.ts @@ -91,7 +91,7 @@ export function importedSkill(source: SkillSource, entry: SkillSourceEntry): Com export type RepositoryScenario = 'single' | 'multiple' | 'none' | 'empty' | 'partial-error' | 'error' | 'loading'; /** Scoped API fixtures keep the production page interactive without contacting GitHub. */ -export function installFixtures(empty: boolean, needsConnection: boolean, options: { packageScenario?: PackageScenario; journey?: boolean; refreshed?: boolean; assigned?: boolean; repositories?: RepositoryScenario; scan?: "live" | "large" | "interrupted"; saving?: boolean } = {}) { +export function installFixtures(empty: boolean, needsConnection: boolean, options: { packageScenario?: PackageScenario; journey?: boolean; refreshed?: boolean; assigned?: boolean; repositories?: RepositoryScenario; scan?: "live" | "large" | "interrupted"; saving?: boolean; disconnected?: boolean } = {}) { const fixtureController = new AbortController(); const wait = (ms: number, signal = fixtureController.signal) => new Promise((resolve, reject) => { signal.throwIfAborted(); @@ -284,10 +284,12 @@ export function installFixtures(empty: boolean, needsConnection: boolean, option source.lastSuccessAt = new Date(); return structuredClone({ source, imported: [], updated: [], unchanged: source.entries.filter(entry => entry.selection === "selected").length, warnings: [] }); }; - skillSourcesApi.disconnect = async (_companyId, id) => { + if (options.disconnected) sources.forEach(source => { source.enabled = false; }); + skillSourcesApi.disconnect = async (_companyId, id, input) => { const source = sources.find(item => item.id === id)!; - source.enabled = false; - return structuredClone(source); + const count = source.entries.filter(entry => entry.skillId).length; + sources.splice(sources.findIndex(item => item.id === id), 1); + return { id, copiedCount: input.skills === 'copy' ? count : 0, deletedCount: input.skills === 'delete' ? count : 0 }; }; return () => { fixtureController.abort(); diff --git a/ui/storybook/stories/skills/github-synced-skills.stories.tsx b/ui/storybook/stories/skills/github-synced-skills.stories.tsx index 5fbf0aa45ad..c6201f83214 100644 --- a/ui/storybook/stories/skills/github-synced-skills.stories.tsx +++ b/ui/storybook/stories/skills/github-synced-skills.stories.tsx @@ -5,13 +5,13 @@ import { COMPANY_ID, SOURCE_ID, installFixtures, type RepositoryScenario } from import { Link, Route, Routes, useNavigate } from "@/lib/router"; import { SkillSources } from "@/pages/SkillSources"; -function SourcesStory({ view = "sources", empty = false, needsConnection = false, repositories = "single", repositoryUrl = "", scan }: { - view?: "sources" | "import" | "manage"; empty?: boolean; needsConnection?: boolean; repositories?: RepositoryScenario; repositoryUrl?: string; scan?: "live" | "large" | "interrupted"; +function SourcesStory({ view = "sources", empty = false, needsConnection = false, repositories = "single", repositoryUrl = "", scan, disconnected = false }: { + disconnected?: boolean; view?: "sources" | "import" | "manage"; empty?: boolean; needsConnection?: boolean; repositories?: RepositoryScenario; repositoryUrl?: string; scan?: "live" | "large" | "interrupted"; }) { const [ready, setReady] = useState(false); const navigate = useNavigate(); useEffect(() => { - const cleanup = installFixtures(empty, needsConnection, { repositories, scan }); + const cleanup = installFixtures(empty, needsConnection, { repositories, scan, disconnected }); for (const id of ["new", SOURCE_ID]) sessionStorage.removeItem(`paperclip.skill-source-draft:${COMPANY_ID}:${id}`); if (repositoryUrl) sessionStorage.setItem(`paperclip.skill-source-draft:${COMPANY_ID}:new`, JSON.stringify({ repositoryUrl })); navigate(`/skills/sources${view === "import" ? "/new" : view === "manage" ? `/${SOURCE_ID}` : ""}`, { replace: true }); @@ -19,7 +19,7 @@ function SourcesStory({ view = "sources", empty = false, needsConnection = false return cleanup; // Install each story's fixtures only once; route changes belong to the user. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [empty, needsConnection, view, repositories, repositoryUrl, scan]); + }, [empty, needsConnection, view, repositories, repositoryUrl, scan, disconnected]); if (!ready) return null; return } /> @@ -98,3 +98,21 @@ export const LargeRepository: Story = { export const InterruptedScan: Story = { name: 'Import · Interrupted scan', args: { view: 'import', empty: true, repositoryUrl: 'https://github.com/acme/team-skills', scan: 'interrupted' }, play: startScan, }; + +export const DisconnectSource: Story = { + name: 'Sources · Disconnect source', + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole('button', { name: 'More actions for acme/team-skills' })); + await userEvent.click(await page.findByRole('menuitem', { name: 'Disconnect source' })); + }, +}; +export const RemoveDisconnectedSource: Story = { + name: 'Sources · Remove disconnected source', args: { disconnected: true }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole('button', { name: 'More actions for acme/team-skills' })); + await userEvent.click(await page.findByRole('menuitem', { name: 'Remove source' })); + await userEvent.click(await page.findByRole('radio', { name: /^Delete skills/ })); + }, +}; From aded4197e54e215e03b87834ecc05724c401abba Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:09:19 -0500 Subject: [PATCH 08/85] fix: make multiplayer task toasts personal (#16153) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A company can have several human users and shared agents. > - Live task events currently produce toasts for any user who can read the task. > - This makes unrelated work interrupt other users. > - This pull request checks each user's task participation before it permits a toast. > - Users receive relevant task notifications while company views continue to refresh. ## Linked Issues or Issue Description **What happened?** Company task activity and agent failures produce noisy toasts for users who have no involvement in the task. **Expected behavior** A task qualifies when the viewer created it, commented on it, is its assigned user, deliberately acted on it under the Mine rules, or has an explicit user mention in its description or a non-deleted comment. Viewing, agent ownership, responsible-user attribution, and parent participation alone do not qualify. **Steps to reproduce** 1. Sign in as two users in the same company. 2. Have the second user create and update a task that does not involve the first user. 3. Observe a task toast in the first user's company view before this fix. The duplicate PR search found no existing change for personal task-toast eligibility. ## What Changed - Extract the existing Mine participation predicate without changing Mine behavior. - Compute task eligibility from stored tasks, comments, and run associations after WebSocket access checks. Use an exact indexed comment-mention lookup with `LIMIT 1`. - Add optional `toastEligible` metadata to each viewer's event copy. Keep it off the shared event bus. - Require eligibility for live task activity and task-linked run errors. Suppress generic agent errors. - Continue cache updates for quiet events. Refetch visible comments when authenticated events omit hydration details. Ignore stale socket callbacks after account or company changes. - Document toast semantics and add feature-map and agent-access-map coverage. - Add persisted-data, socket, UI, and authenticated browser regression coverage. ## Verification - Final commit: `1dc4f9402c5883744738cfe236ad00fa0bb5ca4c`. - All 55 current-head checks passed. Two Storybook checks were skipped because this change does not affect that coverage. - Greptile approved this exact commit with 5/5. The security scan passed. Both review threads are resolved. - Local `pnpm build`, `pnpm -r typecheck`, `pnpm check:token-gates`, and `node scripts/check-maps.mjs` passed. - The focused socket/UI suite passed 123 tests. The persisted eligibility suite passed 9 tests, including long history, exact viewer matching, and regex escaping. The existing Mine service suite previously passed 140 tests. - The named browser case `task toasts follow personal involvement in multiplayer` passed against the final commit in an isolated authenticated instance. It used two real sessions, real WebSocket delivery, and persisted tasks, edits, comments, and mentions. It verified quiet unrelated activity, viewing alone, first and historical mentions, own-action suppression, visible-task suppression, and live cache refresh. - A prior-head server shard passed its single rerun after a fixture-cleanup deadlock. The final-head CI run passed without a rerun. - The broad local run remained in progress when CI completed the full test suite. Local focused checks and CI provide the completed test evidence. ## Risks - This intentionally reduces live notifications. A new client stays quiet against an older server that omits eligibility. - Eligibility queries add work to live event delivery. A lookup failure suppresses the toast and still delivers the authorized event. - Deleted mentions cease to qualify unless another participation rule applies. Historical comment authorship retains the existing Mine behavior. - No database migration or notification-settings change is required. ## Model Used OpenAI Codex with GPT-6 assisted with implementation, tests, documentation, and PR preparation. It used repository tools, shell commands, and browser automation. The runtime did not expose an exact backend revision 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/SPEC-implementation.md | 4 + doc/TOASTS.md | 75 ++++++++++++ maps/agent-access-map/inbox-search.md | 26 ++++ maps/feature-map/README.md | 2 +- maps/feature-map/inbox-search.md | 36 ++++++ packages/shared/src/types/live.ts | 2 + .../src/__tests__/live-events-toast.test.ts | 62 ++++++++++ .../__tests__/task-toast-eligibility.test.ts | 112 ++++++++++++++++++ server/src/realtime/live-events-ws.ts | 14 ++- server/src/realtime/task-toast-eligibility.ts | 47 ++++++++ server/src/services/issue-participation.ts | 103 ++++++++++++++++ server/src/services/issues.ts | 101 +--------------- tests/e2e/multi-user-authenticated.spec.ts | 109 +++++++++++++++++ .../context/LiveUpdatesProvider.hook.test.tsx | 84 ++++++++++++- ui/src/context/LiveUpdatesProvider.test.ts | 19 +++ ui/src/context/LiveUpdatesProvider.tsx | 42 ++++--- 16 files changed, 712 insertions(+), 126 deletions(-) create mode 100644 doc/TOASTS.md create mode 100644 server/src/__tests__/task-toast-eligibility.test.ts create mode 100644 server/src/realtime/task-toast-eligibility.ts create mode 100644 server/src/services/issue-participation.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 9d029961800..bd4b0738ec2 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1130,6 +1130,10 @@ identity shows no failed runs. All retains company-wide failed-run visibility. Company health alerts do not contribute to the personal inbox badge. Run list responses, including summaries, retain `responsibleUserId`. +Live task and run toasts require personal task participation or an explicit user +mention, independently of read access. Agent ownership and responsible-user +attribution alone do not qualify. See [toast semantics](TOASTS.md). + Ownership split: - **Core / Free:** permission key and scoped-grant enforcement; responsible-user resolution; default-open, disabled, and allowlist policy modes; archive/unarchive APIs; per-user archive persistence; resurfacing behavior; activity audit records; and stable denial codes. diff --git a/doc/TOASTS.md b/doc/TOASTS.md new file mode 100644 index 00000000000..5831cc6cb67 --- /dev/null +++ b/doc/TOASTS.md @@ -0,0 +1,75 @@ +# Toast semantics + +Toasts are transient feedback, not a company activity feed. Live task notifications +are personal to the connected human, including in multiplayer and `local_trusted` +mode (`local-board`). Access to a task does not subscribe someone to notifications. + +## Which tasks qualify + +A task qualifies when the viewer created it, is its current human assignee, +authored a comment, or performed a deliberate action included in the existing +Mine participation allowlist in +[`issue-participation.ts`](../server/src/services/issue-participation.ts). +Read receipts, denied requests, and automatic read-time recovery cleanup do not +count. Comment authorship follows Mine's existing historical participation rule. + +A task also qualifies when its current description or a non-deleted comment +contains an explicit mention of the viewer: `[Name](user://USER_ID)`. The shared +user-mention parser resolves the exact user ID; plain names and bare URLs do not +count. Existing comments are checked, so users need not have opened the task. +The event introducing the first mention can notify immediately. Removing the +only mention removes that qualification unless another participation rule holds. +Historical comment lookup uses an exact viewer-link regex backed by the existing +comment-body trigram index and returns at most one body for the shared parser. + +Agent ownership, primary-agent preference, task/run `responsibleUserId`, company +membership, and involvement in a parent task do not qualify on their own. +Activity by any person or agent can notify a qualifying viewer. These rules do +not change Mine membership, task permissions, assignment, or inbox archive state. + +## Events and suppression + +| Event | Toast behavior | +| --- | --- | +| Task created, updated, or commented on | Requires personal task eligibility. The viewer's own activity stays quiet. | +| Run failed, timed out, or cancelled | Requires eligibility for the persisted run's task. An event's task hint or agent identity is not sufficient. | +| Agent error without a task, or run without a task | No live toast. Status remains available in company views. | +| Run started or succeeded | No live toast. | +| Save, Copy, and other direct action feedback | Existing caller-owned feedback is unchanged. | +| Company join request | Existing join-request notification behavior is unchanged. | + +Eligibility is an additional gate, not a promise that every event produces a +toast. Existing rules still suppress activity for the task in view and run +outcomes already represented by the visible task/run tree. Comment-driven updates +can be combined with their paired comment event when that metadata is available. +Intentional operator interruptions and workspace contention do not announce run +failures. Duplicate terminal outcomes and failures older than five minutes (when +timestamps are available) remain quiet. Reconnect suppression, per-category rate +limits, content deduplication, and toast lifetime limits remain in effect. Hidden +pages reconcile their data on return without replaying a notification backlog. +Archiving a task is not a notification unsubscribe. + +## Delivery contract + +The company WebSocket authorizes each event before evaluating relevance. Its +viewer-specific copy of the shared `LiveEvent` envelope contains `toastEligible`. +Only `true` permits a live task/run toast. Missing/false values, unresolved viewer +identity, and relevance lookup failures stay quiet. Agent-authenticated sockets +do not receive human toast eligibility. This field is computed per connection, +never persisted or published on the shared company event bus. + +Authorized events still reach cache handling and component subscribers when +eligibility is false. Lookup failure is logged but does not discard the authorized +event. Existing task/run privacy checks and payload redaction remain in force. +When an authenticated comment event omits the comment ID, the visible task thread +refreshes through its authorized HTTP read instead of trying to hydrate the comment. +Closed sockets cannot deliver stale notifications after account/company changes. +No client-side task-list cache or new database migration is required. During a +mixed-version rollout, a new client receiving events from an older server stays +quiet until the server provides eligibility. + +## Verification + +See [`inbox-search/toasts`](../maps/feature-map/inbox-search.md#toasts) for the +two-user browser recipe and linked regression suites. The map describes coverage; +it is not evidence that a particular deployment passed that journey. diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md index 84ada459d12..60e3265de7a 100644 --- a/maps/agent-access-map/inbox-search.md +++ b/maps/agent-access-map/inbox-search.md @@ -119,3 +119,29 @@ remaining operations before qualifying the whole capability. Gaps: General company search and dedicated task search are different result sets; use the appropriate one. + + +## `toasts` + +Review: `baseline`. + +API: `present` — GET /api/companies/{companyId}/events/ws (WebSocket upgrade); viewer-specific +`toastEligible` metadata on authorized events. No notification mutation endpoint. + +Agent access: `not-applicable` — toast presentation is a human browser concern. +Agent-authenticated event subscriptions retain authorization but do not qualify +for human toasts. Agent task mutations may notify participating humans. + +Policy: Company membership and task/run read checks precede human participation +checks. Creator, current human assignee, commenter, deliberate actor, or explicit +user mention qualifies; agent ownership and responsible-user attribution alone do +not. See [toast semantics](../../doc/TOASTS.md). + +Evidence: `source-only` — [socket projection](../../server/src/realtime/live-events-ws.ts) +and [eligibility](../../server/src/realtime/task-toast-eligibility.ts). + +Verification: `not-run` — Connect two users and an agent; assert per-user eligibility without +losing authorized cache updates. Deny revoked membership and private task access. +Confirm relevance lookup failure yields a quiet authorized event. + +Gaps: Browser recipes and regression sources are not deployment acceptance proof. diff --git a/maps/feature-map/README.md b/maps/feature-map/README.md index 0ac2ab9a5b9..94aae4ea71c 100644 --- a/maps/feature-map/README.md +++ b/maps/feature-map/README.md @@ -31,7 +31,7 @@ manual verification steps, and gotchas. | --- | --- | | [Task creation and lifecycle](./tasks.md) | Creation, assignment, lists, properties, comments, and completion. | | [Delegation, dependencies, and signoff](./task-coordination.md) | Child work, prerequisites, reviewers, and task-tree controls. | -| [Inbox, decisions, and search](./inbox-search.md) | Personal triage, decision queues, unread/blocked views, and search. | +| [Inbox, decisions, and search](./inbox-search.md) | Personal triage, decision queues, unread/blocked views, search, and personal task toasts. | | [Agent conversations and project handoff](./agent-chat.md) | Persistent conversations, discovery, handoff, and gated board chat. | | [Questions and approvals](./questions-and-approvals.md) | Question/plan responses, formal approvals, and app-tool review. | | [Steering and queued messages](./steering.md) | Follow-ups, queue edits, interruption, and pause/resume. | diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 14df9295962..687ec84534b 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -12,6 +12,7 @@ Agent access: [companion](../agent-access-map/inbox-search.md). - `triage`: read or archive attention items without pretending the underlying task is complete. - `decisions`: open the source of a decision and act in the right context. - `search`: query company work and follow a result to its original record. +- `toasts`: receive live task notifications only for personal participation or explicit mentions. ## How to get to it (user POV) @@ -27,6 +28,11 @@ Open `/decisions`, then a queue at `/decisions/queues/:key` and its source item. Open `/search` or the navigation search entry. +### `toasts` + +Keep a company page open while another person or agent updates a task. +See [toast semantics](../../doc/TOASTS.md) for eligibility and suppression rules. + ## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed assigned, unread, blocked, and resolved tasks plus a pending decision. Record the combined Inbox/Tasks experimental setting. @@ -60,8 +66,38 @@ Automated: [search page](../../ui/src/pages/Search.test.tsx) and [company search Manual: Search a unique task title and content phrase, open the result, and compare the actual record. Test filters, no results, and a second company. Verify stale results do not expose inaccessible data. +### `toasts` + +Automated: [persisted eligibility](../../server/src/__tests__/task-toast-eligibility.test.ts) +checks creation, assignment, comments, mentions, deliberate edits, excluded read +activity, company isolation, local-board, and persisted run-task attribution. +[Socket delivery](../../server/src/__tests__/live-events-toast.test.ts) checks +viewer-specific projection; [UI delivery](../../ui/src/context/LiveUpdatesProvider.hook.test.tsx) +checks eligibility gating, cache updates, stale callbacks, and existing suppression. +[UI cache handling](../../ui/src/context/LiveUpdatesProvider.test.ts) checks visible +comment refresh when authenticated events omit hydration metadata. +The `task toasts follow personal involvement in multiplayer` case in the +[authenticated browser suite](../../tests/e2e/multi-user-authenticated.spec.ts) +exercises quiet unrelated updates, read-only visits, first/historical mentions, +self-activity, and visible-task suppression against persisted data. + +Manual: Use two authenticated browser sessions in the same isolated company. +Create separate tasks for each person and use one shared agent. From a company +task list, verify that the other person's unrelated task updates produce no +toast but still refresh the list. Comment on one of their tasks, then have them +update it: a toast should appear. Repeat using assignment, a deliberate edit, +and a first explicit user mention in a comment or description. Reload before a +later update to prove eligibility does not depend on browser history. Mere +viewing, agent ownership, responsible-user attribution, and parent participation +must remain quiet. Task-linked failures follow the same participation rule; +generic agent errors remain quiet. Repeat while viewing the relevant task and +after switching company/account to verify suppression and isolation. + ## Gotchas +- Notifications do not grant task access or change Mine membership. +- A feature-map recipe and mocked UI tests do not prove live multiplayer behavior. + - Combined Inbox/Tasks requires both `enableCombinedInboxTasks` and `enableStreamlinedUi`. [Standalone Inbox tests](../../ui/src/pages/Inbox.test.tsx) render archive/read controls and toolbars; this is not live multi-user evidence. diff --git a/packages/shared/src/types/live.ts b/packages/shared/src/types/live.ts index 805db173bb8..b4da9a874b9 100644 --- a/packages/shared/src/types/live.ts +++ b/packages/shared/src/types/live.ts @@ -5,5 +5,7 @@ export interface LiveEvent { companyId: string; type: LiveEventType; createdAt: string; + /** Viewer-specific WebSocket projection; absent/false means no task toast. */ + toastEligible?: boolean; payload: Record; } diff --git a/server/src/__tests__/live-events-toast.test.ts b/server/src/__tests__/live-events-toast.test.ts index 5cddd929a9a..68c00feaca0 100644 --- a/server/src/__tests__/live-events-toast.test.ts +++ b/server/src/__tests__/live-events-toast.test.ts @@ -7,6 +7,9 @@ import { WebSocket } from "ws"; import { describe, expect, it, vi } from "vitest"; import { setupLiveEventsWebSocketServer } from "../realtime/live-events-ws.js"; import { publishLiveEvent } from "../services/live-events.js"; +import { taskToastEligible } from "../realtime/task-toast-eligibility.js"; + +vi.mock("../realtime/task-toast-eligibility.js", () => ({ taskToastEligible: vi.fn(async () => false) })); vi.mock("../services/authorization.js", () => ({ authorizationService: () => ({ decide: async ({ resource }: { resource: { issueId: string } }) => ({ allowed: resource.issueId !== "hidden" }) }), @@ -30,6 +33,65 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void } }; } describe("authenticated activity notifications", () => { + it("uses the persisted local-board user identity for local task eligibility", async () => { + vi.mocked(taskToastEligible).mockImplementation(async (_db, _event, userId) => userId === "local-board"); + const server = createServer(); + setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "local_trusted" }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`); + try { + await once(socket, "open"); + const delivery = once(socket, "message"); + publishLiveEvent({ companyId: "company", type: "activity.logged", payload: { + action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "agent", actorId: "agent", + } }); + const [data] = await delivery; + expect(JSON.parse(data.toString()).toastEligible).toBe(true); + expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board"); + } finally { + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false); + } + }); + + it("projects eligibility per viewer and keeps authorized updates on lookup failure", async () => { + vi.mocked(taskToastEligible).mockImplementation(async (_db, event, userId) => { + if (event.payload.entityId === "lookup-failure") throw new Error("Relevance unavailable"); + return userId === "alice"; + }); + const server = createServer(); + setupLiveEventsWebSocketServer(server, database() as never, { + deploymentMode: "authenticated", + resolveCloudActor: async (req) => ({ userId: String(req.headers["x-test-viewer"]), companyIds: ["company"] }), + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`; + const alice = new WebSocket(url, { headers: { "x-test-viewer": "alice" } }); + const bob = new WebSocket(url, { headers: { "x-test-viewer": "bob" } }); + try { + await Promise.all([once(alice, "open"), once(bob, "open")]); + for (const entityId of ["visible", "lookup-failure"]) { + const deliveries = Promise.all([once(alice, "message"), once(bob, "message")]); + const original = publishLiveEvent({ companyId: "company", type: "activity.logged", payload: { + action: "issue.comment_added", entityType: "issue", entityId, actorType: "agent", actorId: "agent", + } }); + const [a, b] = (await deliveries).map(([data]) => JSON.parse(data.toString())); + expect(a.toastEligible).toBe(entityId === "visible"); + expect(b.toastEligible).toBe(false); + expect(a.payload.entityId).toBe(entityId); + expect(b.payload.entityId).toBe(entityId); + expect(original).not.toHaveProperty("toastEligible"); + } + } finally { + alice.terminate(); bob.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false); + } + }); + it("keeps actor identity after authorization and excludes hidden tasks and private details", async () => { const server = createServer(); setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated", diff --git a/server/src/__tests__/task-toast-eligibility.test.ts b/server/src/__tests__/task-toast-eligibility.test.ts new file mode 100644 index 00000000000..41540873ddf --- /dev/null +++ b/server/src/__tests__/task-toast-eligibility.test.ts @@ -0,0 +1,112 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db"; +import type { LiveEvent } from "@paperclipai/shared"; +import { taskToastEligible } from "../realtime/task-toast-eligibility.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("personal task toast eligibility (persisted data)", () => { + let temp: Awaited>; + let db: ReturnType; + const companyId = randomUUID(); + const agentId = randomUUID(); + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-task-toasts-"); + db = createDb(temp.connectionString); + await db.insert(companies).values({ id: companyId, name: "Toast test", issuePrefix: "TST" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Shared agent", role: "engineer", adapterType: "process" }); + }, 120_000); + afterAll(async () => { await temp?.cleanup(); }); + + async function task(overrides: Partial = {}) { + const id = randomUUID(); + await db.insert(issues).values({ id, companyId, title: "Task", ...overrides }); + return id; + } + function event(issueId: string): LiveEvent { + return { id: 1, companyId, type: "activity.logged", createdAt: new Date().toISOString(), + payload: { entityType: "issue", entityId: issueId, action: "issue.comment_added", actorType: "agent", actorId: agentId } }; + } + async function onlyAlice(id: string) { + expect(await taskToastEligible(db, event(id), "alice")).toBe(true); + expect(await taskToastEligible(db, event(id), "bob")).toBe(false); + } + + it("qualifies creators and current human assignees independently", async () => { + await onlyAlice(await task({ createdByUserId: "alice" })); + await onlyAlice(await task({ assigneeUserId: "alice" })); + await onlyAlice(await task({ description: "Please ask [Alice](user://alice)." })); + }); + it("qualifies a commenter without relying on browser caches", async () => { + const id = await task(); + await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: "alice", body: "Following this." }); + await onlyAlice(id); + }); + it("qualifies the first mention and subsequent events, excluding deleted mentions and plain names", async () => { + const id = await task({ description: "alice user://alice" }); + expect(await taskToastEligible(db, event(id), "alice")).toBe(false); + const [comment] = await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, + body: "[Alice](user://alice) please review" }).returning(); + await onlyAlice(id); + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, body: "A later update" }); + await onlyAlice(id); + await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, comment.id)); + expect(await taskToastEligible(db, event(id), "alice")).toBe(false); + }); + it("counts deliberate edits but not reads, rejected requests, or automatic cleanup", async () => { + const id = await task(); + for (const action of ["issue.read_marked", "issue.resource_access_denied", "issue.recovery_action_resolved"]) { + await db.insert(activityLog).values({ companyId, entityType: "issue", entityId: id, actorType: "user", actorId: "alice", + action, details: { source: "source_revalidation", trigger: "read_projection" } }); + } + expect(await taskToastEligible(db, event(id), "alice")).toBe(false); + await db.insert(activityLog).values({ companyId, entityType: "issue", entityId: id, actorType: "user", actorId: "alice", action: "issue.updated" }); + await onlyAlice(id); + }); + it("finds an exact historical mention without loading unrelated mention history", async () => { + const id = await task(); + await db.insert(issueComments).values(Array.from({ length: 500 }, (_, index) => ({ + companyId, issueId: id, authorAgentId: agentId, + body: `[Another viewer](user://someone-${index}) alice user://alice [Similar](user://alice-extra)`, + }))); + expect(await taskToastEligible(db, event(id), "alice")).toBe(false); + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, + body: "[Alice](user://alice?source=review#note)" }); + await onlyAlice(id); + }); + it("escapes regex syntax in viewer IDs and preserves parsed href forms", async () => { + const id = await task(); + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, + body: "[Different user](user://aliceXX)" }); + expect(await taskToastEligible(db, event(id), "alice.+")).toBe(false); + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, + body: "[Alice](user:///alice.+) [Bob](user://name@bob)" }); + expect(await taskToastEligible(db, event(id), "alice.+")).toBe(true); + expect(await taskToastEligible(db, event(id), "bob")).toBe(true); + }); + it("does not inherit involvement from responsibility, a shared agent, or a parent", async () => { + const parentId = await task({ createdByUserId: "alice" }); + const id = await task({ parentId, createdByAgentId: agentId, assigneeAgentId: agentId, responsibleUserId: "alice" }); + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, body: "Agent update" }); + expect(await taskToastEligible(db, event(id), "alice")).toBe(false); + }); + it("uses the persisted run task, never an event task hint or responsible user", async () => { + const mine = await task({ createdByUserId: "alice" }); + const other = await task({ createdByUserId: "bob" }); + const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, issueId: other, scopeKind: "issue", responsibleUserId: "alice", status: "failed" }).returning(); + const e: LiveEvent = { ...event(mine), type: "heartbeat.run.status", payload: { runId: run.id, issueId: mine, status: "failed" } }; + expect(await taskToastEligible(db, e, "alice")).toBe(false); + expect(await taskToastEligible(db, e, "bob")).toBe(true); + await db.update(heartbeatRuns).set({ issueId: null, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id)); + expect(await taskToastEligible(db, e, "bob")).toBe(false); + }); + it("fails closed for missing identity, company mismatch, and generic agent errors; supports local-board", async () => { + const id = await task({ createdByUserId: "local-board" }); + expect(await taskToastEligible(db, event(id), "local-board")).toBe(true); + expect(await taskToastEligible(db, event(id), null)).toBe(false); + expect(await taskToastEligible(db, { ...event(id), companyId: randomUUID() }, "local-board")).toBe(false); + expect(await taskToastEligible(db, { ...event(id), type: "agent.status" }, "local-board")).toBe(false); + }); +}); diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 9351d54d1e7..7fe53906e35 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -9,6 +9,7 @@ import type { DeploymentMode, LiveEvent } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "../middleware/logger.js"; import { activityToastIdentity } from "./activity-toast-identity.js"; +import { taskToastEligible } from "./task-toast-eligibility.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; import { trackIdleWork } from "../services/task-admission.js"; @@ -338,7 +339,18 @@ export function setupLiveEventsWebSocketServer( // 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)); + if (visible) { + // Relevance is independent of authorization and cache delivery. A + // failed relevance lookup must keep the authorized update flowing. + let toastEligible = false; + try { + toastEligible = await taskToastEligible(db, visible, + context.actor.type === "board" ? context.actor.userId ?? null : null); + } catch (err) { + logger.warn({ err, companyId: context.companyId }, "task toast eligibility failed"); + } + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ ...visible, toastEligible })); + } }).catch(err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed")) .finally(() => { pending -= 1; }); }); diff --git a/server/src/realtime/task-toast-eligibility.ts b/server/src/realtime/task-toast-eligibility.ts new file mode 100644 index 00000000000..9aeb45e68c2 --- /dev/null +++ b/server/src/realtime/task-toast-eligibility.ts @@ -0,0 +1,47 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { heartbeatRuns, issueComments, issues, type Db } from "@paperclipai/db"; +import { extractUserMentionIds, type LiveEvent } from "@paperclipai/shared"; +import { touchedByUserCondition } from "../services/issue-participation.js"; + +const TASK_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); +const RUN_STATUSES = new Set(["failed", "timed_out", "cancelled"]); + +/** Relevance only: the caller must authorize the event before invoking this. */ +export async function taskToastEligible(db: Db, event: LiveEvent, userId: string | null): Promise { + if (!userId) return false; + const { payload, companyId } = event; + let issueId: string | null = null; + if (event.type === "activity.logged" && payload.entityType === "issue" && TASK_ACTIONS.has(String(payload.action))) { + issueId = typeof payload.entityId === "string" ? payload.entityId : null; + } else if (event.type === "heartbeat.run.status" && RUN_STATUSES.has(String(payload.status))) { + if (typeof payload.runId !== "string") return false; + const [run] = await db.select({ issueId: heartbeatRuns.issueId }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, payload.runId))).limit(1); + issueId = run?.issueId ?? null; + } + if (!issueId) return false; + + const [issue] = await db.select({ + description: issues.description, + }).from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))).limit(1); + if (!issue) return false; + if (extractUserMentionIds(issue.description ?? "").includes(userId)) return true; + // Keep the correlated predicate in WHERE, as in Mine. Drizzle strips table + // qualifiers from SQL selections, which would bind inner IDs incorrectly. + const [participation] = await db.select({ id: issues.id }).from(issues).where(and( + eq(issues.companyId, companyId), eq(issues.id, issueId), touchedByUserCondition(companyId, userId), + )).limit(1); + if (participation) return true; + + // Match a complete link for this exact viewer in PostgreSQL. The existing + // body_search GIN trigram index supports this regex; LIMIT 1 bounds the + // history returned to each connection instead of parsing every comment. + const exactId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const href = `user://(?:[^/\\s?#)]+@)?${exactId}|user:///+${exactId}`; + const mentionLink = `\\[[^\\]]*\\]\\((?:${href})(?:[?#][^\\s)]*)?\\)`; + const [comment] = await db.select({ body: issueComments.body }).from(issueComments).where(and( + eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), + isNull(issueComments.deletedAt), sql`${issueComments.body} ~ ${mentionLink}`, + )).limit(1); + return !!comment && extractUserMentionIds(comment.body).includes(userId); +} diff --git a/server/src/services/issue-participation.ts b/server/src/services/issue-participation.ts new file mode 100644 index 00000000000..27b9899041c --- /dev/null +++ b/server/src/services/issue-participation.ts @@ -0,0 +1,103 @@ +import { sql } from "drizzle-orm"; +import { activityLog, issueComments, issues } from "@paperclipai/db"; + +// Mine participation fails closed. Add new user-authored issue mutation actions +// here instead of admitting every issue activity, because reads, previews, and +// denied resource requests are audited too. +const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [ + "issue.accepted_plan_decomposition_updated", + "issue.admin_force_release", + "issue.approval_linked", + "issue.approval_unlinked", + "issue.approvers_updated", + "issue.assigned", + "issue.attachment_added", + "issue.attachment_removed", + "issue.blockers.updated", + "issue.blockers_updated", + "issue.checked_out", + "issue.checkout", + "issue.child_created", + "issue.comment_cancelled", + "issue.document_annotation_comment_added", + "issue.document_annotation_remapped", + "issue.document_annotation_thread_created", + "issue.document_annotation_thread_resolved", + "issue.document_deleted", + "issue.document_locked", + "issue.document_restored", + "issue.document_unlocked", + "issue.document_updated", + "issue.document_upserted", + "issue.feedback_vote_saved", + "issue.inbox_touched", + "issue.low_trust_output_promoted", + "issue.monitor_cleared", + "issue.monitor_scheduled", + "issue.recovery_action_resolved", + "issue.relations.updated", + "issue.released", + "issue.reviewers_updated", + "issue.scheduled_retry_retry_now", + "issue.successful_run_handoff_resolved", + "issue.task_watchdog_fingerprint_reviewed", + "issue.thread_interaction_accepted", + "issue.thread_interaction_answered", + "issue.thread_interaction_cancelled", + "issue.thread_interaction_created", + "issue.thread_interaction_item_verdicts_submitted", + "issue.thread_interaction_withdrawn", + "issue.tree_cancel_status_updated", + "issue.tree_hold_created", + "issue.tree_hold_released", + "issue.tree_restore_status_updated", + "issue.updated", + "issue.watchdog_created", + "issue.watchdog_removed", + "issue.work_product_created", + "issue.work_product_deleted", + "issue.work_product_updated", +] as const; + +// Old read projections attributed this automatic cleanup to the reader. Keep +// the audit rows, but do not count them as participation or issue activity. +export function automaticReadRecoveryCleanupCondition() { + return sql`( + ${activityLog.action} = 'issue.recovery_action_resolved' + AND COALESCE(${activityLog.details}->>'source', '') = 'source_revalidation' + AND COALESCE(${activityLog.details}->>'trigger', '') = 'read_projection' + )`; +} + +export function touchedByUserCondition(companyId: string, userId: string) { + return sql` + ( + ${issues.createdByUserId} = ${userId} + OR ${issues.assigneeUserId} = ${userId} + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + WHERE ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} = ${issues.id}::text + AND ${activityLog.companyId} = ${companyId} + AND ${activityLog.actorType} = 'user' + AND ${activityLog.actorId} = ${userId} + AND NOT ${automaticReadRecoveryCleanupCondition()} + AND ${activityLog.action} IN (${sql.join( + ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map( + (action) => sql`${action}`, + ), + sql`, `, + )}) + ) + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorUserId} = ${userId} + ) + ) + `; +} + diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 3149a777035..7c627f7b49f 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,3 +1,4 @@ +import { automaticReadRecoveryCleanupCondition, touchedByUserCondition } from "./issue-participation.js"; import { resolveProjectDefaultWorkspaceId } from "./project-default-workspace.js"; import { type AuthorizationActor, executionWorkspaceReadSqlCondition, projectReadSqlCondition } from "./authorization.js"; import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js"; @@ -2785,106 +2786,6 @@ async function getWorkspaceInheritanceIssue( return issue; } -// Mine participation fails closed. Add new user-authored issue mutation actions -// here instead of admitting every issue activity, because reads, previews, and -// denied resource requests are audited too. -const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [ - "issue.accepted_plan_decomposition_updated", - "issue.admin_force_release", - "issue.approval_linked", - "issue.approval_unlinked", - "issue.approvers_updated", - "issue.assigned", - "issue.attachment_added", - "issue.attachment_removed", - "issue.blockers.updated", - "issue.blockers_updated", - "issue.checked_out", - "issue.checkout", - "issue.child_created", - "issue.comment_cancelled", - "issue.document_annotation_comment_added", - "issue.document_annotation_remapped", - "issue.document_annotation_thread_created", - "issue.document_annotation_thread_resolved", - "issue.document_deleted", - "issue.document_locked", - "issue.document_restored", - "issue.document_unlocked", - "issue.document_updated", - "issue.document_upserted", - "issue.feedback_vote_saved", - "issue.inbox_touched", - "issue.low_trust_output_promoted", - "issue.monitor_cleared", - "issue.monitor_scheduled", - "issue.recovery_action_resolved", - "issue.relations.updated", - "issue.released", - "issue.reviewers_updated", - "issue.scheduled_retry_retry_now", - "issue.successful_run_handoff_resolved", - "issue.task_watchdog_fingerprint_reviewed", - "issue.thread_interaction_accepted", - "issue.thread_interaction_answered", - "issue.thread_interaction_cancelled", - "issue.thread_interaction_created", - "issue.thread_interaction_item_verdicts_submitted", - "issue.thread_interaction_withdrawn", - "issue.tree_cancel_status_updated", - "issue.tree_hold_created", - "issue.tree_hold_released", - "issue.tree_restore_status_updated", - "issue.updated", - "issue.watchdog_created", - "issue.watchdog_removed", - "issue.work_product_created", - "issue.work_product_deleted", - "issue.work_product_updated", -] as const; - -// Old read projections attributed this automatic cleanup to the reader. Keep -// the audit rows, but do not count them as participation or issue activity. -function automaticReadRecoveryCleanupCondition() { - return sql`( - ${activityLog.action} = 'issue.recovery_action_resolved' - AND COALESCE(${activityLog.details}->>'source', '') = 'source_revalidation' - AND COALESCE(${activityLog.details}->>'trigger', '') = 'read_projection' - )`; -} - -function touchedByUserCondition(companyId: string, userId: string) { - return sql` - ( - ${issues.createdByUserId} = ${userId} - OR ${issues.assigneeUserId} = ${userId} - OR EXISTS ( - SELECT 1 - FROM ${activityLog} - WHERE ${activityLog.entityType} = 'issue' - AND ${activityLog.entityId} = ${issues.id}::text - AND ${activityLog.companyId} = ${companyId} - AND ${activityLog.actorType} = 'user' - AND ${activityLog.actorId} = ${userId} - AND NOT ${automaticReadRecoveryCleanupCondition()} - AND ${activityLog.action} IN (${sql.join( - ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map( - (action) => sql`${action}`, - ), - sql`, `, - )}) - ) - OR EXISTS ( - SELECT 1 - FROM ${issueComments} - WHERE ${issueComments.issueId} = ${issues.id} - AND ${issueComments.companyId} = ${companyId} - AND ${issueComments.authorUserId} = ${userId} - ) - ) - `; -} - function participatedByAgentCondition(companyId: string, agentId: string) { return sql` ( diff --git a/tests/e2e/multi-user-authenticated.spec.ts b/tests/e2e/multi-user-authenticated.spec.ts index 4f291a40209..0a9611e81e3 100644 --- a/tests/e2e/multi-user-authenticated.spec.ts +++ b/tests/e2e/multi-user-authenticated.spec.ts @@ -383,3 +383,112 @@ test("agent chats keep personal identity and ordinary company visibility", async } } finally { await invited.context.close(); } }); + +test("task toasts follow personal involvement in multiplayer", async ({ browser, page }, testInfo) => { + test.setTimeout(180_000); + page.setDefaultTimeout(15_000); + // Observe the real socket handshake so mutations after navigation do not + // race subscription setup. No network responses or event payloads are mocked. + await page.addInitScript(() => { + const NativeWebSocket = window.WebSocket; + const sockets: WebSocket[] = []; + (window as unknown as { toastTestSockets: WebSocket[] }).toastTestSockets = sockets; + window.WebSocket = class extends NativeWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + sockets.push(this); + } + }; + }); + await signUp(page, { ...ownerUser, email: `toasts-${ownerUser.email}` }); + const bootstrapToken = new URL(createBootstrapInvite()).pathname.split("/").at(-1); + expect((await sessionJsonRequest(page, `${BASE}/api/invites/${bootstrapToken}/accept`, { + method: "POST", data: { requestType: "human" }, + })).ok).toBe(true); + const company = await createCompanyForSession(page, `Personal notifications ${runId}`); + const prefix = company.issuePrefix ?? company.id; + const invite = await sessionJsonRequest<{ inviteUrl: string }>(page, `${BASE}/api/companies/${company.id}/invites`, { + method: "POST", data: { allowedJoinTypes: "human", humanRole: "operator" }, + }); + expect(invite.ok).toBe(true); + const other = await newPage(browser); + try { + await signUp(other.page, { ...invitedUser, email: `toasts-${invitedUser.email}` }); + const token = new URL(invite.json!.inviteUrl, BASE).pathname.split("/").at(-1); + expect((await sessionJsonRequest(other.page, `${BASE}/api/invites/${token}/accept`, { + method: "POST", data: { requestType: "human" }, + })).ok).toBe(true); + const session = await sessionJsonRequest<{ user: { id: string } }>(page, `${BASE}/api/auth/get-session`); + const viewerId = session.json!.user.id; + const task = await sessionJsonRequest<{ id: string; identifier: string }>(other.page, `${BASE}/api/companies/${company.id}/issues`, { + method: "POST", data: { title: "Another person's task", status: "backlog" }, + }); + expect(task.ok).toBe(true); + const id = task.json!.id; + const frames: Array<{ toastEligible?: boolean; payload: { entityId?: string; action?: string } }> = []; + page.on("websocket", socket => socket.on("framereceived", frame => { + try { frames.push(JSON.parse(String(frame.payload))); } catch { /* ignore control frames */ } + })); + await page.goto(`${BASE}/${prefix}/issues?view=all`); + await expect(page.getByText("Another person's task", { exact: true }).first()).toBeVisible(); + await other.page.goto(`${BASE}/${prefix}/issues?view=all`); + const toasts = page.locator('aside[aria-live="polite"]'); + async function waitForSocket() { + await page.waitForFunction((companyId) => + (window as unknown as { toastTestSockets: WebSocket[] }).toastTestSockets.some(socket => + socket.url.includes(`/companies/${companyId}/events/ws`) && socket.readyState === WebSocket.OPEN), company.id); + } + async function update(title: string, eligible: boolean) { + await waitForSocket(); + // Let the existing two-second server list cache expire after navigation. + // This case tests steady-state notification scoping, not cache TTL policy. + await page.waitForTimeout(2200); + const before = frames.length; + expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}`, { + method: "PATCH", data: { title }, + })).ok).toBe(true); + await expect.poll(() => frames.slice(before).find(e => e.payload.entityId === id && e.payload.action === "issue.updated")?.toastEligible) + .toBe(eligible); + await expect(page.getByText(title, { exact: true }).first()).toBeVisible(); + if (eligible) { + await expect(toasts).toContainText("Invited User updated"); + await testInfo.attach(title, { body: await page.screenshot(), contentType: "image/png" }); + await page.getByRole("button", { name: "Dismiss notification" }).click(); + } else { + await expect(toasts).toHaveCount(0); + } + // Each qualifying update is a distinct observation, outside deduplication. + await page.waitForTimeout(3600); + } + await update("Unrelated update still refreshes the list", false); + // Visiting alone must not subscribe the user. + await page.getByRole("link", { name: `Open ${task.json!.identifier}: Unrelated update still refreshes the list`, exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/issues/(${id}|${task.json!.identifier})`)); + await page.goto(`${BASE}/${prefix}/issues?view=all`); + await update("Viewing is still quiet", false); + // Use persisted API fixtures to vary participation, observing actual live UI. + expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}/comments`, { + method: "POST", data: { body: `Please review [Owner](user://${viewerId}).` }, + })).ok).toBe(true); + await expect(toasts).toContainText("Invited User commented on"); + await testInfo.attach("First mention notifies", { body: await page.screenshot(), contentType: "image/png" }); + await page.getByRole("button", { name: "Dismiss notification" }).click(); + await page.reload(); + await expect(page.getByText("Viewing is still quiet", { exact: true }).first()).toBeVisible(); + await update("Historical mention survives reload", true); + // Same event must not toast to its author in the second authenticated session. + await expect(other.page.locator('aside[aria-live="polite"]')).toHaveCount(0); + // Viewing the qualifying task suppresses redundant notifications. + await page.getByRole("link", { name: `Open ${task.json!.identifier}: Historical mention survives reload`, exact: true }).click(); + await expect(page.getByRole("heading", { name: "Historical mention survives reload", exact: true })).toBeVisible(); + await waitForSocket(); + const beforeComment = frames.length; + expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}/comments`, { + method: "POST", data: { body: "Already visible in the task." }, + })).ok).toBe(true); + await expect.poll(() => frames.slice(beforeComment).find(e => e.payload.entityId === id && e.payload.action === "issue.comment_added")?.toastEligible) + .toBe(true); + await expect(page.getByText("Already visible in the task.", { exact: true }).first()).toBeVisible(); + await expect(toasts).toHaveCount(0); + } finally { await other.context.close(); } +}); diff --git a/ui/src/context/LiveUpdatesProvider.hook.test.tsx b/ui/src/context/LiveUpdatesProvider.hook.test.tsx index 8650bfc355a..7ebe3c6dae5 100644 --- a/ui/src/context/LiveUpdatesProvider.hook.test.tsx +++ b/ui/src/context/LiveUpdatesProvider.hook.test.tsx @@ -8,9 +8,12 @@ import type { LiveEvent } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryKeys } from "../lib/queryKeys"; -const { pushToast } = vi.hoisted(() => ({ pushToast: vi.fn(() => "toast-id") })); +const { pushToast, companySelection } = vi.hoisted(() => ({ + pushToast: vi.fn(() => "toast-id"), + companySelection: { id: "company-1" }, +})); vi.mock("./CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1" } }), + useCompany: () => ({ selectedCompanyId: companySelection.id, selectedCompany: { id: companySelection.id } }), })); vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast }) })); vi.mock("../lib/router", () => ({ useLocation: () => ({ pathname: "/PAP/issues/PAP-204" }) })); @@ -133,6 +136,7 @@ describe("LiveUpdatesProvider socket run notification scope", () => { beforeEach(() => { pushToast.mockClear(); + companySelection.id = "company-1"; queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); queryClient.setQueryData(queryKeys.auth.session, { user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }); queryClient.setQueryData(queryKeys.issues.detail("PAP-204"), { @@ -170,17 +174,87 @@ describe("LiveUpdatesProvider socket run notification scope", () => { vi.unstubAllGlobals(); }); - async function receiveStatus(payload: Record, createdAt = "2026-09-09T18:00:00.000Z") { + async function receiveStatus(payload: Record, createdAt = "2026-09-09T18:00:00.000Z", toastEligible: boolean | undefined = true) { await reactAct(async () => { root!.render(Visible task); }); await vi.waitFor(() => expect(sockets).toHaveLength(1)); expect(sockets[0]?.onmessage).toBeTypeOf("function"); await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", { - data: JSON.stringify({ id: Date.now(), companyId: "company-1", type: "heartbeat.run.status", createdAt, payload }), + data: JSON.stringify({ id: Date.now(), companyId: "company-1", type: "heartbeat.run.status", createdAt, payload, toastEligible }), }))); } + it.each([false, undefined])("keeps ineligible runs quiet (%s) while updating their cache", async (eligibility) => { + queryClient.setQueryData(queryKeys.runDetail("other-run"), { id: "other-run", status: "running" }); + // Pass an event directly to exercise a missing property, not the helper default. + await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" }); + await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", { + data: JSON.stringify({ id: 99, companyId: "company-1", type: "heartbeat.run.status", + createdAt: "2026-09-09T18:00:00.000Z", toastEligible: eligibility, + payload: { runId: "other-run", agentId: "other-agent", status: "failed" } }), + }))); + expect(pushToast).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(queryKeys.runDetail("other-run"))).toMatchObject({ status: "failed" }); + }); + + it.each([true, false, undefined])("gates task activity (%s)", async (eligibility) => { + await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" }); + await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", { + data: JSON.stringify({ id: 99, companyId: "company-1", type: "activity.logged", + createdAt: "2026-09-09T18:00:00.000Z", toastEligible: eligibility, + payload: { entityType: "issue", entityId: "other-task", action: "issue.comment_added", actorType: "user", actorId: "another-user" } }), + }))); + expect(pushToast).toHaveBeenCalledTimes(eligibility === true ? 1 : 0); + }); + + it("keeps generic agent errors quiet even with eligibility metadata", async () => { + await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" }); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", { + data: JSON.stringify({ id: 99, companyId: "company-1", type: "agent.status", toastEligible: true, + createdAt: "2026-09-09T18:00:00.000Z", payload: { agentId: "other-agent", status: "error" } }), + }))); + expect(pushToast).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ + queryKey: queryKeys.agents.detail("other-agent"), + }))); + }); + + it("ignores a callback retained from a closed socket", async () => { + await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" }); + const staleCallback = sockets[0]!.onmessage!; + await reactAct(async () => root!.unmount()); + root = null; + staleCallback(new MessageEvent("message", { data: JSON.stringify({ + id: 99, companyId: "company-1", type: "heartbeat.run.status", toastEligible: true, + createdAt: "2026-09-09T18:00:00.000Z", payload: { runId: "stale-run", agentId: "other-agent", status: "failed" }, + }) })); + expect(pushToast).not.toHaveBeenCalled(); + }); + + it.each(["account", "company"])("ignores old socket messages after switching %s", async (scope) => { + await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" }); + const staleCallback = sockets[0]!.onmessage!; + await reactAct(async () => { + if (scope === "account") { + queryClient.setQueryData(queryKeys.auth.session, { + user: { id: "new-viewer" }, session: { id: "new-session", userId: "new-viewer" }, + }); + } else { + companySelection.id = "company-2"; + } + root!.render(New scope); + }); + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + await reactAct(async () => staleCallback(new MessageEvent("message", { data: JSON.stringify({ + id: 99, companyId: "company-1", type: "heartbeat.run.status", toastEligible: true, + createdAt: "2026-09-09T18:00:00.000Z", payload: { runId: "stale-run", agentId: "other-agent", status: "failed" }, + }) }))); + expect(pushToast).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(queryKeys.runDetail("stale-run"))).toBeUndefined(); + }); + it("announces a run outcome once across minute-apart redeliveries but still refreshes caches", async () => { const clock = vi.spyOn(Date, "now"); const payload = { runId: "unrelated-run", agentId: "other-agent", status: "failed", finishedAt: "2026-09-09T18:00:00.000Z" }; @@ -223,7 +297,7 @@ describe("LiveUpdatesProvider socket run notification scope", () => { }); await vi.waitFor(() => expect(sockets).toHaveLength(2)); await reactAct(async () => sockets[1]!.onmessage!(new MessageEvent("message", { - data: JSON.stringify({ id: 2, companyId: "company-1", type: "heartbeat.run.status", createdAt: "2026-09-09T18:01:00.000Z", payload }), + data: JSON.stringify({ id: 2, companyId: "company-1", toastEligible: true, type: "heartbeat.run.status", createdAt: "2026-09-09T18:01:00.000Z", payload }), }))); expect(pushToast).toHaveBeenCalledTimes(1); }); diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 0e35df6486c..49ef0cf07a6 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -867,6 +867,25 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refetches visible comments when an authenticated event omits hydration details", () => { + const client = new QueryClient(); + client.setQueryData(queryKeys.issues.detail("PAP-759"), { + id: "issue-1", companyId: "company-1", identifier: "PAP-759", + }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "issue", entityId: "issue-1", action: "issue.comment_added", + actorType: "user", actorId: "another-user", + }, { userId: "viewer", agentId: null }, { + pathname: "/PAP/issues/PAP-759", isForegrounded: true, + }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("PAP-759") }); + expect(invalidate).not.toHaveBeenCalledWith({ + queryKey: queryKeys.issues.comments("PAP-759"), refetchType: "inactive", + }); + client.clear(); + }); + it("refreshes visible issue run queries when the displayed run changes status", () => { const invalidations: unknown[] = []; const cache = new Map([ diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 21f4c278b2a..fe26a868fb8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -773,6 +773,9 @@ function shouldDeferVisibleIssueCommentActivity( if (entityType !== "issue" || !entityId) return false; if (action !== "issue.comment_added") return false; + // Authenticated events omit comment details. Only defer a refetch when + // the event contains the ID needed to hydrate the new comment separately. + if (!readString(details?.commentId)) return false; const context = resolveVisibleIssueRouteContext( queryClient, @@ -1367,6 +1370,20 @@ function invalidateActivityQueries( entityId, details, ); + const visibleContext = options?.pathname + ? resolveVisibleIssueRouteContext(queryClient, options.pathname, { + isForegrounded: options.isForegrounded, + }) + : null; + if ( + visibleContext && + overlaps(visibleContext.issueRefs, buildIssueRefsForPayload(entityId, details)) && + !issueRefs.includes(visibleContext.routeIssueRef) + ) { + // Authenticated payloads omit the identifier. Include the visible + // route's cache key when its loaded task matches the event's UUID. + issueRefs.push(visibleContext.routeIssueRef); + } for (const ref of issueRefs) { const invalidationOptions = selfCommentActivity || @@ -1729,7 +1746,7 @@ function handleLiveEvent( invalidateVisibleIssueRunQueries(queryClient, pathname, payload); if (event.type === "heartbeat.run.status") { const toast = buildRunStatusToast(payload, nameOf); - if (toast && !suppressRunToast) { + if (toast && event.toastEligible === true && !suppressRunToast) { gatedPushToast(gate, pushToast, "run-status", toast); } } @@ -1761,22 +1778,8 @@ function handleLiveEvent( queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId), }); - const toast = buildAgentStatusToast( - payload, - nameOf, - queryClient, - expectedCompanyId, - ); - if ( - toast && - !shouldSuppressAgentStatusToastForVisibleIssue( - queryClient, - pathname, - payload, - ) - ) { - gatedPushToast(gate, pushToast, "agent-status", toast); - } + // Agent-wide errors have no task participation context. Task-linked + // failures are announced through the personally scoped run-status event. return; } @@ -1795,12 +1798,12 @@ function handleLiveEvent( } const action = readString(payload.action); const toast = - buildActivityToast( + (event.toastEligible === true ? buildActivityToast( queryClient, expectedCompanyId, payload, currentActor, - ) ?? buildJoinRequestToast(payload); + ) : null) ?? buildJoinRequestToast(payload); if ( toast && !shouldSuppressActivityToastForVisibleIssue( @@ -2031,6 +2034,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { }; nextSocket.onmessage = (message) => { + if (closed || socket !== nextSocket) return; if (!getPageVisibility().visible) return; const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; From 5e8e992df2b3d71f82091b7bbf29fd482854d9f5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:11:15 -0500 Subject: [PATCH 09/85] fix(storybook): reconcile capability associations and map omissions (#16154) ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The feature map describes user capabilities and verification recipes. > - Storybook holds isolated examples of those capabilities. > - Missing catalog links can hide existing examples. > - Some stories also expose shipped capabilities absent from the map. > - This pull request adds verified source associations and three paired map entries. > - Readers can find existing examples without changing their homes or URLs. ## Linked Issues or Issue Description Refs #16052, #16049 and #16046. **What existing behavior does this improve?** Storybook capability lookup and the feature/agent access maps. **Current behavior** Seventy capabilities have no example link. Some matching stories already exist. Primary-agent preferences and company decision/fast-response services appear only under broad neighboring capabilities. **Proposed behavior** Add source-supported links across 34 suites. Keep shared foundations in place while cross-listing them. Add primary-agent, decision-model and fast-response capabilities to both upstream maps. Retain explicit simulation and partial-example limits. **Reason and benefit** The catalog distinguishes missing associations from missing examples. The upstream map describes three existing product capabilities more precisely. **Breaking changes** None to product behavior, story exports, folders, sidebar titles or URLs. A shared foundation can declare a reasoned `home: "foundations"` override while referencing related capabilities. ## What Changed - Add 55 references across 34 suites. Replace primary-agent suites' reporting-line association with their own capability. - Add three source-reviewed capabilities and their API/actor boundaries to paired maps. - Preserve the shared Foundations home when cross-listing developer design examples. Add a validation regression test. - Record which exports demonstrate each association and what remains simulated or unverified. - Regenerate the catalog and capability data. Preserve all original 223 authored suites and 2,210 story IDs. Retain the two skill-removal stories added by current master, for 2,212 authored stories in total. - Reduce empty example lookups from 70 of 171 capabilities to 59 of 174. Links do not establish complete capability coverage. ## Verification - Passed: 27 catalog/generator/map tests, catalog validation, map structural checks, token gates and skill validation. - Passed: source inventory comparison preserves all authored files, titles, meta IDs and story IDs. - Passed: repository typecheck/build and Storybook build. Final built index has 2,248 entries and preserves all previous entries, including the two skills stories added by current master. - The broad local repository test run was interrupted before completion to resolve a new master conflict. A complete local suite pass is not claimed; fresh CI passed on the reconciled branch. - Browser: Design links to preserved Foundations stories; company-service sections link settings/history/journey suites; decision SetupToHistory passed 12 interaction steps; primary-agent ReplacePrimary passed four steps. No console errors in selected checks. - Greptile: 5/5 on final commit `805a8af62abe589907e7cc6ca1b415ce1e0cc3c0`, with no findings or unresolved threads. - Final-revision CI passed all 57 checks/statuses, including all eight browser shards, server and Runner tests, typecheck and build. The unchanged Cloud-auth mocked-response race seen on a superseded revision did not recur on the final commit. No test expectations were changed. - New agent-access evidence is source-only. Live provider and agent-credential verification is not established. ## Risks - Suite-level associations can represent only selected states. Notes retain those limits and existing review classifications. - In-memory connection drafts do not prove reload persistence. Resume-failure UI does not prove successful continuation. No-op budget controls do not prove relief or resumption. - Three map capabilities document existing behavior. No permission, API, provider or production component changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution and source inspection. The exact runtime model ID 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 #` / `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 --- .agents/skills/maintain-storybook/SKILL.md | 7 + maps/agent-access-map/agents.md | 33 ++ maps/agent-access-map/companies.md | 68 ++++ maps/feature-map/agents.md | 21 + maps/feature-map/companies.md | 28 ++ maps/storybook-map/INDEX.md | 121 +++--- maps/storybook-map/README.md | 3 + maps/storybook-map/REVIEW.md | 84 ++++ maps/storybook-map/catalog.json | 365 +++++++++++------- scripts/storybook-catalog.mjs | 11 +- scripts/storybook-catalog.test.mjs | 25 ++ ui/storybook/generated/feature-index.json | 15 + .../support/feature-map/FeatureOverview.tsx | 4 +- 13 files changed, 574 insertions(+), 211 deletions(-) diff --git a/.agents/skills/maintain-storybook/SKILL.md b/.agents/skills/maintain-storybook/SKILL.md index 822fdcd8981..66973bce51c 100644 --- a/.agents/skills/maintain-storybook/SKILL.md +++ b/.agents/skills/maintain-storybook/SKILL.md @@ -50,6 +50,13 @@ reasons rather than invented capabilities. Cross-cutting stories have one source and several references. Do not count historical/proposed stories as current feature coverage. +For shared foundations that demonstrate a mapped capability, keep their existing +folder/sidebar with `home: "foundations"` and an exception reason, then add related +capability references. Inspect the named exports before linking them. Notes must +name the demonstrated states and remaining limits; an association does not certify +the whole capability. If a story reveals a shipped capability absent upstream, +reconcile its feature recipe and access companion through map maintenance first. + Preserve story exports and IDs. Before moves/title changes, capture the existing built `index.json`; use explicit meta IDs when needed and compare IDs afterward. Place stories under `ui/storybook/stories//`. Use the feature diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index decd1fbe6d0..3e92c471667 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -140,3 +140,36 @@ remaining operations before qualifying the whole capability. Gaps: Provisioning checks agents:create. Enabling/running its built-in routine is not the same permission path. + +## `primary-agent` + +Review: `baseline`. + +API: `present` — GET and PUT /api/companies/{companyId}/primary-agent/me. + +Agent access: `restricted` — this is the authenticated human's personal +preference. Agent keys and native call_api use agent identity and are denied by +the board gate; no dedicated agent selection tool is present. + +Policy: Requires board user identity, company access and active membership, +except implicit-local board or instance administrator. Viewers may choose their +own primary. Selection requires a same-company readable, available agent and +is scoped to actor user ID; the request cannot select another user's preference. +Explicit null selection is rejected; leave/termination can clear an unavailable +choice. Initialization stays recorded so clearing does not silently replace it. + +Evidence: `source-only` — reviewed on 2026-10-13 at +`60bc0d5fee9a6e48db08fc016521fee3d9f57d67`: +[routes](../../server/src/routes/primary-agent.ts), +[service](../../server/src/services/primary-agent.ts), +[provider](../../ui/src/context/PrimaryAgentProvider.tsx), and +[route tests](../../server/src/__tests__/primary-agent.test.ts). + +Verification: `not-run` — select and read the preference as two company users; +assert isolation, wrong-company/unreadable targets, pending/terminated agent +rejection and agent-key denial. Verify replacement confirmation and fallback +precedence in the UI; inspect the cleared state after permitted leave/termination. + +Gaps: No agent mutation path for another person's preference. Live human/agent +access is unverified; UI fixture callbacks do not establish persistence or task +and Chat default resolution. diff --git a/maps/agent-access-map/companies.md b/maps/agent-access-map/companies.md index 85c41cefb79..0e3eabedc4b 100644 --- a/maps/agent-access-map/companies.md +++ b/maps/agent-access-map/companies.md @@ -115,3 +115,71 @@ remaining operations before qualifying the whole capability. Gaps: Exports use inline credential delivery and encrypted mutation receipts. New-company/ZIP import flows retain their board and transport constraints. + +## `decision-models` + +Review: `baseline`. + +API: `present` — GET and PUT /api/companies/{companyId}/decision-model; +POST /api/companies/{companyId}/decision-model/test; +GET /api/companies/{companyId}/decision-model/availability; +GET /api/companies/{companyId}/decision-model/history. + +Agent access: `partial` — same-company REST and admitted native call_api +availability/history reads where policy allows. Settings and the fixed Test +action require board identity. Native rollout/run gates follow the shared +[transport contract](README.md#native-rest-transport). + +Policy: Settings access requires board and company access; non-managers receive +no settings or choices. Configuration and tests additionally require connection +management authority (owner/admin, instance admin, or tools:manage_connections). +History requires company_scope:read and redacts task/run links without issue:read. +Background sponsorship and budget admission remain separate service checks. + +Evidence: `source-only` — reviewed on 2026-10-13 at +`60bc0d5fee9a6e48db08fc016521fee3d9f57d67`: +[routes](../../server/src/routes/decision-models.ts), +[service](../../server/src/services/decision-models.ts), and +[connection authority](../../server/src/routes/ai-connections.ts). + +Verification: `not-run` — read history with an allowed company actor and an actor +without task access; check link redaction. Reject agent configuration/tests and +board mutations without connection-management permission. Verify settings and +bounded test receipts with an owned simulated provider. + +Gaps: No agent settings/test path; permitted history does not delegate management. +Live native and agent-credential reads remain unverified. + +## `fast-responses` + +Review: `baseline`. + +API: `present` — GET and PUT /api/companies/{companyId}/fast-response; +GET /api/companies/{companyId}/fast-response/models; +GET /api/companies/{companyId}/fast-response/availability; +POST /api/companies/{companyId}/fast-response/test; +GET /api/companies/{companyId}/fast-response/history. + +Agent access: `partial` — same-company REST and admitted native call_api history +reads where policy allows, subject to the shared +[transport contract](README.md#native-rest-transport). +Settings, model selection, availability and test controls require board identity. + +Policy: Board and company access gate configuration surfaces. Updates, models +and tests additionally require connection-management authority. History requires +company_scope:read; task/run links require issue:read. The experiment, provider +account, sponsorship and budget controls still gate actual acknowledgements. + +Evidence: `source-only` — reviewed on 2026-10-13 at +`60bc0d5fee9a6e48db08fc016521fee3d9f57d67`: +[routes](../../server/src/routes/fast-responses.ts), +[service](../../server/src/services/fast-responses.ts), and +[connection authority](../../server/src/routes/ai-connections.ts). + +Verification: `not-run` — inspect history and redacted task links as an allowed +company actor. Reject agent settings/models/availability/tests and unauthorized +board mutations. Use a simulated provider to inspect a saved configuration, +success receipt, timeout and recorded usage. + +Gaps: No agent configuration/test path. An acknowledgement is not completed task +work. Live native, provider and agent access remain unverified. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 673d7c45982..db866c65d14 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -12,6 +12,7 @@ Agent access: [companion](../agent-access-map/agents.md). reporting relationships through the agent configuration afterward. - `instructions`: edit agent configuration and instruction files with durable revisions. - `organization`: inspect and change reporting relationships through supported controls. +- `primary-agent`: choose a personal primary agent for the company, confirm replacement, and inspect its roster/sidebar and task/chat defaults. - `lifecycle`: pause/resume or retire an agent while preserving history. - `built-in`: configure required agents without treating them as ordinary removable hires. @@ -29,6 +30,11 @@ Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. Use the roster’s organization view; `/org` redirects to the roster in the streamlined shell. +### `personal-primary-agent` + +Open an agent profile and choose **Set as my primary**. Inspect its crown on the +profile/roster and its position in the Agents sidebar, then open a new task or Chat. + ## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable company and supported adapter; hiring may require a formal approval. @@ -56,8 +62,23 @@ Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component eviden Manual: Inspect a manager and report, change the relationship through the offered controls, and reload both views. Exercise a rejected invalid relationship. Configure a built-in agent and verify it retains its required identity and restrictions. +### `personal-primary-agent` + +Automated: [primary-agent context](../../ui/src/context/PrimaryAgentProvider.test.tsx) +and [primary-agent routes](../../server/src/__tests__/primary-agent.test.ts) +cover selection, company/user scope, and request contracts. + +Manual: Select an available agent, reload, and inspect the retained personal choice. +Replace it and verify confirmation is required. Leave or terminate the selected +agent through permitted controls and inspect the unavailable/cleared state. +Switch company/user to check isolation. Open a task and Chat with a +valid draft or recent selection; verify those explicit choices take precedence +over the primary fallback. Opening Chat must not start execution. + ## Gotchas - A roster entry is not proof of an authenticated or invokable adapter. - Pending hires may live in approval/task surfaces rather than the active roster. - Skill and tool grants are separate: see [skills](./skills.md) and [app permissions](./app-permissions.md). +- A personal primary agent is a per-user/company preference. It does not change + reporting lines, stars, permissions, or another person's primary agent. diff --git a/maps/feature-map/companies.md b/maps/feature-map/companies.md index 0740c974556..b4fbbfed251 100644 --- a/maps/feature-map/companies.md +++ b/maps/feature-map/companies.md @@ -10,6 +10,8 @@ Agent access: [companion](../agent-access-map/companies.md). - `selection`: create/select a company and keep navigation in its scope. - `identity`: update company identity and configured settings. +- `decision-models`: configure and test the company's decision model, then inspect invocation history and recorded usage. +- `fast-responses`: configure and test the experimental fast-response service, then inspect acknowledgement history, failures, and recorded usage. - `archive`: archive and restore through the available controls without confusing archival with deletion. - `portability`: preview a company package and inspect imported agents, projects, skills, and conflicts. @@ -23,6 +25,12 @@ Use the company switcher or `/companies`, then enter company Settings. Use `/company/export` and `/company/import` where enabled, or the corresponding CLI company commands. +### `company-ai-services` + +Use Settings → Connections (`/company/settings/connections`) when fast responses +are enabled. Otherwise decision-model settings remain in Company Settings. +Open the Decisions and Fast responses tabs in Costs to inspect invocation history. + ## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use two disposable companies and a small package fixture. Record cloud-managed restrictions and visible settings gates. @@ -39,6 +47,22 @@ Automated: [portability routes](../../server/src/__tests__/company-portability-r Manual: Export a small company, inspect the package preview, and import into a disposable target. Verify counts, references, conflict choices, and agent configuration. Run one imported task after supplying its required local credentials. Confirm source company data is unchanged. +### `company-ai-services` + +Automated: [decision settings](../../ui/src/components/decision-models/DecisionModelSettings.test.tsx), +[decision history](../../ui/src/components/decision-models/DecisionHistory.test.tsx), +[fast-response settings](../../ui/src/components/fast-responses/FastResponseSettings.test.tsx), +[decision service](../../server/src/__tests__/decision-models.test.ts), and +[fast-response service](../../server/src/__tests__/fast-responses.test.ts) cover +component and service contracts with simulated providers. + +Manual: As an authorized connection manager, select a disposable shared AI +connection, save the provider/model and sponsorship options, reload, and run the +bounded Test action. Inspect success and failure receipts and their history in +Costs. Verify disabled or revoked connections remain clear and that a user +without connection-management permission cannot configure or test the service. +Use an owned provider fixture for failure states; record any actual provider usage. + ## Gotchas - Restore an archived company through `/companies` → its menu → **Unarchive**. @@ -48,3 +72,7 @@ Manual: Export a small company, inspect the package preview, and import into a d - Package presence does not prove a runnable imported company; credentials and environment bindings need separate verification. - Company deletion is a distinct gated operation; do not use it as the archive test. - Settings hidden in a managed deployment should be recorded as unavailable, not bypassed. +- Decision tests and fast responses have different request and execution contracts. + A fast-response receipt is an acknowledgement, not completed agent work. + History visibility and connection-management authority are separate; task links + in history also require access to the underlying task. diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index eb190f50ce3..b64c3cb40df 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -47,11 +47,11 @@ Company switching/settings, archival, package import/export. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | unreviewed / production / fixtures | 9 / 0 | Production settings view with simulated callbacks. Excluded exported fixture helpers and added the onConnectionChange spy required during rendering; full save/API state parity remains unreviewed. | -| [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting` | current / production / fixtures | 4 / 0 | Shared production DecisionHistoryTable receives fastResponse=true, matching FastResponseHistory presentation; API loading/error and feature-gate wrapper are outside this story. | +| [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting`, `companies/decision-models` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof. | +| [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting`, `companies/decision-models` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof. | +| [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting`, `companies/decision-models` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof. | +| [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting`, `companies/fast-responses` | unreviewed / production / fixtures | 9 / 0 | Production settings view with simulated callbacks. Excluded exported fixture helpers and added the onConnectionChange spy required during rendering; full save/API state parity remains unreviewed. Related fast-response examples show configuration/test receipts or invocation history with shipped subjects and simulated data. A receipt is an acknowledgement, not task completion; no paid provider or authorization proof. | +| [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) | `companies/identity`, `budgets-costs/cost-reporting`, `companies/fast-responses` | current / production / fixtures | 4 / 0 | Shared production DecisionHistoryTable receives fastResponse=true, matching FastResponseHistory presentation; API loading/error and feature-gate wrapper are outside this story. Related fast-response examples show configuration/test receipts or invocation history with shipped subjects and simulated data. A receipt is an acknowledgement, not task completion; no paid provider or authorization proof. | ## Tasks and conversations @@ -74,7 +74,7 @@ Creation, assignment, lists, properties, comments, and completion. | [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) | `tasks/properties` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) | `tasks/properties`, `tasks/organize` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create`, `workspaces/binding` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. | | [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | @@ -122,7 +122,7 @@ Persistent conversations, discovery, handoff, and gated board chat. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | | [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | unreviewed / production / fixtures | 9 / 1 | Wrapper mounts production Layout and AgentChat; conversation selection and sends use in-memory routes. Directory name is not prototype evidence. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | -| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | mixed / mixed / fixtures | 17 / 0 | Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) | `agent-chat/discovery`, `agent-chat/history`, `agent-chat/handoff` | mixed / mixed / fixtures | 17 / 0 | Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. ProjectCreated, ExistingProject, MultipleRepositories, ProjectWithoutRepository and ProjectCreationFailed show linked handoff results/navigation through shipped chat/task hosts. Tool execution and persistence are simulated. | | [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | current / production / fixtures | 10 / 1 | Support module re-exports production AgentConversationSidebar; search/selection use fixture callbacks. | | [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | historical / mixed / fixtures | 9 / 3 | Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | @@ -138,7 +138,7 @@ Question/plan responses, formal approvals, and app-tool review. | [Tasks and conversations/Questions and approvals/Composer/Question flow](../../ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx) | `questions-and-approvals/questions` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | | [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) | `questions-and-approvals/tool-review`, `connection-setup/task-request` | unreviewed / production / fixtures | 25 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | | [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) | `questions-and-approvals/plan-review`, `task-coordination/delegation` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 53 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience`, `questions-and-approvals/plan-review`, `questions-and-approvals/continuation` | unreviewed / production / fixtures | 53 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Plan approval Pending, Confirmed, ExpiredByTargetChange and ResumeFailed states use shipped interaction cards. The continuation association covers accepted-but-resume-failed presentation only; no successful resumption, retry or duplicate-suppression proof. | | [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 12 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | current / production / fixtures | 15 / 5 | Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | | [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `tasks/discussion` | unreviewed / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | @@ -198,7 +198,7 @@ Agent identity, instructions, reporting lines, lifecycle, and built-ins. | --- | --- | --- | --- | --- | | [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) | `agents/instructions` | unreviewed / production / fixtures | 9 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) | `agents/lifecycle`, `agents/instructions`, `execution-environments/assignment` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. ForcedKubernetesExecution and ForcedKubernetesMissingEnvironment mount AgentConfigForm with a forced execution target or missing-target warning. Read-only assignment presentation; no saved target or remote execution proof. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 19 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) | `agents/lifecycle`, `agents/instructions` | mixed / mixed / fixtures | 15 / 0 | Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. | @@ -206,14 +206,14 @@ Agent identity, instructions, reporting lines, lifecycle, and built-ins. | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 20 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) | `agents/hire` | historical / prototype / fixtures | 37 / 1 | Retained local wizard predates production ui/src/components/new-agent flow; shared fields do not make the wizard current. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/organization`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 15 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Runs, harnesses, and model accounts @@ -253,12 +253,12 @@ Discovery, sources, authoring, revisions, tests, and agent policies. | [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/skills/agents-using-skill.stories.tsx) | `skills/policy` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy](../../ui/storybook/stories/skills/fork-skill-flow.stories.tsx) | `skills/authoring` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/skills/frontmatter-panel.stories.tsx) | `skills/authoring` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 14 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) | `skills/versions`, `skills/discovery`, `skills/policy` | unreviewed / production / fixtures | 14 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. AssignToAgent and AssignedSkill demonstrate library/detail assignment with local policy fixtures. No runtime enforcement or backend persistence proof. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 21 / 8 | Production Sources page with fixture APIs. Disconnect and legacy removal stories show a required keep-or-delete choice; the real copy, delete, and reimport flows were also tested locally. Other routes remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) | `skills/versions`, `skills/discovery` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) | `skills/policy` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) | `skills/discovery` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) | `skills/discovery`, `skills/versions`, `skills/policy` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related examples cover fixture revisions and agent attachment selection. Save and attachment submission callbacks are no-ops; durable authoring, grants and enforcement are not established. | | [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid](../../ui/storybook/stories/skills/skills-store-discovery.stories.tsx) | `skills/discovery` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Team packages and installation @@ -269,7 +269,7 @@ Catalog preview/install via CLI/API; catalog UI availability called out. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) | `teams/preview`, `teams/discovery` | unreviewed / production / fixtures | 12 / 0 | Renders shipped TeamCatalog source, but feature map documents current CLI/API access and no top-level catalog route. | +| [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) | `teams/preview`, `teams/discovery`, `teams/install`, `teams/configuration` | unreviewed / production / fixtures | 12 / 0 | Renders shipped TeamCatalog source, but feature map documents current CLI/API access and no top-level catalog route. InstallApplyProgress, InstallSuccess and InstalledStates show simulated installation results; InstallPreview edits adapter choices in local state. No routed catalog, real package installation, account setup or working-agent proof. | ## Projects and execution @@ -355,20 +355,20 @@ Catalog, MCP links, task requests, authentication, and setup resumption. | [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) | `connection-setup/catalog` | proposed / prototype / fixtures | 12 / 1 | Local catalog composition uses shipped cards and filters; retains design exploration purpose, not acceptance of the Apps route. | | [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) | `connection-setup/catalog` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure`, `connection-setup/draft-recovery`, `connection-setup/identity-and-access`, `app-permissions/action-policy`, `app-permissions/test-action` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review. | | [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) | `connection-setup/credentials` | current / production / fixtures | 9 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 27 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure`, `connection-setup/draft-recovery`, `connection-setup/identity-and-access`, `app-permissions/action-policy`, `app-permissions/test-action` | unreviewed / production / fixtures | 27 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review. | | [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 20 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 9 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure`, `connection-setup/draft-recovery`, `connection-setup/identity-and-access`, `app-permissions/action-policy`, `app-permissions/test-action` | unreviewed / production / fixtures | 26 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review. | | [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) | `connection-setup/credentials`, `routines/triggers` | unreviewed / production / fixtures | 27 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) | `connection-setup/task-request` | unreviewed / production / fixtures | 78 / 23 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | | [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) | `connection-setup/handoff` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) | `connection-setup/identity-and-access` | unreviewed / production / fixtures | 10 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) | `connection-setup/credentials`, `connection-setup/failure`, `connection-setup/draft-recovery`, `connection-setup/identity-and-access`, `app-permissions/action-policy`, `app-permissions/test-action` | unreviewed / production / fixtures | 21 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review. | ### App access and action permissions @@ -395,11 +395,11 @@ Provider-specific identity, threads, files, delivery, and endpoint upkeep. | [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here. | | [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | | [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | +| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle`, `chat-channels/identity`, `chat-channels/conversation` | unreviewed / production / fixtures | 15 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. Access, InvitePeople, ConnectAndTest and Conversations render the shipped endpoint detail with simulated identity linking and conversation data. External delivery and identity authorization are not live. | | [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | | [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | | [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | -| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. | +| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation`, `chat-channels/providers`, `chat-channels/identity`, `chat-channels/lifecycle`, `connection-setup/channel-setup`, `connection-setup/failure` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. Catalog, ChooseAgent, Credentials, Access, Reconnect and ProviderOutageRecovery render shipped setup/detail states with simulated provider data. No live iMessage connection, delivery or recovery claim. | | [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | | [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | | [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | @@ -420,7 +420,7 @@ Tool exposure, client configuration, tokens, profiles, and activity. | [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 25 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) | `gateways-profiles/client-connect` | current / production / fixtures | 7 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 14 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) | `gateways-profiles/client-connect` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) | `gateways-profiles/client-connect`, `instance-operations/configuration` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. ToggleOnAndOff, ManagedByCloud and SaveFailedRollsBack mount production experimental settings with simulated saves. Only the assistant-connection flag is represented, not every instance setting. | ### Secrets and proposals @@ -455,10 +455,10 @@ Definitions, variables, scheduled/webhook/manual runs, and history. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) | `routines/definition`, `routines/management`, `activity/routine-audit` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Activity mounts the shipped ActivitySection with fixture trigger/run/configuration events. This does not establish live routine execution or audit persistence. | | [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) | `routines/definition`, `secrets/runtime-delivery` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 21 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) | `routines/triggers` | unreviewed / production / fixtures | 16 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) | `routines/triggers`, `routines/history`, `activity/routine-audit` | unreviewed / production / fixtures | 16 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runs and Activity mount RoutineDetail with fixture execution tasks/events and play assertions. Scheduler execution, delivery and audit persistence are not live. | | [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) | `routines/definition`, `routines/management` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Pipelines, review queues, and learnings @@ -498,7 +498,7 @@ Spend reports, scoped limits, incidents, hard stops, and resumption. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets`, `budgets-costs/incidents` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. FullMatrix renders BudgetIncidentCard in warning, resolved, pending-approval and escalated hard-stop states. Resolution handlers are no-ops; budget relief and resumption remain unverified. | | [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) | `budgets-costs/cost-reporting`, `budgets-costs/budgets` | unreviewed / production / fixtures | 7 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Dashboards and audit trails @@ -565,10 +565,10 @@ Shared components and contributor guidance outside the product feature index. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Foundations/Components/Forms & Editors](../../ui/storybook/stories/foundations/forms-editors.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Primitive Matrix](../../ui/storybook/stories/foundations/foundations.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Primitive Coverage](../../ui/storybook/stories/foundations/primitives-coverage.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Foundations/Status Language](../../ui/storybook/stories/foundations/status-language.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Foundations/Components/Forms & Editors](../../ui/storybook/stories/foundations/forms-editors.stories.tsx) | `developer-labs/design` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim. | +| [Foundations/Primitive Matrix](../../ui/storybook/stories/foundations/foundations.stories.tsx) | `developer-labs/design` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim. | +| [Foundations/Primitive Coverage](../../ui/storybook/stories/foundations/primitives-coverage.stories.tsx) | `developer-labs/design` | unreviewed / production / fixtures | 11 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim. | +| [Foundations/Status Language](../../ui/storybook/stories/foundations/status-language.stories.tsx) | `developer-labs/design` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim. | | [Overview/Storybook Guide](../../ui/storybook/stories/overview/overview.stories.tsx) | Shared foundations or contributor guide; no single business capability owns this suite. | unreviewed / guide / static | 1 / 0 | Matches the upstream feature index and order, including features with no cataloged examples. Related suite links and classifications do not imply execution coverage. | ## Capability lookup @@ -587,6 +587,8 @@ References are navigation links, not proof of parity. An absent link means no ca - `access/cli-auth`: No cataloged story. - `companies/selection`: No cataloged story. - `companies/identity`: [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) +- `companies/decision-models`: [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed) +- `companies/fast-responses`: [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) - `companies/archive`: No cataloged story. - `companies/portability`: No cataloged story. - `tasks/create`: [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) (current), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) @@ -604,16 +606,16 @@ References are navigation links, not proof of parity. An absent link means no ca - `inbox-search/search`: [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) (unreviewed) - `agent-chat/discovery`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) - `agent-chat/history`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical) -- `agent-chat/handoff`: No cataloged story. +- `agent-chat/handoff`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed) - `agent-chat/conference-room`: No cataloged story. - `questions-and-approvals/questions`: [Tasks and conversations/Questions and approvals/Composer/Question flow](../../ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) (current) - `questions-and-approvals/dismiss-and-reopen`: [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) (unreviewed) - `questions-and-approvals/confirmations`: [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) (current) -- `questions-and-approvals/plan-review`: [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) (unreviewed) +- `questions-and-approvals/plan-review`: [Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions](../../ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed) - `questions-and-approvals/audience`: [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) (current) - `questions-and-approvals/governance`: [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed) - `questions-and-approvals/tool-review`: [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) (unreviewed) -- `questions-and-approvals/continuation`: No cataloged story. +- `questions-and-approvals/continuation`: [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) (unreviewed) - `steering/queue`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) - `steering/edit-order-discard`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) - `steering/steer`: [Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages](../../ui/storybook/stories/steering/composer-queued-messages.stories.tsx) (current) @@ -628,7 +630,8 @@ References are navigation links, not proof of parity. An absent link means no ca - `documents-artifacts/library`: [Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) (unreviewed) - `agents/hire`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) (current), [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) (historical) - `agents/instructions`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files](../../ui/storybook/stories/agents/agent-files.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed) -- `agents/organization`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `agents/organization`: [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages](../../ui/storybook/stories/agents/agent-persona-pages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas](../../ui/storybook/stories/agents/agent-personas.stories.tsx) (unreviewed) +- `agents/primary-agent`: [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) - `agents/lifecycle`: [Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail](../../ui/storybook/stories/agents/agent-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh](../../ui/storybook/stories/agents/agent-settings-redesign.stories.tsx) (mixed) - `agents/built-in`: [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents](../../ui/storybook/stories/agents/built-in-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) (unreviewed) - `runs-adapters/availability`: [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) (mixed) @@ -637,13 +640,13 @@ References are navigation links, not proof of parity. An absent link means no ca - `runs-adapters/session`: [Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals](../../ui/storybook/stories/runs-adapters/session-goals.stories.tsx) (unreviewed) - `skills/discovery`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid](../../ui/storybook/stories/skills/skills-store-discovery.stories.tsx) (unreviewed) - `skills/authoring`: [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy](../../ui/storybook/stories/skills/fork-skill-flow.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/skills/frontmatter-panel.stories.tsx) (unreviewed) -- `skills/versions`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed) -- `skills/policy`: [Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow](../../ui/storybook/stories/skills/agent-skill-row.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states](../../ui/storybook/stories/skills/agent-skills-edge.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/skills/agents-using-skill.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) (unreviewed) +- `skills/versions`: [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills](../../ui/storybook/stories/skills/github-synced-skills.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages](../../ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress](../../ui/storybook/stories/skills/skill-import-progress.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) (unreviewed) +- `skills/policy`: [Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow](../../ui/storybook/stories/skills/agent-skill-row.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states](../../ui/storybook/stories/skills/agent-skills-edge.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/skills/agents-using-skill.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey](../../ui/storybook/stories/skills/github-skills-journey.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces](../../ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx) (unreviewed), [Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail](../../ui/storybook/stories/skills/skills-store-detail.stories.tsx) (unreviewed) - `skills/testing`: No cataloged story. - `teams/discovery`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) - `teams/preview`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) -- `teams/install`: No cataloged story. -- `teams/configuration`: No cataloged story. +- `teams/install`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) +- `teams/configuration`: [Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog](../../ui/storybook/stories/teams/team-catalog.stories.tsx) (unreviewed) - `projects/lifecycle`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) (unreviewed), [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) - `projects/task-context`: [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) (unreviewed) - `projects/repositories`: [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) (proposed), [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) (proposed) @@ -651,7 +654,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `goals/hierarchy`: [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) (unreviewed), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed) - `goals/alignment`: No cataloged story. - `goals/navigation`: No cataloged story. -- `workspaces/binding`: [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) (unreviewed) +- `workspaces/binding`: [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) (current), [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) (unreviewed) - `workspaces/provisioning`: [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) (unreviewed) - `workspaces/services`: [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) (unreviewed) - `workspaces/files-git`: [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) (unreviewed), [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) (unreviewed) @@ -659,7 +662,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `execution-environments/configuration`: No cataloged story. - `execution-environments/capabilities`: No cataloged story. - `execution-environments/probe`: No cataloged story. -- `execution-environments/assignment`: No cataloged story. +- `execution-environments/assignment`: [Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management](../../ui/storybook/stories/agents/agent-management.stories.tsx) (unreviewed) - `execution-environments/images`: No cataloged story. - `recovery/diagnosis`: [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) (unreviewed), [Projects and execution/Recovery/Task chat/Recovery notice](../../ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Tasks/Execution recovery](../../ui/storybook/stories/recovery/execution-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) (unreviewed) - `recovery/bounded-continuation`: [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) (unreviewed) @@ -670,23 +673,23 @@ References are navigation links, not proof of parity. An absent link means no ca - `recovery/user-continuation`: No cataloged story. - `recovery/reconnect`: [Projects and execution/Recovery/App/Connection recovery](../../ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx) (unreviewed) - `connection-setup/catalog`: [Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry](../../ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Catalog source selection](../../ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx) (proposed), [Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) (unreviewed) -- `connection-setup/identity-and-access`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) (unreviewed) +- `connection-setup/identity-and-access`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice](../../ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx) (unreviewed) - `connection-setup/credentials`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx) (current), [Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-setup/connection-instructions.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Memory](../../ui/storybook/stories/connection-setup/memory-connectors.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) - `connection-setup/handoff`: [Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx) (unreviewed) -- `connection-setup/draft-recovery`: No cataloged story. +- `connection-setup/draft-recovery`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed) - `connection-setup/task-request`: [Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews](../../ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Connections/In-task connections](../../ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx) (unreviewed) -- `connection-setup/channel-setup`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) -- `connection-setup/failure`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) +- `connection-setup/channel-setup`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) +- `connection-setup/failure`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Enterpret connector](../../ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx) (unreviewed) - `app-permissions/agent-access`: [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) -- `app-permissions/action-policy`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) +- `app-permissions/action-policy`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed](../../ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx) (unreviewed) - `app-permissions/catalog-change`: No cataloged story. -- `app-permissions/test-action`: [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed) +- `app-permissions/test-action`: [Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade](../../ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Composio](../../ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Executor](../../ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier](../../ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard](../../ui/storybook/stories/app-permissions/action-card.stories.tsx) (unreviewed), [Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test](../../ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx) (unreviewed) - `app-permissions/account-maintenance`: [Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts](../../ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx) (current) -- `chat-channels/providers`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) -- `chat-channels/identity`: [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) (current), [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) (current) -- `chat-channels/conversation`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) (unreviewed) +- `chat-channels/providers`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) (unreviewed) +- `chat-channels/identity`: [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) (current), [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) (current) +- `chat-channels/conversation`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) (historical), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) (unreviewed) - `chat-channels/attachments`: No cataloged story. -- `chat-channels/lifecycle`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed) +- `chat-channels/lifecycle`: [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) (unreviewed), [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) (unreviewed) - `gateways-profiles/gateway`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx) (unreviewed) - `gateways-profiles/profiles`: [Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles](../../ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx) (unreviewed) - `gateways-profiles/client-connect`: [Apps, channels, and extensions/Connection setup/Components/Agent setup prompt](../../ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx) (unreviewed), [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections](../../ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent](../../ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization](../../ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx) (current), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here](../../ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx) (unreviewed), [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) (unreviewed) @@ -704,7 +707,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `routines/definition`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab](../../ui/storybook/stories/routines/routine-secrets.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) - `routines/triggers`: [Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) (unreviewed) - `routines/run-now`: No cataloged story. -- `routines/history`: No cataloged story. +- `routines/history`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) (unreviewed) - `routines/management`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines/routines-list-groups.stories.tsx) (unreviewed) - `pipelines/stages`: No cataloged story. - `pipelines/items`: No cataloged story. @@ -720,12 +723,12 @@ References are navigation links, not proof of parity. An absent link means no ca - `status-cards/archive`: No cataloged story. - `budgets-costs/cost-reporting`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/01 Settings](../../ui/storybook/stories/companies/decision-models/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/02 History](../../ui/storybook/stories/companies/decision-models/02-history.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Decision models/03 Journey](../../ui/storybook/stories/companies/decision-models/03-journey.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/01 Settings](../../ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx) (unreviewed), [Getting started and access/Companies and portability/Fast responses/02 History](../../ui/storybook/stories/companies/fast-responses/02-history.stories.tsx) (current) - `budgets-costs/budgets`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed), [Oversight and operation/Costs and budgets/Costs/Subscriptions](../../ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx) (unreviewed) -- `budgets-costs/incidents`: No cataloged story. +- `budgets-costs/incidents`: [Oversight and operation/Costs and budgets/Product/Budget & Finance](../../ui/storybook/stories/budgets-costs/budget-finance.stories.tsx) (unreviewed) - `budgets-costs/resolution`: No cataloged story. - `activity/overview`: [Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc](../../ui/storybook/stories/activity/data-viz-misc.stories.tsx) (unreviewed) - `activity/activity`: [Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)](../../ui/storybook/stories/activity/activity-feed.stories.tsx) (unreviewed), [Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) (unreviewed) - `activity/runs`: No cataloged story. -- `activity/routine-audit`: No cataloged story. +- `activity/routine-audit`: [Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)](../../ui/storybook/stories/routines/routine-detail-c.stories.tsx) (unreviewed), [Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks](../../ui/storybook/stories/routines/routine-webhooks.stories.tsx) (unreviewed) - `activity/timeline`: [Oversight and operation/Dashboards and audit trails/Pages/Work Timeline](../../ui/storybook/stories/activity/work-timeline.stories.tsx) (unreviewed) - `navigation-preferences/navigation`: [Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx) (proposed), [Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout](../../ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx) (unreviewed) - `navigation-preferences/preferences`: No cataloged story. @@ -734,7 +737,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `navigation-preferences/fallbacks`: No cataloged story. - `instance-operations/install-update`: No cataloged story. - `instance-operations/server-service`: [Oversight and operation/Instance operations/Product/Dev Ops Surfaces](../../ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx) (unreviewed) -- `instance-operations/configuration`: No cataloged story. +- `instance-operations/configuration`: [Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting](../../ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx) (unreviewed) - `instance-operations/diagnostics`: No cataloged story. - `instance-operations/backup`: No cataloged story. - `cli-operations/context`: No cataloged story. @@ -742,7 +745,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `cli-operations/outputs`: No cataloged story. - `cli-operations/run-controls`: No cataloged story. - `cli-operations/worktree-instances`: No cataloged story. -- `developer-labs/design`: No cataloged story. +- `developer-labs/design`: [Foundations/Components/Forms & Editors](../../ui/storybook/stories/foundations/forms-editors.stories.tsx) (unreviewed), [Foundations/Primitive Matrix](../../ui/storybook/stories/foundations/foundations.stories.tsx) (unreviewed), [Foundations/Primitive Coverage](../../ui/storybook/stories/foundations/primitives-coverage.stories.tsx) (unreviewed), [Foundations/Status Language](../../ui/storybook/stories/foundations/status-language.stories.tsx) (unreviewed) - `developer-labs/interaction-labs`: [Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages](../../ui/storybook/stories/developer-labs/ux-labs.stories.tsx) (unreviewed) - `developer-labs/performance`: No cataloged story. - `developer-labs/route-gates`: No cataloged story. diff --git a/maps/storybook-map/README.md b/maps/storybook-map/README.md index 86ad034b217..44098767672 100644 --- a/maps/storybook-map/README.md +++ b/maps/storybook-map/README.md @@ -22,6 +22,9 @@ maintainer; this catalog does not redefine product behavior or permissions. export filtering, story counts and IDs come from source, not filename guesses. All examples are retained, including historical suites. A file's first capability is its primary home; remaining capabilities are lookup references, not copies. +Shared foundations may declare `home: "foundations"` to retain their folder and +sidebar while linking related capabilities. This override requires an exception +reason; it is not available for arbitrary feature homes. Foundations/guides and proposals without a mapped capability need an exception reason. An absent story is not automatically a feature gap. diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index 90aa33a7744..b1ab397fee0 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -332,6 +332,90 @@ pages still cover 35 areas and 171 capabilities. Catalog/generator tests (16), catalog validation, map checks and token gates pass. This is source reconciliation, not a new visual or live production acceptance claim. +## 2026-10-13 capability association reconciliation + +Source baseline: `60bc0d5fee9a6e48db08fc016521fee3d9f57d67`. +Reviewed the complete authored inventory and missing-capability lookup, then +inspected the candidate story exports, wrappers, fixture behavior and shipped +subjects. This pass corrects navigation associations; it is not a full parity, +interaction or visual review of all 223 suites. + +Added 55 references across 34 suites. Primary-agent suites now use their own +capability instead of organization/reporting. Shared foundations retain their +physical and sidebar home while cross-listing `developer-labs/design` through +an explicit, reasoned Foundations override. No story was moved, renamed or removed. +The parser comparison preserves all 223 files and 2,210 authored story IDs. + +The map had three omissions revealed by stories and confirmed in shipped source: + +- `agents/primary-agent`: personal choice, confirmation and fallback presentation, + distinct from organization/reporting. The production provider and self-service + API have user/company scope; the story presentation provider simulates changes. +- `companies/decision-models`: connection/model/sponsorship configuration, + bounded testing and history. Settings require board connection management; + history and task-link visibility have separate read policies. +- `companies/fast-responses`: experimental acknowledgement configuration, + tests and history. A receipt does not establish completed agent work. + +Added their production recipes and same-ID agent-access companions. Existing +capability IDs remain. Both maps now contain 35 families and 174 capabilities. +New access evidence is source-only; no live provider or agent-access claim. + +Confirmed association evidence and limits: + +| Existing suites | Added lookup | Demonstrated scope | +| --- | --- | --- | +| Four shared foundation suites | Developer design | Production controls and contributor visual states; no application journey claim. | +| Agent chat | Handoff | Project-created, existing-project, repository and failure results; tool results/navigation simulated. | +| Agent management | Environment assignment | Forced Kubernetes target and missing-target warning; no saved assignment or execution. | +| Team catalog | Installation/configuration | Progress/success results and adapter choices; no real install, account setup or routed catalog. | +| Issue-thread interactions | Plan review/continuation | Approval, stale-revision and accepted-but-resume-failed states; no successful resume or duplicate-suppression proof. | +| New-task composer | Workspace binding | Worktree selection and submitted request values; no execution admission. | +| GitHub skills journey/detail | Policy/versions | Assignment selection and fixture revisions; no runtime enforcement or durable no-op callbacks. | +| Routine webhook/detail | History/audit | Production Runs/Activity with fixture tasks/events; no scheduler or audit-persistence proof. | +| Budget finance | Incidents | Warning/resolved/pending/escalated cards; resolution callbacks are no-ops. | +| Public-MCP setting | Instance configuration | Experimental flag toggles, cloud restrictions and failed-save rollback; no other instance settings. | +| Arcade/Composio/Executor/Zapier setup | Draft recovery, identity, action policy/testing | Production setup/permissions with in-memory drafts; reload loses the draft. | +| Chat management/iMessage Photon | Provider, identity, conversation, lifecycle and setup/failure | Shipped hosts with simulated provider/identity/delivery data. | +| Primary-agent suites and decision/fast-response suites | New map capabilities | Production UI subjects plus simulated preference/provider/result boundaries. | + +Eleven previously empty capability lookups now have related examples. With the +three newly mapped capabilities also linked, the absent-example count changes +from 70 of 171 to 59 of 174. This measures catalog links, not complete behavior +coverage. Review classifications were not promoted by this association pass. + +Retained gaps and rejected lookalikes: + +- Document preview/annotations do not establish version editing/restoration. +- Seeded drafts do not establish task switching, reload or uncertain submission. +- Approval resume failure does not establish durable approval delivery. +- NewToolsAllowed does not establish quarantined-action review. +- Assistant OAuth revocation does not establish gateway token scope/revocation. +- Budget resolution controls with no-op handlers do not establish relief/resumption. +- A closed browser session does not establish workspace closure/reopening. +- Team fixtures and Skill Studio route presence do not establish working installs + or Studio test execution/results. + +The gateway-token fixture needs separate review before advertising coverage: +its token data may omit fields read by the production token panel. The missing +lookup remains explicit. Rendering/interaction checks below cover selected +association navigation, not all source-audited suites or production acceptance. + +Executed checks: 27 catalog/generator/map tests, catalog and map validation, +token gates, skill validation, repository typecheck/build and Storybook build +passed. The built index has 2,246 entries and loses none of the original 2,211. +In the browser, the developer Design lookup opens the preserved primitive URL; +company-service sections link the existing settings/history/journey suites. +The decision SetupToHistory journey passed its 12 interaction steps and showed +the simulated test receipt in history. Primary-agent ReplacePrimary passed its +four interaction steps and displayed the replacement confirmation. No console +errors were observed in those selected checks. No visual-baseline comparison. + +The broad local repository test run was interrupted before completion to merge +new master changes; no full local suite pass is claimed. Fresh CI on the updated +branch supplies repository-wide verification. The merge retains master’s native +REST transport updates alongside the three new capability records; new live +provider and agent-credential verification remains not run. ## Slack DM mention exemption — 2026-10-13 Scoped source review: Slack DMs no longer require an at-mention, while channel diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 6c7ca47655c..5e947c7b55c 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -10,7 +10,7 @@ "ui/storybook/generated/feature-index.json", "ui/storybook/support/feature-map/FeatureOverview.tsx" ], - "fingerprint": "8454a7897e390c824f7bd2120009a8454ae60eeae5f4d562f556f8839b716daf" + "fingerprint": "451bee6238c89f00e77fa9fdc5f86c621afffdf9809c4a9ac9cbf1f3f4f60b78" }, "entries": [ { @@ -37,14 +37,18 @@ "file": "ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx", "capabilities": [ "connection-setup/credentials", - "connection-setup/failure" + "connection-setup/failure", + "connection-setup/draft-recovery", + "connection-setup/identity-and-access", + "app-permissions/action-policy", + "app-permissions/test-action" ], "purpose": "Apps/Connections/Arcade", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "ui/src/components/JsonSchemaForm.tsx", @@ -53,9 +57,10 @@ "ui/src/features/connections/remote-mcp/types.ts", "ui/storybook/fixtures/remoteMcpConnections.ts", "ui/storybook/prototypes/RemoteMcpConnectionReview.tsx", - "ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx" + "ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx", + "maps/feature-map/app-permissions.md" ], - "fingerprint": "cb8802f6362599a2aa164520a07a15ae3d41d4ff90173cfcbb9f8b3cefae0dc4", + "fingerprint": "e613c96bf5fdfca19508ffe5d74ed027d757a38110029a59ca66d471f4e829f9", "stableId": "apps-connections-arcade", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade" }, @@ -63,14 +68,18 @@ "file": "ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx", "capabilities": [ "connection-setup/credentials", - "connection-setup/failure" + "connection-setup/failure", + "connection-setup/draft-recovery", + "connection-setup/identity-and-access", + "app-permissions/action-policy", + "app-permissions/test-action" ], "purpose": "Apps/Connections/Composio", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "ui/src/components/JsonSchemaForm.tsx", @@ -79,9 +88,10 @@ "ui/src/features/connections/remote-mcp/types.ts", "ui/storybook/fixtures/remoteMcpConnections.ts", "ui/storybook/prototypes/RemoteMcpConnectionReview.tsx", - "ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx" + "ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx", + "maps/feature-map/app-permissions.md" ], - "fingerprint": "f7623b707dd4982dc583621f2b7b478544b2873653eff36cb7e62e2d2d1bbce3", + "fingerprint": "b5f0945453ee1e8b040bb77577723ea0a0caf13a91d2b367835d331c0d9af9cb", "stableId": "apps-connections-composio", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Composio" }, @@ -116,14 +126,18 @@ "file": "ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx", "capabilities": [ "connection-setup/credentials", - "connection-setup/failure" + "connection-setup/failure", + "connection-setup/draft-recovery", + "connection-setup/identity-and-access", + "app-permissions/action-policy", + "app-permissions/test-action" ], "purpose": "Apps/Connections/Executor", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "ui/src/components/JsonSchemaForm.tsx", @@ -132,9 +146,10 @@ "ui/src/features/connections/remote-mcp/types.ts", "ui/storybook/fixtures/remoteMcpConnections.ts", "ui/storybook/prototypes/RemoteMcpConnectionReview.tsx", - "ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx" + "ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx", + "maps/feature-map/app-permissions.md" ], - "fingerprint": "4617104a6f8867e3eaa996a643bcefa03b158fe9be0c43c0d77f578da42a0cf2", + "fingerprint": "402206b2ff037110ee05a2d00e80f7ee2f9346b211829030eddf4b95541a2e30", "stableId": "apps-connections-executor", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Executor" }, @@ -142,14 +157,18 @@ "file": "ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx", "capabilities": [ "connection-setup/credentials", - "connection-setup/failure" + "connection-setup/failure", + "connection-setup/draft-recovery", + "connection-setup/identity-and-access", + "app-permissions/action-policy", + "app-permissions/test-action" ], "purpose": "Apps/Connections/Zapier", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. SavedDraftResume, Reconnect, SelectedAgents and ManageToolPermissions demonstrate production setup/permissions/test surfaces with an in-memory wrapper. Reload loses the draft; these examples do not verify persisted recovery. NewToolsAllowed does not demonstrate quarantined-action review.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/connection-setup.md", "ui/src/components/JsonSchemaForm.tsx", @@ -158,9 +177,10 @@ "ui/src/features/connections/remote-mcp/types.ts", "ui/storybook/fixtures/remoteMcpConnections.ts", "ui/storybook/prototypes/RemoteMcpConnectionReview.tsx", - "ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx" + "ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx", + "maps/feature-map/app-permissions.md" ], - "fingerprint": "6ab45d2c88183f3f63efaa1a1a6ad9eb05436f94fc4e1ba67fcfb97345ee8ffb", + "fingerprint": "c0593ea1b08ecf7ac978943e1cba6b04289234676caf498aeccd5a45be7fda16", "stableId": "apps-connections-zapier", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier" }, @@ -316,13 +336,14 @@ "file": "ui/storybook/stories/agent-chat/agent-chat.stories.tsx", "capabilities": [ "agent-chat/discovery", - "agent-chat/history" + "agent-chat/history", + "agent-chat/handoff" ], "purpose": "Design explorations/Agent chat", "status": "mixed", "implementation": "mixed", "boundary": "fixtures", - "notes": "Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "notes": "Production conversation/task routes with a local ChatEntryLanding. Default-agent behavior and fallback API responses remain outside current-product parity. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. ProjectCreated, ExistingProject, MultipleRepositories, ProjectWithoutRepository and ProjectCreationFailed show linked handoff results/navigation through shipped chat/task hosts. Tool execution and persistence are simulated.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -366,7 +387,7 @@ "ui/storybook/fixtures/onboardingEnvironment.ts", "ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx" ], - "fingerprint": "7b2571f03ffa3a559a9c228f78c87302d346183599b667c1102d131b4f33593a", + "fingerprint": "49e00454cf0fed46e03b30feafb73704723581718a58ec6a250a40dbc480261a", "stableId": "onboarding-saved-connections", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections" }, @@ -412,7 +433,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/agents/agent-detail.stories.tsx" ], - "fingerprint": "11057146b400c257fbdc3ec5c20da9b55eacc6735121632f2fa55e72af29e777", + "fingerprint": "b3f02fa740f2397d82ee459dd686ff8fb34ec383f3192ca07b895c9ce6d1508d", "stableId": "pages-agent-detail", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail" }, @@ -433,7 +454,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/agents/agent-files.stories.tsx" ], - "fingerprint": "d7976922e4431b6432f966c2f120b9a57f66437bfe519694007d76a790318043", + "fingerprint": "6324b721acf10b3ce10aa174a789ed09a99f821d4ee651d770026a7e23712463", "stableId": "agents-persistent-files", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files" }, @@ -441,14 +462,15 @@ "file": "ui/storybook/stories/agents/agent-management.stories.tsx", "capabilities": [ "agents/lifecycle", - "agents/instructions" + "agents/instructions", + "execution-environments/assignment" ], "purpose": "Product/Agent Management", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. ForcedKubernetesExecution and ForcedKubernetesMissingEnvironment mount AgentConfigForm with a forced execution target or missing-target warning. Read-only assignment presentation; no saved target or remote execution proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agents.md", "ui/src/components/ActiveAgentsPanel.tsx", @@ -459,9 +481,10 @@ "ui/src/components/agent-config-defaults.ts", "ui/src/components/agent-config-primitives.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/agents/agent-management.stories.tsx" + "ui/storybook/stories/agents/agent-management.stories.tsx", + "maps/feature-map/execution-environments.md" ], - "fingerprint": "f6dbb548bf03324350a3658ce6e724d88d04535b1d7e4a647492f21ff64cc332", + "fingerprint": "825f3c53828c756a52b4d710ae84fc5bd9b36d00affd6f4c0ffaf8ae9562e4c1", "stableId": "product-agent-management", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management" }, @@ -490,7 +513,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/agents/agent-persona-pages.stories.tsx" ], - "fingerprint": "885a9c5e6afc2f064ef699ec80153b62e4e4bdaa62da4c0724055e910ab3284d", + "fingerprint": "35454c02bbae403677d0d79644dc92da6c96172265d0f7581508e7d111463e0f", "stableId": "agents-personas-full-pages", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages" }, @@ -515,7 +538,7 @@ "ui/src/components/ui/button.tsx", "ui/storybook/stories/agents/agent-personas.stories.tsx" ], - "fingerprint": "0b6f1811a05f57c3f87f45072d762f5c7fb613d82ddf1967553f0f0f78e9a4fe", + "fingerprint": "43f4316011eeec7f2c1b1aa5e73f6194c49ba0fb1eb9484375a3929792f4cbf1", "stableId": "agents-personas", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas" }, @@ -551,7 +574,7 @@ "ui/storybook/prototypes/new-agent-fixtures.ts", "ui/storybook/stories/agents/agent-settings-redesign.stories.tsx" ], - "fingerprint": "0d438c68efa2c8b29d359c95944bc7aa55bd90808ea4f6229f144ffb4a16ffe4", + "fingerprint": "f3f3f3b232dd5e600485e2701153e069aa545eba232264c69c6eb64e21fd0f24", "stableId": "agents-configuration-refresh", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh" }, @@ -1301,14 +1324,15 @@ "file": "ui/storybook/stories/budgets-costs/budget-finance.stories.tsx", "capabilities": [ "budgets-costs/cost-reporting", - "budgets-costs/budgets" + "budgets-costs/budgets", + "budgets-costs/incidents" ], "purpose": "Product/Budget & Finance", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. FullMatrix renders BudgetIncidentCard in warning, resolved, pending-approval and escalated hard-stop states. Resolution handlers are no-ops; budget relief and resumption remain unverified.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "ui/src/components/AccountingModelCard.tsx", @@ -1349,7 +1373,7 @@ "ui/src/components/StatusBadge.tsx", "ui/storybook/stories/agents/built-in-agents.stories.tsx" ], - "fingerprint": "10f7d4cc67fac768afe96fc635480cf9cb91cfe1f18f11ffdfd2273de8eac8a3", + "fingerprint": "33d0aceb2ce1d9841cb03ea0b3ca84e4daadd367a424ef57d104e40733d948eb", "stableId": "product-built-in-agents", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents" }, @@ -1440,13 +1464,15 @@ { "file": "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx", "capabilities": [ - "chat-channels/lifecycle" + "chat-channels/lifecycle", + "chat-channels/identity", + "chat-channels/conversation" ], "purpose": "Connections/Chat management", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. Access, InvitePeople, ConnectAndTest and Conversations render the shipped endpoint detail with simulated identity linking and conversation data. External delivery and identity authorization are not live.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", @@ -1655,7 +1681,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/connection-setup/connection-instructions.stories.tsx" ], - "fingerprint": "3efdccf3ef32cfd6593a9dd5490337f53be859f7b2f0ecc3d8e3e843be74c7f7", + "fingerprint": "8f34f47beca85eea7d4cd012422422a37cd2d47ce7068bce9103dfc9d934e9dd", "stableId": "design-explorations-connections-agent-instructions", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions" }, @@ -1917,14 +1943,15 @@ "file": "ui/storybook/stories/companies/decision-models/01-settings.stories.tsx", "capabilities": [ "companies/identity", - "budgets-costs/cost-reporting" + "budgets-costs/cost-reporting", + "companies/decision-models" ], "purpose": "Decision models/01 Settings", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "maps/feature-map/companies.md", @@ -1932,7 +1959,7 @@ "ui/storybook/stories/companies/decision-models/01-settings.stories.tsx", "ui/storybook/support/decision-models/fixtures.ts" ], - "fingerprint": "26ac3209bb283a94e102e2328689aec2cd801680ede48cbfdf85f77ed3b2b647", + "fingerprint": "deb224b7109eeb7c2672ac22e63c5ea62053198eac5ba69d796c13b418bff263", "stableId": "decision-models-01-settings", "sidebarTitle": "Getting started and access/Companies and portability/Decision models/01 Settings" }, @@ -1940,14 +1967,15 @@ "file": "ui/storybook/stories/companies/decision-models/02-history.stories.tsx", "capabilities": [ "companies/identity", - "budgets-costs/cost-reporting" + "budgets-costs/cost-reporting", + "companies/decision-models" ], "purpose": "Decision models/02 History", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "maps/feature-map/companies.md", @@ -1955,7 +1983,7 @@ "ui/storybook/stories/companies/decision-models/02-history.stories.tsx", "ui/storybook/support/decision-models/fixtures.ts" ], - "fingerprint": "bae133e089fe0a1f7a27436d63bcc6eb4e49612a6418bb0529d49dc3da54d79f", + "fingerprint": "a32eb6bdb93e8fd8ecb05ed6bc8a7483c6c39e77b862700ff1ae192293d139ee", "stableId": "decision-models-02-history", "sidebarTitle": "Getting started and access/Companies and portability/Decision models/02 History" }, @@ -1963,14 +1991,15 @@ "file": "ui/storybook/stories/companies/decision-models/03-journey.stories.tsx", "capabilities": [ "companies/identity", - "budgets-costs/cost-reporting" + "budgets-costs/cost-reporting", + "companies/decision-models" ], "purpose": "Decision models/03 Journey", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related decision-service examples show connection/model configuration, bounded test receipts or invocation history using shipped subjects and simulated data. No paid provider or authorization proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "maps/feature-map/companies.md", @@ -1980,7 +2009,7 @@ "ui/storybook/stories/companies/decision-models/03-journey.stories.tsx", "ui/storybook/support/decision-models/fixtures.ts" ], - "fingerprint": "9d5c8aa2027e900b4b5b52b602065dfe7c779e235b42f46a99dddb8037e4006a", + "fingerprint": "fe773821e67983b5a6255d417fd9952e3bc227c4ccdb8410b06e1a9200c94d63", "stableId": "decision-models-03-journey", "sidebarTitle": "Getting started and access/Companies and portability/Decision models/03 Journey" }, @@ -2056,7 +2085,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/dialogs-modals.stories.tsx" ], - "fingerprint": "0f7eecae93e4360074aeb626deabe29add4a7b3cb96467830d62802094f144a2", + "fingerprint": "75e4b0effdb3cd0c426dce00f908ca4657a03e2ffc365558d074858020ac5f23", "stableId": "product-dialogs-modals", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals" }, @@ -2233,7 +2262,7 @@ "ui/src/components/new-agent/ExternalAgentInviteContent.tsx", "ui/storybook/stories/agents/external-agent-invite/components.stories.tsx" ], - "fingerprint": "68569d7c53ea9f52a63965a6a7568bf7d34d7fc8b34f404bf2e45cccd5822c3b", + "fingerprint": "596b3f1c4e49684683cbf309dc516c5b2d14c1032f2f3c2dba44257bc25c13ef", "stableId": "onboarding-external-agent-invitation-components", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components" }, @@ -2261,7 +2290,7 @@ "ui/storybook/support/external-agent-invite/fixtures.ts", "ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx" ], - "fingerprint": "1dcbec5f8100e600233a3d2a27124e946f24a3fdb9c6fd4dd2b91e42c7134363", + "fingerprint": "fcca4eb4429c48361c9f39c01fe0aa3dab983452fbe8b1cf6be0266a9ff391ad", "stableId": "onboarding-external-agent-invitation-journeys", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys" }, @@ -2297,14 +2326,15 @@ "file": "ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx", "capabilities": [ "companies/identity", - "budgets-costs/cost-reporting" + "budgets-costs/cost-reporting", + "companies/fast-responses" ], "purpose": "Fast responses/01 Settings", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Production settings view with simulated callbacks. Excluded exported fixture helpers and added the onConnectionChange spy required during rendering; full save/API state parity remains unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Production settings view with simulated callbacks. Excluded exported fixture helpers and added the onConnectionChange spy required during rendering; full save/API state parity remains unreviewed. Related fast-response examples show configuration/test receipts or invocation history with shipped subjects and simulated data. A receipt is an acknowledgement, not task completion; no paid provider or authorization proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "maps/feature-map/companies.md", @@ -2312,7 +2342,7 @@ "ui/storybook/support/decision-models/fixtures.ts", "ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx" ], - "fingerprint": "1610fca918abcbf89a5cf901ae36217df841169efcc21bd91db735bb0ea306bd", + "fingerprint": "569c9d9b91872d61d0b4fe44b114d7f9c81bb8c5a5ca5b3545235cd935d8129e", "stableId": "fast-responses-01-settings", "sidebarTitle": "Getting started and access/Companies and portability/Fast responses/01 Settings" }, @@ -2320,14 +2350,15 @@ "file": "ui/storybook/stories/companies/fast-responses/02-history.stories.tsx", "capabilities": [ "companies/identity", - "budgets-costs/cost-reporting" + "budgets-costs/cost-reporting", + "companies/fast-responses" ], "purpose": "Fast responses/02 History", "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shared production DecisionHistoryTable receives fastResponse=true, matching FastResponseHistory presentation; API loading/error and feature-gate wrapper are outside this story.", - "reviewedAt": "2026-10-12", + "notes": "Shared production DecisionHistoryTable receives fastResponse=true, matching FastResponseHistory presentation; API loading/error and feature-gate wrapper are outside this story. Related fast-response examples show configuration/test receipts or invocation history with shipped subjects and simulated data. A receipt is an acknowledgement, not task completion; no paid provider or authorization proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/budgets-costs.md", "maps/feature-map/companies.md", @@ -2335,7 +2366,7 @@ "ui/storybook/support/decision-models/fixtures.ts", "ui/storybook/stories/companies/fast-responses/02-history.stories.tsx" ], - "fingerprint": "a2ed599851817c86c4b03be10c9083e365401be922f731260f1c718e0975c803", + "fingerprint": "0be6f0579e9daf13ba3bc6500c4a5980efc09407a408498385219391a5fdd3c1", "stableId": "fast-responses-02-history", "sidebarTitle": "Getting started and access/Companies and portability/Fast responses/02 History" }, @@ -2417,13 +2448,15 @@ }, { "file": "ui/storybook/stories/foundations/forms-editors.stories.tsx", - "capabilities": [], + "capabilities": [ + "developer-labs/design" + ], "purpose": "Components/Forms & Editors", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim.", + "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/ExecutionParticipantPicker.tsx", "ui/src/components/FoldCurtain.tsx", @@ -2440,22 +2473,26 @@ "ui/src/components/ui/badge.tsx", "ui/src/components/ui/button.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/foundations/forms-editors.stories.tsx" + "ui/storybook/stories/foundations/forms-editors.stories.tsx", + "maps/feature-map/developer-labs.md" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "2ffc131b9725c748a223fe4ddc8f9d31242e6d96e0d2feb3ab91563a89dea120", + "fingerprint": "28920ad31b69171ba62c0fd1f5b7f1581d527ede597acd6b445c5f8e773c2f64", "stableId": "components-forms-editors", - "sidebarTitle": "Foundations/Components/Forms & Editors" + "sidebarTitle": "Foundations/Components/Forms & Editors", + "home": "foundations" }, { "file": "ui/storybook/stories/foundations/foundations.stories.tsx", - "capabilities": [], + "capabilities": [ + "developer-labs/design" + ], "purpose": "Foundations/Primitive Matrix", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim.", + "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/ui/badge.tsx", "ui/src/components/ui/button.tsx", @@ -2471,12 +2508,14 @@ "ui/src/components/ui/textarea.tsx", "ui/src/components/ui/toggle-switch.tsx", "ui/src/components/ui/tooltip.tsx", - "ui/storybook/stories/foundations/foundations.stories.tsx" + "ui/storybook/stories/foundations/foundations.stories.tsx", + "maps/feature-map/developer-labs.md" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "ed53a4be78a572368f4c22577ccad159699f291eb1d01c242528df3b35cd63fe", + "fingerprint": "59d7a5410b79c4825e561dc3286e6138f97fe6fa89a30ccced2c980cf8fc4fe3", "stableId": "foundations-primitive-matrix", - "sidebarTitle": "Foundations/Primitive Matrix" + "sidebarTitle": "Foundations/Primitive Matrix", + "home": "foundations" }, { "file": "ui/storybook/stories/skills/frontmatter-panel.stories.tsx", @@ -2612,13 +2651,14 @@ "file": "ui/storybook/stories/skills/github-skills-journey.stories.tsx", "capabilities": [ "skills/versions", - "skills/discovery" + "skills/discovery", + "skills/policy" ], "purpose": "Skills/GitHub sync journey", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. AssignToAgent and AssignedSkill demonstrate library/detail assignment with local policy fixtures. No runtime enforcement or backend persistence proof.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", @@ -2734,13 +2774,18 @@ { "file": "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx", "capabilities": [ - "chat-channels/conversation" + "chat-channels/conversation", + "chat-channels/providers", + "chat-channels/identity", + "chat-channels/lifecycle", + "connection-setup/channel-setup", + "connection-setup/failure" ], "purpose": "Connections/iMessage Photon", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Inspected channel task-default controls, optional API fields, avatar rendering and matching fixture updates; live defaults inheritance and permission checks remain outside these stories. Catalog, ChooseAgent, Credentials, Access, Reconnect and ProviderOutageRecovery render shipped setup/detail states with simulated provider data. No live iMessage connection, delivery or recovery claim.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", @@ -2754,9 +2799,10 @@ "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", "ui/src/pages/apps/chat/ChatExecutionDefaults.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" + "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx", + "maps/feature-map/connection-setup.md" ], - "fingerprint": "c8ba561728c0c418b6b2083cf72cb09712b96415a66285267ec46abc9d7c26bc", + "fingerprint": "b851159519b6a550975b618df95beb65dc1b3eba1c5cae4ebaf19665b81b99a0", "stableId": "connections-imessage-photon", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" }, @@ -2910,13 +2956,15 @@ "capabilities": [ "questions-and-approvals/questions", "questions-and-approvals/confirmations", - "questions-and-approvals/audience" + "questions-and-approvals/audience", + "questions-and-approvals/plan-review", + "questions-and-approvals/continuation" ], "purpose": "Chat & Comments/Issue Thread Interactions", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Plan approval Pending, Confirmed, ExpiredByTargetChange and ResumeFailed states use shipped interaction cards. The continuation association covers accepted-but-resume-failed presentation only; no successful resumption, retry or duplicate-suppression proof.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", @@ -2989,7 +3037,7 @@ "ui/src/pages/apps/Browse.tsx", "ui/storybook/stories/connection-setup/memory-connectors.stories.tsx" ], - "fingerprint": "498446eef1e0bae94d8d740335989d615eeddb5961768d900bee994533ed9883", + "fingerprint": "f7e104c4e8b912b93fab037be9811583b3437e3c2d8a906eba343e70107dfc80", "stableId": "apps-connections-memory", "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Memory" }, @@ -3139,7 +3187,7 @@ "ui/storybook/stories/agents/new-agent-onboarding.stories.tsx" ], "replacement": "ui/storybook/stories/agents/agent-persona-pages.stories.tsx", - "fingerprint": "4f8600fabe74a9c84e6fd6501f8f7cf9c1e1b5926bf2086d3bafaf2b2ed17a94", + "fingerprint": "b79b3193788385fef0e26daaf1cf30f48e77ca2f224a70141a64c0feced97aea", "stableId": "onboarding-new-agent", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent" }, @@ -3171,22 +3219,24 @@ { "file": "ui/storybook/stories/tasks/new-task-composer.stories.tsx", "capabilities": [ - "tasks/create" + "tasks/create", + "workspaces/binding" ], "purpose": "Composer/New task", "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", "ui/src/components/MarkdownBody.tsx", "ui/src/components/NewIssueDialog.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/tasks/new-task-composer.stories.tsx" + "ui/storybook/stories/tasks/new-task-composer.stories.tsx", + "maps/feature-map/workspaces.md" ], - "fingerprint": "35fbacd5d5fc225d537fc537e34ad8601a75a7b3aa21b6016537096337b71261", + "fingerprint": "56ef4e67f8e6b51ab54a664336fae33a75aece2f56883861ce617d53145b5637", "stableId": "composer-new-task", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/New task" }, @@ -3328,7 +3378,7 @@ "ui/storybook/generated/feature-index.json" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "e8dad5fdfca60cad92af994a3d9defbe847894610c368a0edbd1900837c03743", + "fingerprint": "b4a1cb9cf763e78cfc632a5adf859c8827318de12eeb0cd359b4a5b8b838a730", "stableId": "overview-storybook-guide", "sidebarTitle": "Overview/Storybook Guide" }, @@ -3402,7 +3452,7 @@ { "file": "ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3410,7 +3460,7 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3418,14 +3468,14 @@ "maps/feature-map/tasks.md", "ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx" ], - "fingerprint": "277165110d00845305892000266531905d0a18f017c6c9bf2d8e2e2069af5cb2", + "fingerprint": "d54242a3d8d8096014dd54fe49ca0e86669894ff859d41ff0e50866315a412f4", "stableId": "primary-agent-00-review-guide", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide" }, { "file": "ui/storybook/stories/agents/primary-agent/01-components.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3433,7 +3483,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3453,14 +3503,14 @@ "ui/storybook/stories/agents/primary-agent/01-components.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "910d12dfac4a601dad1d80217dadf4db6855d8580157c8d9bb18db13b5bf7ddc", + "fingerprint": "b1ae1d45a7e4a89f94a4a10343ddabd085cc758f0ceb17df43806722df874019", "stableId": "primary-agent-01-components", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components" }, { "file": "ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3468,7 +3518,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3487,14 +3537,14 @@ "ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "05f87fcb1dc24bd7ca56ca885ef1c79343f0330f9e93fb0c017aa7cb21c3a104", + "fingerprint": "a98ea7e6c56135bd030b43385bfdb2a5e12f5e78d18ed301c88ff12da07481a3", "stableId": "primary-agent-02-sidebar", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar" }, { "file": "ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3502,7 +3552,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3521,14 +3571,14 @@ "ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "d9878058d6c2d4d144bcb8aeb97e9e0cb8186407b1b3fefb07078af7f147d74f", + "fingerprint": "2096810ef9ccf52eaf2811175604550302b314fc2e3d6bb951413a5505c3214b", "stableId": "primary-agent-03-agents", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents" }, { "file": "ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3536,7 +3586,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3555,14 +3605,14 @@ "ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "8ab5193560196f684c82e8d8fab6728a208395c3b8628f04dac0ac8c0185d4aa", + "fingerprint": "2ab88c703c7376edd519dc413a562ab802998ae25cafc44925fdd00f48ceb7cb", "stableId": "primary-agent-04-new-task", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task" }, { "file": "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3570,7 +3620,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3589,14 +3639,14 @@ "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "7defc95cd9c952f5f5f4ad1c7954478f5a33921fc6c6b1adf0a8fa3e16621dff", + "fingerprint": "2b22fe59288fb0d6597f7122ac195038f3ed8d748c2ed80fba7e795576501cd3", "stableId": "primary-agent-05-chat", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat" }, { "file": "ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3604,7 +3654,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3623,14 +3673,14 @@ "ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "6c8a08ae15112a54e69fee220160a1550ab9098445486e316c4fb34e2d6e085f", + "fingerprint": "67c35f4ec63e84d1ccaa4362e2e2289857ecfa583d412c8582168d169ca38a54", "stableId": "primary-agent-06-journeys", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys" }, { "file": "ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx", "capabilities": [ - "agents/organization", + "agents/primary-agent", "tasks/create", "agent-chat/discovery" ], @@ -3638,7 +3688,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3657,18 +3707,20 @@ "ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "a9ecc61cc3e70460b23e3fdd5ede33abd8e4e8e62d5dbce5fa5fcdbf0df2d234", + "fingerprint": "6be424a8d962be786c09ef1d2c3b505e407c7b645367adabfc2f737d73c692c6", "stableId": "primary-agent-07-confirmation", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation" }, { "file": "ui/storybook/stories/foundations/primitives-coverage.stories.tsx", - "capabilities": [], + "capabilities": [ + "developer-labs/design" + ], "purpose": "Foundations/Primitive Coverage", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim.", "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/ui/alert-dialog.tsx", @@ -3682,12 +3734,14 @@ "ui/src/components/ui/scroll-area.tsx", "ui/src/components/ui/sheet.tsx", "ui/src/components/ui/skeleton.tsx", - "ui/storybook/stories/foundations/primitives-coverage.stories.tsx" + "ui/storybook/stories/foundations/primitives-coverage.stories.tsx", + "maps/feature-map/developer-labs.md" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "2fba85501507d80dae7b3c202c083d9ac32c796ee8e234a4fecc6a80b33fa35c", + "fingerprint": "bfacafe1d48789bac91fb595247ee767edf52cc705ba353031ccb155879b3d4c", "stableId": "foundations-primitive-coverage", - "sidebarTitle": "Foundations/Primitive Coverage" + "sidebarTitle": "Foundations/Primitive Coverage", + "home": "foundations" }, { "file": "ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx", @@ -4351,21 +4405,23 @@ { "file": "ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx", "capabilities": [ - "gateways-profiles/client-connect" + "gateways-profiles/client-connect", + "instance-operations/configuration" ], "purpose": "Assistant connections/Experimental setting", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. ToggleOnAndOff, ManagedByCloud and SaveFailedRollsBack mount production experimental settings with simulated saves. Only the assistant-connection flag is represented, not every instance setting.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/gateways-profiles.md", "ui/src/pages/InstanceExperimentalSettings.tsx", "ui/storybook/fixtures/publicMcp.ts", - "ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx" + "ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx", + "maps/feature-map/instance-operations.md" ], - "fingerprint": "15f99c5632c11f8e90dfaa340d192b90b07138176317e17e51a176952383adb7", + "fingerprint": "96735a71b1d81cbe604ed55594ee9a8a04de8f365665c33ca260fbfc84263d55", "stableId": "assistant-connections-experimental-setting", "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting" }, @@ -4474,14 +4530,15 @@ "file": "ui/storybook/stories/routines/routine-detail-c.stories.tsx", "capabilities": [ "routines/definition", - "routines/management" + "routines/management", + "activity/routine-audit" ], "purpose": "Product/Routines · Detail (variation C)", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Activity mounts the shipped ActivitySection with fixture trigger/run/configuration events. This does not establish live routine execution or audit persistence.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/routines.md", "ui/src/components/AgentActionButtons.tsx", @@ -4491,9 +4548,10 @@ "ui/src/components/routine-sections/editable-sections.tsx", "ui/src/components/routine-sections/operate-sections.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/routines/routine-detail-c.stories.tsx" + "ui/storybook/stories/routines/routine-detail-c.stories.tsx", + "maps/feature-map/activity.md" ], - "fingerprint": "c07544c830cd35d90c553d9388e2a9479e42f69460a6c506e1c541cea78607d4", + "fingerprint": "853370d71e85c5e01b79d19831cba7f839f4b75af42fa51e7e94f84e31a6eb81", "stableId": "product-routines-·-detail-variation-c", "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)" }, @@ -4554,14 +4612,16 @@ { "file": "ui/storybook/stories/routines/routine-webhooks.stories.tsx", "capabilities": [ - "routines/triggers" + "routines/triggers", + "routines/history", + "activity/routine-audit" ], "purpose": "Product/Routines/Webhooks", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runs and Activity mount RoutineDetail with fixture execution tasks/events and play assertions. Scheduler execution, delivery and audit persistence are not live.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/routines.md", "ui/src/components/Layout.tsx", @@ -4569,9 +4629,10 @@ "ui/src/pages/Routines.tsx", "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/fixtures/routineWebhooks.tsx", - "ui/storybook/stories/routines/routine-webhooks.stories.tsx" + "ui/storybook/stories/routines/routine-webhooks.stories.tsx", + "maps/feature-map/activity.md" ], - "fingerprint": "588536cb71d6c79eff20ea1dc14d60c96932631f267a45adc36e2078d816aa39", + "fingerprint": "93bdd95dd2c677ba7562f477b111486f93a081276a9e3c3a8af3c45874f20a5d", "stableId": "product-routines-webhooks", "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks" }, @@ -4784,14 +4845,16 @@ { "file": "ui/storybook/stories/skills/skills-store-detail.stories.tsx", "capabilities": [ - "skills/discovery" + "skills/discovery", + "skills/versions", + "skills/policy" ], "purpose": "Skills Store/Skill detail", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related examples cover fixture revisions and agent attachment selection. Save and attachment submission callbacks are no-ops; durable authoring, grants and enforcement are not established.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/skills.md", "ui/src/pages/CompanySkills.tsx", @@ -4983,13 +5046,15 @@ }, { "file": "ui/storybook/stories/foundations/status-language.stories.tsx", - "capabilities": [], + "capabilities": [ + "developer-labs/design" + ], "purpose": "Foundations/Status Language", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Related design reference: demonstrates shared production controls and visual states in an isolated contributor fixture; retains its Foundations home. No product-journey or baseline comparison claim.", + "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/CopyText.tsx", "ui/src/components/EmptyState.tsx", @@ -5003,12 +5068,14 @@ "ui/src/components/StatusIcon.tsx", "ui/src/components/ui/card.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/foundations/status-language.stories.tsx" + "ui/storybook/stories/foundations/status-language.stories.tsx", + "maps/feature-map/developer-labs.md" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "1c7360f6c527b4377c85ee10b1eeb4d268b1af77b7a403ed321129b918409b76", + "fingerprint": "9cf8b929ba0d6ad4d8d2a6711aa7fb609ef57069d87a050f77dd389de608ee36", "stableId": "foundations-status-language", - "sidebarTitle": "Foundations/Status Language" + "sidebarTitle": "Foundations/Status Language", + "home": "foundations" }, { "file": "ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx", @@ -5074,7 +5141,7 @@ "ui/src/components/SummarySlotCard.tsx", "ui/storybook/stories/agents/summary-slot-card.stories.tsx" ], - "fingerprint": "810f833adef832e3eaf3a0e70da1b433d6c1bb59deb7928725e4de48753c0000", + "fingerprint": "88f4120cbc9da82fdcd44644259f8bacf39b482ef12950e0b26313b5c0a2c81d", "stableId": "summaries-summaryslotcard", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard" }, @@ -5318,14 +5385,16 @@ "file": "ui/storybook/stories/teams/team-catalog.stories.tsx", "capabilities": [ "teams/preview", - "teams/discovery" + "teams/discovery", + "teams/install", + "teams/configuration" ], "purpose": "Surfaces/Team Catalog", "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Renders shipped TeamCatalog source, but feature map documents current CLI/API access and no top-level catalog route.", - "reviewedAt": "2026-10-12", + "notes": "Renders shipped TeamCatalog source, but feature map documents current CLI/API access and no top-level catalog route. InstallApplyProgress, InstallSuccess and InstalledStates show simulated installation results; InstallPreview edits adapter choices in local state. No routed catalog, real package installation, account setup or working-agent proof.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/teams.md", "ui/src/pages/TeamCatalog.fixtures.ts", diff --git a/scripts/storybook-catalog.mjs b/scripts/storybook-catalog.mjs index bba57300f71..43c50bcba81 100644 --- a/scripts/storybook-catalog.mjs +++ b/scripts/storybook-catalog.mjs @@ -54,6 +54,10 @@ export function scanStories(root = defaultRoot) { }); } +function storyHome(entry) { + return entry.home ?? entry.capabilities[0]?.split("/")[0] ?? (entry.implementation === "guide" ? "overview" : "foundations"); +} + export function validateCatalog(root, catalog, stories) { const errors = []; const refs = capabilities(root); @@ -79,10 +83,12 @@ export function validateCatalog(root, catalog, stories) { if (!entry.purpose?.trim() || !entry.notes?.trim()) errors.push(`${label}: purpose and review notes are required`); if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.reviewedAt ?? "")) errors.push(`${label}: missing review date`); if (!Array.isArray(entry.capabilities)) { errors.push(`${label}: missing capabilities`); continue; } + if (entry.home !== undefined && entry.home !== "foundations") errors.push(`${label}: only shared foundations may override the capability home`); + if (entry.home === "foundations" && !entry.exception?.trim()) errors.push(`${label}: foundation home needs an explicit reason`); for (const ref of entry.capabilities) if (!refs.has(ref)) errors.push(`${label}: unknown feature capability ${ref}`); if (!entry.capabilities.length && !entry.exception?.trim()) errors.push(`${label}: unmapped story needs an explicit reason`); if (entry.capabilities.length !== new Set(entry.capabilities).size) errors.push(`${label}: duplicate capability references`); - const area = entry.capabilities[0]?.split("/")[0] ?? (entry.implementation === "guide" ? "overview" : "foundations"); + const area = storyHome(entry); const prefix = navigation.get(area) ?? ({ overview: "Overview", foundations: "Foundations" })[area]; const story = byFile.get(label); if (!label.startsWith(`${storyRoot}/${area}/`)) errors.push(`${label}: story must live in its primary feature folder ${area}`); @@ -104,7 +110,8 @@ export function renderIndex(catalog, stories, index) { const byFile = new Map(stories.map(story => [story.file, story])); const groups = new Map(); for (const entry of catalog.entries) { - const area = entry.capabilities[0]?.split("/")[0] ?? "foundations-and-guides"; + const home = storyHome(entry); + const area = ["foundations", "overview"].includes(home) ? "foundations-and-guides" : home; if (!groups.has(area)) groups.set(area, []); groups.get(area).push(entry); } diff --git a/scripts/storybook-catalog.test.mjs b/scripts/storybook-catalog.test.mjs index 2dddd2d07e0..9c41a069ec4 100644 --- a/scripts/storybook-catalog.test.mjs +++ b/scripts/storybook-catalog.test.mjs @@ -82,6 +82,31 @@ test("feature organization rejects wrong folders, sidebar homes and unstable met assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /sidebar title/); }); +test("shared foundations cross-list capabilities without moving their canonical home or IDs", t => { + const f = fixture(t); + const entry = f.catalog.entries[0]; + const file = "ui/storybook/stories/foundations/task.stories.tsx"; + f.write(file, "// shared control example\n"); + entry.file = file; + entry.home = "foundations"; + entry.exception = "Shared control with related product examples."; + entry.sidebarTitle = "Foundations/Task"; + entry.sources = [file, "ui/src/Task.tsx"]; + entry.fingerprint = fingerprint(f.root, entry.sources); + f.stories[0] = { ...f.stories[0], file, title: entry.sidebarTitle }; + assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []); + const index = renderIndex(f.catalog, f.stories, featureIndex(f.root)); + assert.match(index, /### Tasks[\s\S]*?No primary story suite/); + assert.match(index, /### Shared foundations and guides[\s\S]*?\[Foundations\/Task\]/); + assert.match(index, /`tasks\/create`: \[Foundations\/Task\]/); + assert.deepEqual(f.stories[0].ids, ["tasks-create--empty"]); + entry.home = "tasks"; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /only shared foundations/); + entry.home = "foundations"; + delete entry.exception; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /foundation home needs an explicit reason/); +}); + test("production and shared fixture drift invalidate review fingerprints", t => { const f = fixture(t); f.write("ui/src/Task.tsx", "// changed contract\n"); diff --git a/ui/storybook/generated/feature-index.json b/ui/storybook/generated/feature-index.json index 2ba715c3dff..b7d02a273be 100644 --- a/ui/storybook/generated/feature-index.json +++ b/ui/storybook/generated/feature-index.json @@ -82,6 +82,16 @@ "label": "Identity", "description": "update company identity and configured settings." }, + { + "id": "companies/decision-models", + "label": "Decision models", + "description": "configure and test the company's decision model, then inspect invocation history and recorded usage." + }, + { + "id": "companies/fast-responses", + "label": "Fast responses", + "description": "configure and test the experimental fast-response service, then inspect acknowledgement history, failures, and recorded usage." + }, { "id": "companies/archive", "label": "Archive", @@ -358,6 +368,11 @@ "label": "Organization", "description": "inspect and change reporting relationships through supported controls." }, + { + "id": "agents/primary-agent", + "label": "Primary agent", + "description": "choose a personal primary agent for the company, confirm replacement, and inspect its roster/sidebar and task/chat defaults." + }, { "id": "agents/lifecycle", "label": "Lifecycle", diff --git a/ui/storybook/support/feature-map/FeatureOverview.tsx b/ui/storybook/support/feature-map/FeatureOverview.tsx index b543a545dde..025616fb8eb 100644 --- a/ui/storybook/support/feature-map/FeatureOverview.tsx +++ b/ui/storybook/support/feature-map/FeatureOverview.tsx @@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input"; import catalog from "../../../../maps/storybook-map/catalog.json"; import featureIndex from "../../generated/feature-index.json"; -type Entry = Omit<(typeof catalog.entries)[number], "capabilities"> & { capabilities: string[] }; +type Entry = Omit<(typeof catalog.entries)[number], "capabilities" | "home"> & { capabilities: string[]; home?: string }; const entries: Entry[] = catalog.entries; type IndexEntry = { id: string; type: string; importPath: string }; const recipeRoot = "https://github.com/paperclipai/paperclip/blob/master/maps/feature-map/"; @@ -68,7 +68,7 @@ export function FeatureMapIndex() { ].join(" ").toLowerCase().includes(search); }), })).filter(section => section.features.length), [search]); - const foundations = examples.filter(entry => !entry.capabilities.length + const foundations = examples.filter(entry => (entry.home === "foundations" || !entry.capabilities.length) && ["Shared foundations", entry.file, entry.purpose, entry.status, entry.implementation, entry.boundary].join(" ").toLowerCase().includes(search)); return (
    From 7ac0354b5166f3f834639a7b5bb4a872a55c03f3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:59:27 -0500 Subject: [PATCH 10/85] docs: track chat channel rules and Greptile configuration (#16172) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat channels connect shared task workflows to several platforms. > - Recent refactors moved platform behavior into named channel folders. > - Authors and reviewers need a durable rule that preserves these boundaries. > - Review settings also need version control and an explicit source of rules. > - This pull request adds scoped MDC rules and a staged migration of supported Greptile settings into Git. ## Linked Issues or Issue Description **Issue type** Missing documentation and versioned review configuration. **Where is the issue?** Root contributor instructions, chat channel architecture guidance, and Greptile configuration for this repository. **What's wrong?** The channel folder boundary is established in the refactor but has no focused rule for future changes. Greptile settings and custom context are maintained in the dashboard. **Suggested fix** Keep canonical rule bodies under `rules/`. Route coding agents through `AGENTS.md` and load matching rules through `.greptile/files.json`. Preserve supported review settings in `.greptile/config.json` and document the remaining dashboard controls. Related work: #15823 and #16080. A search for related rule PRs found no duplicate configuration change. ## What Changed - Add `rules/chat-channel-platform-boundaries.mdc`. Its scope covers `server/src/services/chat-channels/**` and the sibling `chat-channels.ts` entry point. - Define provider folder ownership, shared workflow boundaries, typed dependencies, behavior-preserving extraction, and dispatch exceptions. - Add rule-loading instructions to root `AGENTS.md` and `.greptile/rules.md`. - Add explicit, scoped context references in `.greptile/files.json`. - Record observed review triggers, the Dependabot exclusion, Base tier, High strictness, summary options, fix prompts, status checks, and disabled auto-approval in `.greptile/config.json`. - Move existing PR-description, source-file growth, and repository-specific screenshot guidance into `rules/pull-request-review.mdc`. - Document supported settings, remaining dashboard controls, and the staged cutover. The dashboard was inspected read-only; no settings or saved rules were changed. ## Verification - Parse both MDC headers with Ruby `YAML.safe_load`: passed. - Parse both Greptile JSON files and verify documented field types and observed values: passed. - Verify all four context references resolve to repository files: passed. - Compare scoped MDC globs with the corresponding Greptile scope: passed. - Check ten positive and negative scope cases with Node `path.matchesGlob`: passed. Includes the service entry point, nested provider files, and unrelated runner, connection, and UI paths. - Verify the scope covers all 101 tracked chat-channel files: passed. - `git diff --check`: passed. - Application typecheck, `pnpm test:run`, and build were not run locally. This change contains only guidance and reviewer configuration, and this worktree has no installed application dependencies. No runtime behavior or application tests changed. - Greptile reviewed commit `32647dfd001289a297b9d43c381371da8575df38`: 5/5, eight files reviewed, zero comments. Hosted CI is still running. ## Risks - Instructions depend on agent and reviewer compliance; these files are not deterministic lint rules. - The new MDC directory is explicitly routed through `AGENTS.md` and Greptile. Cursor does not automatically discover arbitrary `rules/` directories. - This preserves the observed High strictness setting, which the dashboard says omits P2 comments. It does not change that review policy. - Dashboard instructions and custom rules remain additive. Their copies were not disabled during read-only inspection. The repository-specific no-screenshot rule explicitly takes precedence over the conflicting older organization instruction. - Draft review, the required 5/5 status threshold, image badges, and other controls without documented config equivalents remain in the dashboard. See `.greptile/README.md` for the inventory and cutover limitations. ## Model Used OpenAI Codex based on GPT-6. The exact runtime model ID and context-window size are not exposed in this session. Used reasoning, repository inspection, shell validation, official documentation, and read-only browser inspection. ## 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 (configuration parsing and scope checks described above; application suites were not run) - [x] I have added or updated tests where applicable (no runtime change; checked configuration directly) - [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 - [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 --- .greptile/README.md | 71 ++++++++++++++++++++++ .greptile/config.json | 28 +++++++++ .greptile/files.json | 24 ++++++++ .greptile/rules.md | 12 ++++ AGENTS.md | 14 +++++ rules/README.md | 31 ++++++++++ rules/chat-channel-platform-boundaries.mdc | 60 ++++++++++++++++++ rules/pull-request-review.mdc | 22 +++++++ 8 files changed, 262 insertions(+) create mode 100644 .greptile/README.md create mode 100644 .greptile/config.json create mode 100644 .greptile/files.json create mode 100644 .greptile/rules.md create mode 100644 rules/README.md create mode 100644 rules/chat-channel-platform-boundaries.mdc create mode 100644 rules/pull-request-review.mdc diff --git a/.greptile/README.md b/.greptile/README.md new file mode 100644 index 00000000000..00657270392 --- /dev/null +++ b/.greptile/README.md @@ -0,0 +1,71 @@ +# Greptile configuration + +This directory tracks the supported review configuration for +`paperclipai/paperclip`. It was prepared from a read-only dashboard inventory +on 2026-10-13. No dashboard settings or custom context were changed. + +- `config.json` records the observed review settings supported by Greptile's + documented file schema. +- `files.json` loads repository context and scopes architectural rule files. +- `rules.md` explains how to apply the canonical Markdown rules in `rules/`. + +## Settings migrated to Git + +| Dashboard setting | Repository configuration | +| --- | --- | +| Automatic reviews on all events | `autoReview: ["open", "push", "rebase"]` | +| Exclude `dependabot[bot]`; remaining reviews use Base | `excludeAuthors`, `effort: "base"` | +| High strictness (dashboard says it does not comment on P2s) | `strictness: 3` | +| Do not replace the PR description | `shouldUpdateDescription: false` | +| Include PR summary, confidence score, and findings; no sequence diagram | The four section settings in `config.json` | +| Summary is not collapsible; default-open option is off | `summarySection.collapsible` and `defaultOpen` are `false` | +| Include a prompt to fix findings | `fixWithAI: true` | +| Status checks and status comments enabled | `statusCheck` and `statusCommentsEnabled` are `true` | +| Auto-approval disabled; stored risk ceiling is Low | `autoApprove.enabled: false`, `riskCeiling: "low"` | + +The existing PR-description and 1,000-line growth guidance is in +`rules/pull-request-review.mdc`. That file also preserves the active, +repository-specific rule not to request UI screenshots. This resolves the +contradiction with the older organization instruction asking for screenshots. + +The active dashboard pattern `**/[Aa][Gg][Ee][Nn][Tt][Ss].md` reported two files: +`AGENTS.md` and `docs/api/agents.md`. Both are explicit references in +`files.json`; the latter remains API context, not contributor policy. Future +context files need explicit manifest entries. The inactive Contribution +guidelines context remains inactive; it is not added as a separate context +file. Rules scoped to other repositories are not copied here. + +## Settings still managed in Greptile + +The following observed controls do not have equivalents in the published +`.greptile/` schema used for this migration. They remain dashboard settings: + +- Review draft pull requests: enabled. +- Required confidence to pass the status check: 5/5. +- Image badges: enabled. +- Comment header: empty. +- Default coding-agent badges: no agents shown. + +Repository enablement, provider authentication, billing, integrations, and +learned memory also remain service settings. Auto-approval risk instructions, +if used later, are explicitly dashboard-only in Greptile's reference. Do not +invent config keys for unsupported controls. + +## Cutover and maintenance + +Greptile reads committed configuration from the reviewed branch. No reindex is +required. Local changes alone are not visible to the hosted reviewer. + +File-based review settings override dashboard defaults, but dashboard rules +and instructions are additive. The existing dashboard copies are still active. +This is a staged migration, not a completed dashboard cleanup. After a hosted +review verifies the configuration, an authorized maintainer can retire duplicate +dashboard context scoped to this repository. Do not remove shared organization +instructions while other repositories still depend on them. + +Keep each policy's body in `rules/`; update the `files.json` scope together with +the rule's MDC frontmatter. The chat-channel rule includes both the directory +and its sibling service entry point. See `rules/README.md` for authoring steps. + +References: [configuration and precedence](https://www.greptile.com/docs/code-review/greptile-config), +[supported fields and file references](https://www.greptile.com/docs/code-review/greptile-config-reference). diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000000..696d62f3cc6 --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,28 @@ +{ + "autoReview": ["open", "push", "rebase"], + "excludeAuthors": ["dependabot[bot]"], + "effort": "base", + "strictness": 3, + "shouldUpdateDescription": false, + "summarySection": { + "included": true, + "collapsible": false, + "defaultOpen": false + }, + "confidenceScoreSection": { + "included": true + }, + "issuesTableSection": { + "included": true + }, + "sequenceDiagramSection": { + "included": false + }, + "fixWithAI": true, + "statusCheck": true, + "statusCommentsEnabled": true, + "autoApprove": { + "enabled": false, + "riskCeiling": "low" + } +} diff --git a/.greptile/files.json b/.greptile/files.json new file mode 100644 index 00000000000..3738fce829c --- /dev/null +++ b/.greptile/files.json @@ -0,0 +1,24 @@ +{ + "files": [ + { + "path": "AGENTS.md", + "description": "Repository contributor instructions and routing to scoped architecture rules." + }, + { + "path": "docs/api/agents.md", + "description": "Agent API documentation retained from the existing Greptile agent-file context. This is API reference material, not contributor instructions." + }, + { + "path": "rules/pull-request-review.mdc", + "description": "Existing Paperclip review guidance for PR descriptions, UI evidence, and growth of large source files." + }, + { + "path": "rules/chat-channel-platform-boundaries.mdc", + "description": "Architecture requirements and review criteria for separating chat channel platform implementations from shared orchestration.", + "scope": [ + "server/src/services/chat-channels/**", + "server/src/services/chat-channels.ts" + ] + } + ] +} diff --git a/.greptile/rules.md b/.greptile/rules.md new file mode 100644 index 00000000000..f6b4ff9cf8c --- /dev/null +++ b/.greptile/rules.md @@ -0,0 +1,12 @@ +# Repository rule loading + +The `.mdc` files under `rules/` are repository review requirements. +`.greptile/files.json` identifies the rule files to read for matching source +paths. Read their full Markdown bodies and apply their review criteria and +allowed exceptions to the affected code. + +Each rule's YAML frontmatter declares its scope with repository-relative +`globs`, or `alwaysApply: true` for a repository-wide rule. Respect that scope; +do not apply a channel rule to unrelated subsystems. Keep the policy in the +referenced file as the source of truth rather than maintaining a separate copy +in dashboard instructions. diff --git a/AGENTS.md b/AGENTS.md index c7b53d20d43..c6b67349dc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,20 @@ authoring runbook for provider research, supported transport/auth patterns, credential handling, branding, implementation, testing, live proof, and PR submission. +### Scoped repository rules + +Before editing or reviewing files, read the matching rules in `rules/*.mdc`. +Each rule has Markdown content and YAML frontmatter: `globs` contains +comma-separated, repository-relative file patterns, and `alwaysApply: true` +means the rule applies throughout the repository. Read all rules whose scope +matches the affected files. These rules supplement this file and any applicable +nested `AGENTS.md` instructions. + +For `server/src/services/chat-channels.ts` and everything under +`server/src/services/chat-channels/`, read +[`rules/chat-channel-platform-boundaries.mdc`](rules/chat-channel-platform-boundaries.mdc). +See [`rules/README.md`](rules/README.md) for rule authoring and Greptile setup. + ## 3. Repo Map - `server/`: Express REST API and orchestration services diff --git a/rules/README.md b/rules/README.md new file mode 100644 index 00000000000..2e7edf1d706 --- /dev/null +++ b/rules/README.md @@ -0,0 +1,31 @@ +# Repository rules + +Keep architectural guidance in focused `.mdc` files here. Each file is Markdown +with YAML frontmatter containing a `description` and an `alwaysApply` boolean. +For scoped rules, set `alwaysApply: false` and provide comma-separated, +repository-relative `globs`. For repository-wide rules, set `alwaysApply: true` +and omit `globs`. Use `**` for all descendants of a directory. Include sibling +entry points explicitly when needed. + +Root `AGENTS.md` tells coding agents to read matching rules before editing or +reviewing code. This directory is a repository convention: Cursor does not +automatically load it as `.cursor/rules/`, and the `.mdc` extension alone does not +make a tool apply a rule. + +Greptile reads matching rule files through `.greptile/files.json` and receives +loading instructions from `.greptile/rules.md`. The rule body remains here as +the single source of truth. Repository configuration takes effect when the +files are available on the reviewed branch; local files alone are not visible +to the hosted reviewer. + +When adding or changing a rule: + +1. State the scope, required behavior, examples, exceptions, and review criteria. +2. Add its path to `.greptile/files.json`. Copy its `globs` into that entry's + `scope` array, one pattern per element. For `alwaysApply: true`, omit `scope`. +3. Keep the rule's frontmatter and the Greptile scope synchronized. Verify that + the scope covers representative files and excludes unrelated directories. +4. Keep detailed policy in the rule file instead of copying it into tool settings. + +See the [Greptile file-reference documentation](https://www.greptile.com/docs/code-review/greptile-config-reference) +for the manifest format. diff --git a/rules/chat-channel-platform-boundaries.mdc b/rules/chat-channel-platform-boundaries.mdc new file mode 100644 index 00000000000..3fa3e6a2a66 --- /dev/null +++ b/rules/chat-channel-platform-boundaries.mdc @@ -0,0 +1,60 @@ +--- +description: Keep chat channel platform implementations in their own folders and shared orchestration independent of platform details. +globs: "server/src/services/chat-channels/**,server/src/services/chat-channels.ts" +alwaysApply: false +--- + +# Chat channel platform boundaries + +## Scope and purpose + +This rule applies to `server/src/services/chat-channels/` and its sibling +`server/src/services/chat-channels.ts` service entry point. Keep each platform's +implementation cohesive while letting common workflows support different +platforms through explicit contracts. + +## Required structure + +- Put platform-specific code in `chat-channels//`. This includes + payload parsing, SDK and transport calls, protocol details, credential and + setup behavior, message formatting, and platform-specific workarounds. +- Use the existing platform directories, such as `telegram/`, `slack/`, + `discord/`, `github/`, `microsoft-teams/`, and `imessage-photon/`. Organize files + within each directory by responsibility, such as `messages.ts` or `setup.ts`. +- Keep shared product behavior in the service or cohesive workflow modules, + such as `inbound/`, `interactions/`, and `publications/`. Shared code owns common + company isolation, authorization, task lifecycle, ordering, and persistence + invariants. Provider details must not bypass those invariants. +- Use explicit typed inputs, outputs, capabilities, or callbacks at the boundary. + Shared workflows should consume normalized values rather than interpret one + platform's raw payloads. Pass the dependencies a module needs instead of a + large mutable service context. +- Reuse existing contracts and shared implementations when their behavior is + actually common. Preserve meaningful platform differences. Add a new + abstraction only when a concrete workflow needs it. +- When extracting code, preserve transaction ownership, queue ordering, + idempotency, runtime state lifetime, and callback timing. Moving code must not + create duplicate state or a second owner for a side effect. + +## Examples and allowed exceptions + +- Telegram edited-message parsing belongs in `telegram/messages.ts`; a shared + delivery workflow can consume its normalized message or lifecycle event. +- Shared orchestration may import platform modules and select an implementation + by provider name. Small registration tables and dispatch branches are valid; + the selected branch should delegate protocol behavior to the platform module. +- Keep behavior used by several platforms in a shared module when its inputs and + semantics are independent of those platforms. Do not make one platform import + another platform's implementation merely to reuse a helper. +- Existing code outside these boundaries can be migrated incrementally. Apply + the rule to new or materially changed behavior; an unrelated fix does not + require moving the whole subsystem. + +## Review criteria + +Flag new or expanded platform-specific implementation in the service entry +point or shared workflow modules, direct dependencies between unrelated +platform implementations, and shared contracts that expose a platform's raw +payload or SDK types. Identify the behavior and the appropriate owning folder. +Allow the registration and dispatch exceptions above, and do not request an +abstract provider framework solely to remove a small dispatch branch. diff --git a/rules/pull-request-review.mdc b/rules/pull-request-review.mdc new file mode 100644 index 00000000000..1ba08c96312 --- /dev/null +++ b/rules/pull-request-review.mdc @@ -0,0 +1,22 @@ +--- +description: Existing Paperclip pull request review requirements migrated from Greptile settings. +alwaysApply: true +--- + +# Pull request review + +This rule applies when reviewing pull requests throughout this repository. + +- Follow the PR description guide in `CONTRIBUTING.md` and the sections in + `.github/PULL_REQUEST_TEMPLATE.md`. Request missing thinking paths and missing + explanations of what changed, why it matters, the benefits, verification, or + risks. +- Do not request UI screenshots in PR reviews for this repository. Visual state + verification is described through component tests, enumeration of states in + the PR description, and use of existing design tokens. This repository rule + takes precedence over the older organization instruction to request + screenshots for visual changes. +- When a PR grows a source file from fewer than 1,000 lines to more than 1,000 + lines, require a concrete reason for keeping the implementation together. + Flag an unexplained crossing and suggest a cohesive extraction. This check + does not require unrelated cleanup of every existing large file. From 561e9562662fe414016b2a982ef2dfb89532a851 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:05:39 -0500 Subject: [PATCH 11/85] chore: assign owners to Greptile configuration and rules (#16174) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Repository rules guide code authors and reviewers. > - PR #16172 added rules and Greptile configuration to Git. > - Those directories need named maintainers for future changes. > - This pull request adds ownership for both root directories. > - The entries use the same four maintainers as the existing infrastructure entries. ## Linked Issues or Issue Description **Issue type** Missing ownership for repository review guidance. **Where is the issue?** `.github/CODEOWNERS`, for the root `.greptile/` and `rules/` directories. **What's wrong?** Changes to these directories do not match an explicit code owner entry. **Suggested fix** Assign the existing four infrastructure maintainers to both directory trees. Refs: #16172. ## What Changed - Add `/.greptile/` and `/rules/` to CODEOWNERS. - Assign `@cryppadotta`, `@devinfoley`, `@nickyleach`, and `@forgottendev` to both entries. ## Verification - `git diff --check`: passed. - GitHub CODEOWNERS validation API on the published branch: zero errors. - Confirm both entries have the same owner list as the existing `.github/**` entry: passed. - Check root and nested paths with Git's ignore-pattern matching: five positive and three negative cases passed. - Application typecheck, tests, and build were not run locally. This changes only CODEOWNERS and does not change application behavior. - Hosted CI and Greptile review are pending. ## Risks Low risk. Future changes in these root directory trees will request review from the existing maintainers. Nested directories with the same names elsewhere in the repository do not match the new entries. ## Model Used OpenAI Codex based on GPT-6. The exact runtime model ID and context-window size are not exposed in this session. Used reasoning, repository inspection, official GitHub documentation, and shell verification. ## 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 (ownership and pattern checks above; application suites were not run) - [x] I have added or updated tests where applicable (direct checks for this ownership-only change) - [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 Co-authored-by: Paperclip --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1fb71f5dfe0..8687af5aa1b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,6 +10,10 @@ doc/PUBLISHING.md @cryppadotta @devinfoley @nickyleach @forgottendev doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley @nickyleach @forgottendev skills/** @cryppadotta @devinfoley @nickyleach @forgottendev +# Review configuration and repository rules +/.greptile/ @cryppadotta @devinfoley @nickyleach @forgottendev +/rules/ @cryppadotta @devinfoley @nickyleach @forgottendev + # Package files — dependency changes require review # package.json matches recursively at all depths (covers root + all workspaces) package.json @cryppadotta @devinfoley @nickyleach @forgottendev From 3f7b00a8d03ac8d2c8b633d68a3a98bb26ee24ce Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:10:37 -0500 Subject: [PATCH 12/85] fix: preserve public identifiers in feedback redaction (#16168) 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. > - People rate agent comments and documents to capture feedback. > - Feedback bundles remove credentials and personal information before export. > - The feedback scanner treats ordinary dotted text as JWTs and authentication terms as bearer tokens. > - This pull request checks encoded JWT headers and preserves known authentication terms. > - The benefit is readable feedback context with credential masking kept in place. ## Linked Issues or Issue Description Refs #12309 for the related dotted-identifier false-positive report. This PR fixes the separate feedback scanner. It does not close every case in that report. Related PR: #14318 adds fine-grained GitHub token coverage. It addresses a separate credential form and is not duplicated here. **What happened?** Feedback exports replace `api.openai.com`, dotted filenames, and public schema IDs with a JWT placeholder. They also replace the words after `bearer` in phrases such as “bearer tokens.” Five-part encrypted JWTs are only partly masked. **Expected behavior** Keep public identifiers and authentication terms readable. Mask complete credential tokens. Count only text replacements in the redaction summary. **Steps to reproduce** 1. Pass `api.openai.com` or `Use bearer tokens.` to `sanitizeFeedbackText`. 2. Check that the original text is returned and the redaction counts stay empty. 3. Pass a signed JWT or an encrypted JWT to the same function. 4. Check that the full token is replaced and one JWT replacement is counted. **Paperclip version or commit** Reproduced in the feedback scanner on master at `5e8e992df2b3d71f82091b7bbf29fd482854d9f5`. **Deployment mode** Built from source. The defect is in the shared feedback export service and does not depend on deployment mode. ## What Changed - Use the existing JWT header predicate in the feedback scanner. - Match complete three-part and five-part tokens, including direct-encryption tokens with an empty key segment. Accept short token segments in the predicate. - Preserve the closed set of authentication terms already used by the general scanner, including terms inside closing prose delimiters. - Count only replacements that change the text. - Add tests for readable context, credential masking, nested values, and redaction counts. - Document feedback text redaction. ## Verification - Focused redaction and feedback service tests: 89 tests pass across five suites. - `pnpm --filter @paperclipai/adapter-utils typecheck` passes. Full local typecheck, tests, and build were attempted. The kernel killed compiler and test processes at the sandbox memory limit of 4 GB. Serial retries and a capped Node heap still could not complete the server compiler. CI runs the full repository checks. - `git diff --check` passes. - Latest commit `0eb8d3fb4257bb79e67fab615a8299ef69230ad8`: **55 checks passed**, including full typecheck, tests, build, and E2E; two optional Storybook checks were skipped. [CI evidence](https://github.com/paperclipai/paperclip/actions/runs/38407932875). Both Greptile findings have regression tests and fixes. [Greptile gives this commit 5/5](https://github.com/paperclipai/paperclip/pull/16168#issuecomment-6146917423), with both review threads resolved. ## Risks - This changes the feedback export matching rules. Public dotted text no longer triggers credential masking by shape alone. - Explicit credential assignments, provider keys, structured secret fields, email addresses, and phone numbers remain masked. Tests cover these cases. - Existing feedback snapshots are not rewritten. No schema change or migration is needed. - The encoded-header check remains a heuristic. It is not a full JWT validator. ## Model Used OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The runtime identifies the model as GPT-6. It does not expose the exact provider deployment ID or context window size. The earlier patch and this PR preparation were AI-assisted. ## 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 --- docs/feedback-voting.md | 8 ++ .../src/command-redaction.test.ts | 31 ++++++ .../adapter-utils/src/command-redaction.ts | 7 +- .../src/__tests__/feedback-redaction.test.ts | 97 +++++++++++++++++++ server/src/services/feedback-redaction.ts | 21 ++-- 5 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/feedback-redaction.test.ts diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index b9d40db7ad5..5dea89ea127 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -110,6 +110,14 @@ feedback-export-20260331T120000Z.zip Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files. +Feedback text redaction preserves hostnames, dotted filenames, public schema IDs, +and authentication terms such as “bearer tokens.” Dotted text must have an encoded +JWT header to be masked as a token. Complete three-part signed tokens and +five-part encrypted tokens are masked, including tokens with short segments +and direct-encryption tokens with an empty encrypted-key segment. +Explicit credentials, secret fields, email addresses, and phone numbers remain +masked. The redaction summary counts text that was actually replaced. + ```bash # Custom server and output directory npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id --out ./my-export diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 4af35d09dff..321866bf7c7 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -11,10 +11,41 @@ describe("explicit diagnostic credential forms", () => { }); import { REDACTED_COMMAND_TEXT_VALUE, + looksLikeCredentialJwt, redactDiagnosticText, redactCommandText, } from "./command-redaction.js"; +describe("looksLikeCredentialJwt", () => { + it("recognizes encoded headers even with short payloads and signatures", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}.e30.c2ln`)).toBe(true); + }); + + it("recognizes direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}..abcdefghijk.abcdefghijkl.abcdefghijk`)).toBe(true); + }); + + it.each([ + "eyJ..signature", + "eyJ.key..ciphertext.tag", + "eyJ.key.iv..tag", + "eyJ.key.iv.ciphertext.", + ])("rejects empty segments outside the JWE encrypted key: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); + + it.each([ + "api.openai.com", + "agents.staging.example.com", + "deployment.credentials.example.md", + "paperclip.native-model-envelope.v3", + ])("rejects public dotted identifiers: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); +}); + describe("redactDiagnosticText", () => { it("preserves credential metadata and dotted identifiers", () => { for (const text of [ diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 465255caf47..46eeb6f5040 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -36,7 +36,12 @@ const COMMAND_JWT_RE = /** Recognize encoded JSON headers, without treating dotted identifiers as tokens. */ export function looksLikeCredentialJwt(value: string): boolean { const segments = value.split("."); - if (![3, 5].includes(segments.length) || segments.some((part) => !/^[A-Za-z0-9_-]{8,}$/.test(part))) return false; + if (![3, 5].includes(segments.length)) return false; + if (segments.some((part, index) => { + // Compact JWE direct encryption has no encrypted-key value. + if (segments.length === 5 && index === 1 && part === "") return false; + return !/^[A-Za-z0-9_-]+$/.test(part); + })) return false; if (segments[0].startsWith("eyJ")) return true; try { const header = JSON.parse(atob(segments[0].replace(/-/g, "+").replace(/_/g, "/"))); diff --git a/server/src/__tests__/feedback-redaction.test.ts b/server/src/__tests__/feedback-redaction.test.ts new file mode 100644 index 00000000000..868cbb399a7 --- /dev/null +++ b/server/src/__tests__/feedback-redaction.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + createFeedbackRedactionState, + finalizeFeedbackRedactionSummary, + sanitizeFeedbackText, + sanitizeFeedbackValue, +} from "../services/feedback-redaction.js"; + +describe("feedback redaction", () => { + it.each([ + "https://agents.staging.example.com/team/chats/assistant", + "api.openai.com and deployment.credentials.example.md", + "paperclip.native-model-envelope.v3 and executor.coreTools.integrations.list", + "Use bearer tokens for authentication and bearer authentication for requests.", + "Document bearer authorization, bearer credentials, bearer schemes, and bearer flows.", + "Use (bearer tokens). Preserve [bearer authentication] and {bearer credentials}.", + ])("preserves non-secret chat context: %s", (input) => { + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(input, state, "body", 10_000)).toBe(input); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual([]); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({}); + }); + + it("redacts JWTs without counting neighboring hostnames as credentials", () => { + const header = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Visit api.openai.com; token ${jwt}`, state, "body", 10_000)) + .toBe("Visit api.openai.com; token [REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts a JWT with a short payload and signature", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + const state = createFeedbackRedactionState(); + const jwt = `${header}.e30.c2ln`; + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts encrypted JWTs", () => { + const header = Buffer.from('{"alg":"RSA-OAEP","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}..abcdefghijk.abcdefghijkl.abcdefghijk`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Token ${jwt}; visit api.openai.com.`, state, "body", 10_000)) + .toBe("Token [REDACTED_JWT]; visit api.openai.com."); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts explicit credentials and personal information", () => { + const state = createFeedbackRedactionState(); + const input = [ + "api_key=opaque-value", + "Bearer abcdefghijklmnop", + "ghp_abcdefghijklmnopqrstuv", + "sk-abcdefghijklmnopqrstuv", + "postgresql://admin:password@example.com/db", + "person@example.com", + "+1 (415) 555-1234", + ].join("\n"); + const output = sanitizeFeedbackText(input, state, "body", 10_000); + + for (const value of input.split("\n")) expect(output).not.toContain(value); + expect(output).toContain("Bearer [REDACTED_TOKEN]"); + expect(output).toContain("[REDACTED_EMAIL]"); + expect(output).toContain("[REDACTED_PHONE]"); + }); + + it("preserves nested metadata while masking structured secrets", () => { + const state = createFeedbackRedactionState(); + const input = { + body: "Visit api.openai.com and use bearer tokens.", + tokenPolicy: "least privilege", + nested: [{ apiKey: "opaque-value", path: "deployment.credentials.example.md" }], + }; + + expect(sanitizeFeedbackValue(input, state, "bundle", 10_000)).toEqual({ + ...input, + nested: [{ apiKey: "***REDACTED***", path: "deployment.credentials.example.md" }], + }); + }); +}); diff --git a/server/src/services/feedback-redaction.ts b/server/src/services/feedback-redaction.ts index 7c0ed097e2b..e57341407d5 100644 --- a/server/src/services/feedback-redaction.ts +++ b/server/src/services/feedback-redaction.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { looksLikeCredentialJwt } from "@paperclipai/adapter-utils/command-redaction"; import { redactCurrentUserText } from "../log-redaction.js"; import { sanitizeRecord } from "../redaction.js"; @@ -34,7 +35,7 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "bearer_token", - regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + regex: /\bBearer\s+(?!(?:tokens?|authentication|authorization|credentials?|schemes?|flows?)[.,;:!?]?(?:\s|[)\]}]|$))[A-Za-z0-9._~+/-]+=*/gi, replacement: "Bearer [REDACTED_TOKEN]", }, { @@ -49,8 +50,8 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "jwt", - regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g, - replacement: "[REDACTED_JWT]", + regex: /\b[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, + replacement: (match) => looksLikeCredentialJwt(match) ? "[REDACTED_JWT]" : match, }, { kind: "dsn", @@ -84,12 +85,14 @@ function recordField(state: FeedbackRedactionState, fieldPath: string) { } function applyPattern(input: string, pattern: RedactionPattern) { - const matches = Array.from(input.matchAll(pattern.regex)).length; - if (matches === 0) { - pattern.regex.lastIndex = 0; - return { output: input, matches: 0 }; - } - const output = input.replace(pattern.regex, pattern.replacement as never); + let matches = 0; + const output = input.replace(pattern.regex, (match: string, ...args: string[]) => { + const replacement = typeof pattern.replacement === "function" + ? pattern.replacement(match, ...args) + : pattern.replacement; + if (replacement !== match) matches += 1; + return replacement; + }); pattern.regex.lastIndex = 0; return { output, matches }; } From be380ba15574bd9bcb877174477444f1a48a702d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:26:37 -0500 Subject: [PATCH 13/85] fix(ui): hide agent-created tasks by default (#16157) Hide agent-created, system-created, and chat-channel tasks from independent collections by default. Preserve task hierarchy context and existing inbox participation. Add a saved opt-in checkbox without helper text. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 20 ++ doc/SPEC-implementation.md | 20 ++ maps/agent-access-map/inbox-search.md | 8 + maps/agent-access-map/tasks.md | 11 + maps/feature-map/inbox-search.md | 2 + maps/feature-map/tasks.md | 2 + maps/storybook-map/INDEX.md | 101 ++++----- maps/storybook-map/catalog.json | 211 +++++++++--------- packages/shared/src/index.ts | 2 + .../shared/src/issue-creation-source.test.ts | 20 ++ packages/shared/src/issue-creation-source.ts | 17 ++ .../issues-creation-visibility.test.ts | 77 +++++++ server/src/routes/issues.ts | 16 ++ server/src/services/issues.ts | 25 +++ ui/src/api/issues.ts | 5 + ui/src/components/BlockedInboxView.tsx | 7 +- .../components/IssueFiltersPopover.test.tsx | 7 +- ui/src/components/IssueFiltersPopover.tsx | 12 +- ui/src/components/IssuesList.test.tsx | 92 ++++++-- ui/src/components/IssuesList.tsx | 91 +++++--- ui/src/components/LegacyIssuesList.tsx | 121 ++++++---- ui/src/hooks/useIssueHierarchyContext.ts | 50 +++++ ui/src/hooks/useShowAgentCreatedTasks.ts | 30 +++ ui/src/lib/inbox.test.ts | 28 +-- ui/src/lib/inbox.ts | 16 +- ui/src/lib/issue-filters.test.ts | 17 ++ ui/src/lib/issue-filters.ts | 18 +- ui/src/lib/issue-hierarchy-context.test.ts | 50 +++++ ui/src/lib/issue-hierarchy-context.ts | 64 ++++++ ui/src/lib/task-collection-preferences.ts | 5 + ui/src/pages/Inbox.tsx | 14 +- ui/src/pages/Issues.tsx | 6 +- ui/src/pages/LegacyInbox.tsx | 14 +- ui/src/pages/ProjectDetail.tsx | 7 +- ui/storybook/generated/feature-index.json | 7 +- .../tasks/external-objects.stories.tsx | 2 +- .../tasks/issue-management.stories.tsx | 22 +- .../support/issue-creation-visibility.tsx | 103 +++++++++ 38 files changed, 1012 insertions(+), 308 deletions(-) create mode 100644 packages/shared/src/issue-creation-source.test.ts create mode 100644 packages/shared/src/issue-creation-source.ts create mode 100644 server/src/__tests__/issues-creation-visibility.test.ts create mode 100644 ui/src/hooks/useIssueHierarchyContext.ts create mode 100644 ui/src/hooks/useShowAgentCreatedTasks.ts create mode 100644 ui/src/lib/issue-hierarchy-context.test.ts create mode 100644 ui/src/lib/issue-hierarchy-context.ts create mode 100644 ui/storybook/support/issue-creation-visibility.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index c95adef49b7..6d408844d3c 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -72,6 +72,26 @@ the latest agent that assigned it to them. If no assignment is available, it use the latest other commenter, or leaves the recipient empty. A manual recipient choice stays selected while task data refreshes. +### Task collection creation filters + +Tasks and project lists hide agent-created and system-created tasks by default. +The Filters menu's **show agent-created tasks** checkbox includes routine runs, +recovery/watchdog tasks, chat-channel tasks, and other automation. Channel tasks +remain bot-created for this filter even when a human starts the conversation. The choice is saved per company +and collection; Clear filters restores the hidden default. Nested lists preserve +ancestors and expandable subtasks as context without promoting unrelated automated +work to independent results. Agent profiles, routine histories, and task-detail +subtasks retain their full history. + +Inbox membership continues to follow human creation, assignment, comments, and +existing qualifying participation actions, including manually running a routine. +Routine ownership or responsibility alone does not subscribe a person to scheduled +runs; opening or reading a task is not participation. Inbox discovery searches and +Blocked hide unrelated automated tasks unless the checkbox is checked, while +preserving tasks admitted by the existing participation rules. Checking it does +not subscribe the user to all automation. Approvals and other non-task items keep +their existing rules. + ### Company Skills and Policy Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index bd4b0738ec2..f32064cb813 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2187,3 +2187,23 @@ See [the layer architecture](plans/2026-10-09-task-workspaces.md) for resolution ## Company fast-response service Companies may configure an optional shared API connection and model for short acknowledgements of accepted human turns. Fast responses use direct SDK inference alongside normal agent work, with a three-second admission deadline, bounded prompts/output, current authorization and independent `fast_response` accounting. They are server-marked platform receipts attributed to the agent, never completion, interaction or recovery evidence. Company-sponsored unlinked external senders are explicit; linked-user permission denials are never bypassed. See [fast-responses.md](fast-responses.md) for provider, delivery, history and endpoint contracts. + +### Task collection creation visibility (2026-10-13) + +Tasks and project collections default to hiding agent/system-created tasks and +provide a saved **show agent-created tasks** filter. Classify creation using +creator identity and origin; assignee/responsible users do not imply human +authorship. Routine executions remain automated even when manually triggered. +Creator-less ordinary tasks are system-created. All chat-channel tasks count as +bot-created for this filter, including human-initiated inbound conversations. +Human ordinary and onboarding tasks remain visible. Apply collection creation filters before search limits and +pagination, and retrieve hierarchy context separately under existing access checks. +Context does not count as independent results or board cards. Bounded context +reads retain loaded rows while users reveal more of the tree. + +Inbox membership, archives, unread state, and badges retain existing participation +rules, including assignment, comments, and manual routine runs. Responsibility and +read-only activity do not admit scheduled work. Inbox discovery/Blocked preserve +participated automation and hide unrelated automation by default. Agent profile +history, routine history, and task-detail subtasks remain complete. API consumers +that omit the optional creation filter retain their existing list behavior. diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md index 60e3265de7a..d51d0d989dd 100644 --- a/maps/agent-access-map/inbox-search.md +++ b/maps/agent-access-map/inbox-search.md @@ -11,6 +11,14 @@ for format, permission checks, evidence and maintenance. ## `views` +The issue-list API accepts optional `includeAgentCreatedTasks`; false restricts +independent results to human creation before search/pagination. Omission retains +existing agent/API behavior. `includeParticipatedTasks=true` requires a board user +and retains only that authenticated user's existing participation as a creation +visibility exception. Company/private-task access checks still apply. Collection +preferences and hierarchy presentation are browser state, not additional authority. +Source-reviewed behavior; live agent access verification remains separate. + Review: `baseline`. API: `present` — representative operation IDs: diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index cf040965c7f..c2148348330 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -37,6 +37,17 @@ narrower/different APIs. ## `organize` +The issue-list API accepts optional `includeAgentCreatedTasks`; false restricts +independent results to human creation before search/pagination. Omission retains +existing agent/API behavior. `includeParticipatedTasks=true` requires a board user +and retains only that authenticated user's existing participation as a creation +visibility exception. Company/private-task access checks still apply. Collection +preferences and hierarchy presentation are browser state, not additional authority. +Source-reviewed behavior; live agent access verification remains separate. + +All `chat_channel` origins count as bot-created for creation visibility, including +human-initiated conversations. Existing inbox participation remains independent. + Review: `baseline`. API: `present` — representative operation IDs: diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 687ec84534b..4c5b60696b9 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -46,6 +46,8 @@ cover exclusion of historical automatic read cleanup from user participation, unread counts, and activity timestamps while retaining explicit user decisions and audit history. +Manual: Seed a scheduled routine execution owned by the current human. It stays out of Mine until an existing participation rule admits it. Verify comments, assignment, and manual routine runs admit automated tasks while reading alone does not. Inbox discovery and Blocked hide unrelated automation by default; the **show agent-created tasks** checkbox reveals it without subscribing the user. Reload and verify membership, unread badges, archive/resurfacing, and preference persistence. + Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation. Seed a completed task whose only activity under the viewing user is automatic diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 6ed9fd7f353..b1fce19b313 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -49,6 +49,8 @@ Use a human/unassigned task to verify persistence without invoking a model. Automated: [task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, deduplication, and presentation constants. The suite does not render the page or exercise filtering, grouping, opening a task, and returning; those interactions still need a running-browser check. +Manual: Seed human-created, agent-created, scheduled routine, human-initiated chat-channel, and creator-less tasks. Confirm automation is hidden by default, **show agent-created tasks** reveals it, reload preserves the choice, and Clear filters hides it again. Verify list and board search with automation newer than the human results, and expand contextual ancestors/subtasks without promoting unrelated automated roots. Repeat in project task lists. Agent profiles and task-detail subtasks retain complete history. + Manual: Filter by status and assignee, change grouping/sort, open a task, and return. Verify the correct records and selected view survive navigation and reload. Include an empty result and a failed request. ### `task-detail` diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index b64c3cb40df..ec3a4c08b5a 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -4,7 +4,7 @@ [Contract and maintenance](README.md) · [Review findings](REVIEW.md) -223 story files; 2212 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. +223 story files; 2215 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file. @@ -30,14 +30,14 @@ Sessions, bootstrap, membership, roles, and CLI authorization. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 22 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 26 / 10 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 11 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 12 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 19 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 21 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Getting started and access/Login, invitations, and access/Private tasks/00 Review guide](../../ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/01 Creation](../../ui/storybook/stories/access/private-tasks/01-creation.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 22 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/02 Sharing](../../ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 26 / 10 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/03 Task actions](../../ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 11 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers](../../ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 12 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/05 Projects](../../ui/storybook/stories/access/private-tasks/05-projects.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 19 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages](../../ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx) | `access/resources`, `tasks/properties`, `projects/lifecycle` | unreviewed / production / fixtures | 21 / 12 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Getting started and access/Login, invitations, and access/Private tasks/07 User stories](../../ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx) | `access/resources`, `tasks/properties` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | ### Companies and portability @@ -63,27 +63,27 @@ Creation, assignment, lists, properties, comments, and completion. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 14 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) | `tasks/create`, `runs-adapters/availability` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) | `tasks/create`, `runs-adapters/availability` | mixed / mixed / fixtures | 57 / 30 | Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) | `tasks/organize`, `questions-and-approvals/governance`, `budgets-costs/budgets`, `activity/activity` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) | `tasks/create`, `agents/hire`, `projects/lifecycle`, `goals/hierarchy` | unreviewed / production / fixtures | 17 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) | `tasks/properties` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) | `tasks/properties`, `tasks/organize` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create`, `workspaces/binding` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) | `tasks/discussion` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments](../../ui/storybook/stories/tasks/chat-comments.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview](../../ui/storybook/stories/tasks/completed-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Add menu](../../ui/storybook/stories/tasks/composer-add-menu.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 14 / 14 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker](../../ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx) | `tasks/create`, `runs-adapters/availability` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker](../../ui/storybook/stories/tasks/composer-model-picker.stories.tsx) | `tasks/create`, `runs-adapters/availability` | mixed / mixed / fixtures | 57 / 30 | Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces](../../ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx) | `tasks/organize`, `questions-and-approvals/governance`, `budgets-costs/budgets`, `activity/activity` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals](../../ui/storybook/stories/tasks/dialogs-modals.stories.tsx) | `tasks/create`, `agents/hire`, `projects/lifecycle`, `goals/hierarchy` | unreviewed / production / fixtures | 17 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/External Objects](../../ui/storybook/stories/tasks/external-objects.stories.tsx) | `tasks/properties` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Product/Issue Management](../../ui/storybook/stories/tasks/issue-management.stories.tsx) | `tasks/properties`, `tasks/organize` | unreviewed / production / fixtures | 11 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Focused creation-visibility stories mount the production collection with simulated hierarchy reads and assert hidden roots, opt-in inclusion, and the unchecked filter control. Creation visibility examples and fixture setup now live in a support module so the surface matrix stays below 1000 lines; exported story IDs are unchanged. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile](../../ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx) | `tasks/create` | unreviewed / production / fixtures | 3 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Composer/New task](../../ui/storybook/stories/tasks/new-task-composer.stories.tsx) | `tasks/create`, `workspaces/binding` | current / production / fixtures | 40 / 25 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview](../../ui/storybook/stories/tasks/runner-activity.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | current / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) | `tasks/completion`, `recovery/diagnosis` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes](../../ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 4 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow](../../ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice](../../ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Tasks/Context notices](../../ui/storybook/stories/tasks/task-context-notices.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx) | `tasks/discussion` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx) | `tasks/discussion` | unreviewed / production / fixtures | 5 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | ### Delegation, dependencies, and signoff @@ -95,23 +95,23 @@ Child work, prerequisites, reviewers, and task-tree controls. | --- | --- | --- | --- | --- | | [Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions](../../ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx) | `task-coordination/signoff` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces](../../ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) | `task-coordination/delegation`, `projects/task-context` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | -| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) | `task-coordination/dependencies`, `task-coordination/delegation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task](../../ui/storybook/stories/task-coordination/originating-tasks.stories.tsx) | `task-coordination/delegation`, `projects/task-context` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. | +| [Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx) | `task-coordination/dependencies`, `task-coordination/delegation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces. | | [Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces](../../ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx) | `task-coordination/tree-control` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Inbox, decisions, and search -Personal triage, decision queues, unread/blocked views, and search. +Personal triage, decision queues, unread/blocked views, search, and personal task toasts. [Feature recipe](../feature-map/inbox-search.md) | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) | `inbox-search/triage`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) | `inbox-search/search` | unreviewed / prototype / fixtures | 1 / 0 | Local search-page composition mounts production result components; does not establish parity with the production Search route. | -| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) | `inbox-search/triage`, `recovery/diagnosis` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) | `inbox-search/search` | unreviewed / prototype / fixtures | 1 / 0 | Local search-page composition mounts production result components; does not establish parity with the production Search route. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) | `inbox-search/decisions` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | ### Agent conversations and project handoff @@ -141,7 +141,7 @@ Question/plan responses, formal approvals, and app-tool review. | [Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience`, `questions-and-approvals/plan-review`, `questions-and-approvals/continuation` | unreviewed / production / fixtures | 53 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Plan approval Pending, Confirmed, ExpiredByTargetChange and ResumeFailed states use shipped interaction cards. The continuation association covers accepted-but-resume-failed presentation only; no successful resumption, retry or duplicate-suppression proof. | | [Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | unreviewed / production / fixtures | 12 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Questions and approvals/Composer/Interaction above composer](../../ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx) | `questions-and-approvals/questions`, `questions-and-approvals/confirmations`, `questions-and-approvals/audience` | current / production / fixtures | 15 / 5 | Corrected local dismissal/send harness: questions leave no pending badge; confirmations retain reopen. Production cards/composer; persisted dismissal is covered separately by unanswered-question fixtures. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. | -| [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `tasks/discussion` | unreviewed / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx) | `questions-and-approvals/dismiss-and-reopen`, `tasks/discussion` | unreviewed / production / fixtures | 6 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | ### Steering and queued messages @@ -206,14 +206,14 @@ Agent identity, instructions, reporting lines, lifecycle, and built-ins. | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components](../../ui/storybook/stories/agents/external-agent-invite/components.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx) | `agents/hire`, `access/invitations` | unreviewed / production / fixtures | 20 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent](../../ui/storybook/stories/agents/new-agent-onboarding.stories.tsx) | `agents/hire` | historical / prototype / fixtures | 37 / 1 | Retained local wizard predates production ui/src/components/new-agent flow; shared fields do not make the wizard current. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | -| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 12 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 14 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 11 / 11 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) | `agents/primary-agent`, `tasks/create`, `agent-chat/discovery` | unreviewed / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | | [Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard](../../ui/storybook/stories/agents/summary-slot-card.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 15 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | ### Runs, harnesses, and model accounts @@ -303,9 +303,9 @@ Provisioning, task bindings, services/logs, Git/files, and closure. | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work. | | [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | | [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | | [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. | @@ -604,6 +604,7 @@ References are navigation links, not proof of parity. An absent link means no ca - `inbox-search/triage`: [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed) - `inbox-search/decisions`: [Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard](../../ui/storybook/stories/inbox-search/decision-card.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk](../../ui/storybook/stories/inbox-search/decisions-desk.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Pages/Decisions](../../ui/storybook/stories/inbox-search/what-needs-me.stories.tsx) (unreviewed) - `inbox-search/search`: [Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K](../../ui/storybook/stories/inbox-search/search.stories.tsx) (unreviewed) +- `inbox-search/toasts`: No cataloged story. - `agent-chat/discovery`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide](../../ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components](../../ui/storybook/stories/agents/primary-agent/01-components.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar](../../ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents](../../ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task](../../ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat](../../ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys](../../ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx) (unreviewed), [Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation](../../ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx) (unreviewed) - `agent-chat/history`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx) (unreviewed), [Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx) (current), [Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry](../../ui/storybook/stories/agent-chat/chat-entry.stories.tsx) (historical) - `agent-chat/handoff`: [Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat](../../ui/storybook/stories/agent-chat/agent-chat.stories.tsx) (mixed) diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 5e947c7b55c..1c81c9a3adf 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -10,7 +10,7 @@ "ui/storybook/generated/feature-index.json", "ui/storybook/support/feature-map/FeatureOverview.tsx" ], - "fingerprint": "451bee6238c89f00e77fa9fdc5f86c621afffdf9809c4a9ac9cbf1f3f4f60b78" + "fingerprint": "6bdb0d9cc1f9abb1d9b1c1d7b4652ee4f4cf5c9c5d939069dfabef70ed731df3" }, "entries": [ { @@ -1185,7 +1185,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", @@ -1196,7 +1196,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx" ], - "fingerprint": "c605c2eac926e4d0765a6289c22ebba5901de3d536b347f20a8cd293fc9142bc", + "fingerprint": "cdb63642517b31c68df9ca8d62e60039f9f4d69d95d1a96dc96235fb7db1487d", "stableId": "paperclip-assigned-backlog-safeguards", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards" }, @@ -1210,8 +1210,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/inbox-search.md", "maps/feature-map/recovery.md", @@ -1220,7 +1220,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx" ], - "fingerprint": "b6f5f4b7c00e48da7320bef20e5904527f83665e5fdac4fbb44c866e15547502", + "fingerprint": "bcb6c0a3cb34fcd06e7b056521ad84fb968f73ba46a7ba00eda2a86bb060b11b", "stableId": "product-inbox-blocked-tab", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab" }, @@ -1234,7 +1234,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1245,7 +1245,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-activity.stories.tsx" ], - "fingerprint": "80aa360434184867ea46f1e508d3e97c29a6958f991c40c1866e10e5c16af1b1", + "fingerprint": "f0e77b99e99a69532f45d58546a2324893543911771e850a47ca3452818bcf3d", "stableId": "tasks-browser-use-task-activity", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity" }, @@ -1259,7 +1259,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1269,7 +1269,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-footer.stories.tsx" ], - "fingerprint": "ba83c5b16100c735177b19029c587dcd79de2ab537bcaee130326058963bb2dd", + "fingerprint": "b60b13c6ff934b038616eee7da6aeeaac0f595aeb7c8d80d2a2f787c7bb0dbac", "stableId": "tasks-browser-use-footer", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer" }, @@ -1305,7 +1305,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1316,7 +1316,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/workspaces/browser-use.stories.tsx" ], - "fingerprint": "42889268f5e8c23c9ac252a8eddb6b839f05a47594484122a41941c6da7636d0", + "fingerprint": "7dea88462587a256933271c48cfef7ff339f55acd1f77a38bf89ad537c63f3ae", "stableId": "tasks-browser-use-panel", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel" }, @@ -1387,7 +1387,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -1401,7 +1401,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/chat-comments.stories.tsx" ], - "fingerprint": "9375c8226472f5e94b44c7e5dbe6046d1a01f70e17ead05811d08e6636639d17", + "fingerprint": "af6b855e4223073858c00f3d39b294222d32a7eb4f9c780cb3a1259a4c3edbdb", "stableId": "product-chat-comments", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments" }, @@ -1519,7 +1519,7 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -1529,7 +1529,7 @@ "ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx", "ui/storybook/stories/tasks/completed-activity.stories.tsx" ], - "fingerprint": "5a7cb4c48683bde333499fce04c3a0b983f444596ed68ab771b15fc910f05f25", + "fingerprint": "d6a288300082825b36c483a4c1e1dda3ca7ecf4ba7bada065d88ad8f2098c869", "stableId": "tasks-completed-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview" }, @@ -1542,7 +1542,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -1552,7 +1552,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-add-menu.stories.tsx" ], - "fingerprint": "e7a0a6c477048bb9410db5fa29552b6045a4a02520c4437ef0062141a677a134", + "fingerprint": "a3d8d5ddf558df4e5118c747bb1c42ef1d43b23202200614a96c7a4c5ab47e91", "stableId": "composer-add-menu", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Add menu" }, @@ -1566,7 +1566,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -1579,7 +1579,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx" ], - "fingerprint": "5d8ab0bf43155faaf7a3791409cff9d2afc0a382feaac92c50916c9b9c8e9201", + "fingerprint": "62cb4bb214ab3848b9008c2456293027e86bb5bc55bda9c8671a5ea972555ee8", "stableId": "composer-mobile-assignee-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker" }, @@ -1593,7 +1593,7 @@ "status": "mixed", "implementation": "mixed", "boundary": "fixtures", - "notes": "Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Most examples use a local picker implementation; live picker examples use ComposerRunSettingsLiveStory. Do not count the whole suite as production parity. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -1607,7 +1607,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-model-picker.stories.tsx" ], - "fingerprint": "28f590da6e27168049fb39edec3b0d3a8344d5afc6921d622f22a3a02677eccd", + "fingerprint": "6fdfb5463f7601772919b8c73e97b2571f5c13bf728390ecffc22a7438d67ce7", "stableId": "composer-model-and-effort-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker" }, @@ -1775,7 +1775,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/activity.md", @@ -1792,7 +1792,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx" ], - "fingerprint": "76878f4f9b53c94eac1e74fbb50b943b1f5359c3bddd119cc277bc8073bfe52a", + "fingerprint": "ad5842a0179d5b824f4b08170e82ce66fe3929eb93021af3606e7c9dd81ddc12", "stableId": "product-control-plane-surfaces", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces" }, @@ -1928,14 +1928,14 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/inbox-search.md", "ui/src/components/DecisionCard.tsx", "ui/storybook/stories/inbox-search/decision-card.stories.tsx" ], - "fingerprint": "220a4f037c1dd4a456ac7df75d6ee7fd0d844ff7db3fd30ca1da2fff9477cbc8", + "fingerprint": "172482180fa305e06238da1fa90a277dd1eb109354d8bfeb0b2b833b9aba798d", "stableId": "decisions-decisioncard", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard" }, @@ -2022,8 +2022,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/inbox-search.md", "ui/src/components/AttentionQueueRow.tsx", @@ -2031,7 +2031,7 @@ "ui/src/pages/WhatNeedsMe.tsx", "ui/storybook/stories/inbox-search/decisions-desk.stories.tsx" ], - "fingerprint": "1e49e931cddd7ef77de70f25fc4ee991d3de9d933670ae4412c5d17fad6db0aa", + "fingerprint": "4b39ccddf9d5ed2847fe0bd9f0078b183ffcaeb41b67121410909ef10a73e938", "stableId": "pages-decisions-desk", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk" }, @@ -2067,7 +2067,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agents.md", @@ -2085,7 +2085,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/dialogs-modals.stories.tsx" ], - "fingerprint": "75e4b0effdb3cd0c426dce00f908ca4657a03e2ffc365558d074858020ac5f23", + "fingerprint": "0106caca8a9ad499602499ab64ad96f7cbb9db6815195218775cc317ba7204ad", "stableId": "product-dialogs-modals", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals" }, @@ -2303,7 +2303,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -2318,7 +2318,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/external-objects.stories.tsx" ], - "fingerprint": "5484a9480224ce2e2fdc7376e582d26e6d049e6308a1705e45615594cc7db56a", + "fingerprint": "1a7a6fc735b582b7f0968b97d0159528834a499f6d090c0ec678a1092dc6e3ac", "stableId": "foundations-external-objects", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/External Objects" }, @@ -2883,7 +2883,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Focused creation-visibility stories mount the production collection with simulated hierarchy reads and assert hidden roots, opt-in inclusion, and the unchecked filter control. Creation visibility examples and fixture setup now live in a support module so the surface matrix stays below 1000 lines; exported story IDs are unchanged. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -2902,9 +2902,10 @@ "ui/src/components/PriorityIcon.tsx", "ui/src/components/StatusBadge.tsx", "ui/storybook/fixtures/paperclipData.ts", - "ui/storybook/stories/tasks/issue-management.stories.tsx" + "ui/storybook/stories/tasks/issue-management.stories.tsx", + "ui/storybook/support/issue-creation-visibility.tsx" ], - "fingerprint": "1c8ff1f940a366fabec4f2cb3f5bbf84e8d21be64bb301da65dcc39ca24c9faa", + "fingerprint": "554a97d096cb27b9418fa2dc5a3bc0cb853e8bafa128ef9abd7cf1c3df3a6bd5", "stableId": "product-issue-management", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Issue Management" }, @@ -3050,7 +3051,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -3058,7 +3059,7 @@ "ui/src/components/SearchableSelect.tsx", "ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx" ], - "fingerprint": "3707254c9f397fa663d1d51b0af5dbf1a811f34d9d4faf2dea5041116b92f1c8", + "fingerprint": "249524a570189f2b01ef83136346a0d1ee099e233e357aa92ffaecd52a376b32", "stableId": "components-entity-pickers-mobile", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile" }, @@ -3226,7 +3227,7 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -3236,7 +3237,7 @@ "ui/storybook/stories/tasks/new-task-composer.stories.tsx", "maps/feature-map/workspaces.md" ], - "fingerprint": "56ef4e67f8e6b51ab54a664336fae33a75aece2f56883861ce617d53145b5637", + "fingerprint": "db3b18dcf012cfd2174900456dd8f8189b2f0b4287d84fbb62562bb0325a0001", "stableId": "composer-new-task", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/New task" }, @@ -3344,7 +3345,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/projects.md", @@ -3357,7 +3358,7 @@ "ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx", "ui/storybook/stories/task-coordination/originating-tasks.stories.tsx" ], - "fingerprint": "2c1e0da025ec60281b4c8179180114d7eee259cf30a1d7216a21bbf476eb7228", + "fingerprint": "59123e73a0286fd11a48376ee152a974d730c59bd5421fd273cfca3a185f6e08", "stableId": "ux-labs-tasks-created-from-a-task", "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task" }, @@ -3378,7 +3379,7 @@ "ui/storybook/generated/feature-index.json" ], "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", - "fingerprint": "b4a1cb9cf763e78cfc632a5adf859c8827318de12eeb0cd359b4a5b8b838a730", + "fingerprint": "408bb736ebe6591fb5a397db6d8b3d3d0b37741a66ed2a7edc84781c67d5c6c7", "stableId": "overview-storybook-guide", "sidebarTitle": "Overview/Storybook Guide" }, @@ -3460,7 +3461,7 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3468,7 +3469,7 @@ "maps/feature-map/tasks.md", "ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx" ], - "fingerprint": "d54242a3d8d8096014dd54fe49ca0e86669894ff859d41ff0e50866315a412f4", + "fingerprint": "dc2d1c8a114ba4e6c065170fe2cd6b39c8b6b7dce3d7ae1afbfbddc15adf5beb", "stableId": "primary-agent-00-review-guide", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide" }, @@ -3483,7 +3484,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3503,7 +3504,7 @@ "ui/storybook/stories/agents/primary-agent/01-components.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "b1ae1d45a7e4a89f94a4a10343ddabd085cc758f0ceb17df43806722df874019", + "fingerprint": "1ba218400f92d323d120ad223d92a0ddeee6d104ee36f1873e110a3e0124a1bb", "stableId": "primary-agent-01-components", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components" }, @@ -3518,7 +3519,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3537,7 +3538,7 @@ "ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "a98ea7e6c56135bd030b43385bfdb2a5e12f5e78d18ed301c88ff12da07481a3", + "fingerprint": "cf72eaff02b96c8f81a285c96246ef80e9d97e836b47f8bb88734175d5d01664", "stableId": "primary-agent-02-sidebar", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar" }, @@ -3552,7 +3553,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3571,7 +3572,7 @@ "ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "2096810ef9ccf52eaf2811175604550302b314fc2e3d6bb951413a5505c3214b", + "fingerprint": "d8107859209157fdcd775b1e0161aa9b39fd79280b57d2bcd29e76afa1944c2a", "stableId": "primary-agent-03-agents", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents" }, @@ -3586,7 +3587,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3605,7 +3606,7 @@ "ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "2ab88c703c7376edd519dc413a562ab802998ae25cafc44925fdd00f48ceb7cb", + "fingerprint": "2e30847e4f8d957c11600f3663358e0eb555f2185cb85c4c1c1b4d57eba62f51", "stableId": "primary-agent-04-new-task", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task" }, @@ -3620,7 +3621,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3639,7 +3640,7 @@ "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "2b22fe59288fb0d6597f7122ac195038f3ed8d748c2ed80fba7e795576501cd3", + "fingerprint": "fa3248cdb686fb0827390fbabea3c2ed38950e5234141254a53756cd271b4903", "stableId": "primary-agent-05-chat", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat" }, @@ -3654,7 +3655,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3673,7 +3674,7 @@ "ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "67c35f4ec63e84d1ccaa4362e2e2289857ecfa583d412c8582168d169ca38a54", + "fingerprint": "68a196913fd72374066dcccf55430d742881a983be2d914de2e02fd98a825fd0", "stableId": "primary-agent-06-journeys", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys" }, @@ -3688,7 +3689,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Primary choice, replacement confirmation, unavailable states and task/chat fallback presentation use local presentation-provider callbacks. Production preference persistence and default-resolution precedence remain unverified by this suite. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/agent-chat.md", @@ -3707,7 +3708,7 @@ "ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx", "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" ], - "fingerprint": "6be424a8d962be786c09ef1d2c3b505e407c7b645367adabfc2f737d73c692c6", + "fingerprint": "0d65a5877c6ef741b40ddd938c8066608307879ed029baafe59e7248e482558b", "stableId": "primary-agent-07-confirmation", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation" }, @@ -3753,14 +3754,14 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", "maps/feature-map/tasks.md", "ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx" ], - "fingerprint": "ac5d14d14aa3eec4795ff687b25d1c4025abaa29bb890d45086f1e088b62232d", + "fingerprint": "dc46ad819f4c0d925acbd1170d8a9ee4018f2b49de0241a3b3d698f154ee9257", "stableId": "private-tasks-00-review-guide", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/00 Review guide" }, @@ -3774,7 +3775,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3792,7 +3793,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "8010910a61b5f20de41ead806ef1da41c09903b61d47db038fe31c2142960a1e", + "fingerprint": "d4b5ba6bce33a9657d859d3278b1f84bbc9b77cffd24e370214a0fce24c0a02b", "stableId": "private-tasks-01-creation", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/01 Creation" }, @@ -3806,7 +3807,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3818,7 +3819,7 @@ "ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "29ec80e59e9275be25933e1b87b9a49196af7bd946fb0bd9b21edea52ec58663", + "fingerprint": "6595d72c6af25ff292b389d88e8d2019020f4e4a6965cceb2a50b793ec03b882", "stableId": "private-tasks-02-sharing", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/02 Sharing" }, @@ -3832,7 +3833,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3844,7 +3845,7 @@ "ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "249e1e9b5b9f4ff6625734ab176fca3fdfe237238303f843403a506e529517a6", + "fingerprint": "be8143e492deda70c9139b7e9f7022ae6b3c3b7603791074c22adb72a54adb79", "stableId": "private-tasks-03-task-actions", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/03 Task actions" }, @@ -3858,7 +3859,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3875,7 +3876,7 @@ "ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "cb879fe3240a4d1e17e3198c07091d53af898f23c176bba2fc381e4b9bb41dea", + "fingerprint": "371547c4293f4f7e6e922113a9a6a2631bfe73979343fe70fc158b2e1fd3c5a4", "stableId": "private-tasks-04-references-and-blockers", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers" }, @@ -3890,7 +3891,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3905,7 +3906,7 @@ "ui/storybook/stories/access/private-tasks/05-projects.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "5bbc18f1689862e62cf534729dda878cc5ca9e9d8e19f23286fb19536206d77b", + "fingerprint": "8b773d099d8ae633ab72108c0a4dd8b2c032aac03787ab47cda05426f456fb15", "stableId": "private-tasks-05-projects", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/05 Projects" }, @@ -3920,7 +3921,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3938,7 +3939,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "2972bb8615897132cd38835f2a744a5348a53ab746db4749c9ea91877e9e2485", + "fingerprint": "5fb218d88c3b6815839732e4226a27f4b40a2df46c8cbad6709cb665546b4248", "stableId": "private-tasks-06-full-product-pages", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages" }, @@ -3952,7 +3953,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/access.md", @@ -3985,7 +3986,7 @@ "ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "024bd786a1800aed8756e66b876637280c00a51ce3372363aa74e79893a2913d", + "fingerprint": "5fbe5c61f8754bc0c1db1c6a990b9c44bc5d941b7b61de77fac76300e6c42a22", "stableId": "private-tasks-07-user-stories", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/07 User stories" }, @@ -4667,7 +4668,7 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -4682,7 +4683,7 @@ "ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx", "ui/storybook/stories/tasks/runner-activity.stories.tsx" ], - "fingerprint": "2f7782ae1961a4a8f02c7955edf2a44add21eb2fe241d717cd988ff48d0ca485", + "fingerprint": "99a90f98646e1e307ec6255c7f7bd6ce72958ba83f9968f6e0ae3863949b697a", "stableId": "tasks-runner-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview" }, @@ -4717,8 +4718,8 @@ "status": "unreviewed", "implementation": "prototype", "boundary": "fixtures", - "notes": "Local search-page composition mounts production result components; does not establish parity with the production Search route.", - "reviewedAt": "2026-10-12", + "notes": "Local search-page composition mounts production result components; does not establish parity with the production Search route. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/inbox-search.md", "ui/src/components/PageTabBar.tsx", @@ -4731,7 +4732,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/inbox-search/search.stories.tsx" ], - "fingerprint": "4ebfe41c086ce2e0ae674263dfa50239a0770e5e6f98c3c24f54955f6cbdd3d2", + "fingerprint": "a6661b568c8bc74f7667aeff4a5da84a05f507dd62679ee9039808e825eeef61", "stableId": "product-search-command-k", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K" }, @@ -5087,15 +5088,15 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/task-coordination.md", "ui/src/components/IssuesList.tsx", "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx" ], - "fingerprint": "f9ebab21b19cd4aed698c7f1b060c8c6e39788f8224b567d86bf0b4841d038b2", + "fingerprint": "4461d14e90da3e93b82826efdddf131ae014c61a040f33cfae1df1368c6ddb2d", "stableId": "ux-labs-sub-issues-workflow-checklist", "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist" }, @@ -5109,7 +5110,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", @@ -5121,7 +5122,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/successful-run-handoff.stories.tsx" ], - "fingerprint": "8cf0d6e52decc045151c65575984520ac882499baddbeb86ca10b42385863398", + "fingerprint": "2a7db108d46f35f37dd9346d2fc039e61dff8c688e09d9ed7b0e777e7f79af0d", "stableId": "paperclip-successful-run-handoff", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff" }, @@ -5180,7 +5181,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/runs-adapters.md", @@ -5192,7 +5193,7 @@ "ui/src/components/task-chat/task-chat-model.ts", "ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx" ], - "fingerprint": "cd4351774fa50e58fda2f249f2870b7aa376b1eb23a25a4e69280501ef0021d7", + "fingerprint": "4747486949d9bca41e7bed36d511eb523651bc1e23ac0427892ff9b47e29cd28", "stableId": "tasks-task-chat-review-fixes", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes" }, @@ -5234,7 +5235,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5245,7 +5246,7 @@ "ui/src/components/task-chat/useWindowAutoFollow.ts", "ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx" ], - "fingerprint": "988ca771ff638613760d91ef8151689f3cb4405135906472541915ebff39d165", + "fingerprint": "840c21389fca0bf13a1df0d247a9d63836d8f38f4ca95c04838ca0cf36b881d9", "stableId": "task-chat-mobile-streaming-follow", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow" }, @@ -5258,7 +5259,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5266,7 +5267,7 @@ "ui/src/components/task-chat/task-chat-model.ts", "ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx" ], - "fingerprint": "07910730b7ef3be833745377575d4abc41a61dd862428b6711031f1ca2f50eeb", + "fingerprint": "1dd5a86762602b618007a3ed83d0500074560bb9168e7f1873aab0aad555deef", "stableId": "product-task-chat-system-notice", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice" }, @@ -5280,7 +5281,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/questions-and-approvals.md", @@ -5290,7 +5291,7 @@ "ui/storybook/fixtures/unansweredQuestionChat.tsx", "ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx" ], - "fingerprint": "4fbe4667a042a8fcd8f96d7041d207bc115c7d79a8988196bc6bf18ecda456da", + "fingerprint": "2eb8af0c7b2ecf6914c9bf5d1351427309a5a3ce31b90124c82c23dc2b50bf54", "stableId": "chat-comments-task-chat-unanswered-questions", "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions" }, @@ -5303,7 +5304,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5312,7 +5313,7 @@ "ui/src/components/chat/ExternallyConnectedTaskBanner.tsx", "ui/storybook/stories/tasks/task-context-notices.stories.tsx" ], - "fingerprint": "737d40d84d6a42dda3366e41f9d6a829219e424c6e770d7e6bb59f2b530b2bfe", + "fingerprint": "10f395d8ecba84925abf8d843deb52af64356008bb96a03668f29870ac430bc8", "stableId": "tasks-context-notices", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Context notices" }, @@ -5414,7 +5415,7 @@ "status": "unreviewed", "implementation": "guide", "boundary": "static", - "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5422,7 +5423,7 @@ "ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "4e6ed831f9d6049b77a6bfa29da2bf60623f33d70a591f64a4f5c15b3fc38e11", + "fingerprint": "cb8b7d0dcdbd27bcaa16d2fcbc86e776b32db875bc6b4de3d752caa5aa9119da", "stableId": "chat-comments-human-identity-00-review-guide", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide" }, @@ -5435,7 +5436,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5446,7 +5447,7 @@ "ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "ba528b7605db60c2b739862d5f80cbdc1b4379c77587af41dc040fd1015d30f8", + "fingerprint": "d87a48d3be494149116ab8fd366e90c17aa2e23805fb1f0d896464e10ba35b04", "stableId": "chat-comments-human-identity-01-components", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components" }, @@ -5459,7 +5460,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/tasks.md", @@ -5469,7 +5470,7 @@ "ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx", "ui/storybook/support/thread-human-identity/fixtures.ts" ], - "fingerprint": "462c55f151c3b297851e351615f0f37afb87b93dd32caa6dfbb128289354f130", + "fingerprint": "68b3cca3a5ed9b116b34bd1dde6ca8012c350c2a7c2b60cc928d7e5b30da8694", "stableId": "chat-comments-human-identity-02-mixed-author-journey", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey" }, @@ -5528,8 +5529,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Creation-visibility verification recipes were updated; this story scenario is unchanged and does not prove that journey. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/inbox-search.md", "ui/src/components/AttentionQueueRow.tsx", @@ -5537,7 +5538,7 @@ "ui/src/components/ToastViewport.tsx", "ui/storybook/stories/inbox-search/what-needs-me.stories.tsx" ], - "fingerprint": "d8b838185489f6b5009e52d9c2ac3a1fea3b2718211a1ddc393c404a0335a466", + "fingerprint": "0920cb3c4e75ba896955144a41f714a18dc778ec63f7b3abfa7e08b1032c0e94", "stableId": "pages-decisions", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions" }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 352ee970915..b1655cf3169 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2879,3 +2879,5 @@ export * from "./validators/task-workspace.js"; export { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository } from "./validators/workspace-repository.js"; export * from "./fast-response.js"; + +export { HUMAN_TASK_ORIGIN_KINDS, isAgentCreatedIssue, type IssueCreationSourceFields } from "./issue-creation-source.js"; diff --git a/packages/shared/src/issue-creation-source.test.ts b/packages/shared/src/issue-creation-source.test.ts new file mode 100644 index 00000000000..afd00ce1ad8 --- /dev/null +++ b/packages/shared/src/issue-creation-source.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isAgentCreatedIssue } from "./issue-creation-source.js"; + +describe("task creation source", () => { + it.each(["manual", "onboarding_first_task"])("preserves human-created %s tasks", (originKind) => { + expect(isAgentCreatedIssue({ createdByUserId: "person", originKind })).toBe(false); + }); + it.each(["chat_channel", "routine_execution", "stranded_issue_recovery", "task_watchdog", "harness_liveness_escalation", "plugin:workflow:operation", "built_in_agent_bundle", "pipeline_automation"])("classifies %s as automated even with human attribution", (originKind) => { + expect(isAgentCreatedIssue({ createdByUserId: "person", originKind })).toBe(true); + }); + it.each([null, "person"])("classifies channel work as automated with creator %s", (createdByUserId) => { + expect(isAgentCreatedIssue({ originKind: "chat_channel", createdByUserId })).toBe(true); + }); + it("recognizes agents and creator-less ordinary tasks", () => { + expect(isAgentCreatedIssue({ createdByAgentId: "agent", originKind: "manual" })).toBe(true); + expect(isAgentCreatedIssue({ originKind: "manual" })).toBe(true); + expect(isAgentCreatedIssue({ createdByUserId: "person", createdByAgentId: "agent" })).toBe(true); + expect(isAgentCreatedIssue({ createdByUserId: "person" })).toBe(false); + }); +}); diff --git a/packages/shared/src/issue-creation-source.ts b/packages/shared/src/issue-creation-source.ts new file mode 100644 index 00000000000..48bd43f183b --- /dev/null +++ b/packages/shared/src/issue-creation-source.ts @@ -0,0 +1,17 @@ +/** Origins that represent a person's ordinary task or onboarding request. */ +export const HUMAN_TASK_ORIGIN_KINDS = ["manual", "onboarding_first_task"] as const; + +export interface IssueCreationSourceFields { + createdByAgentId?: string | null; + createdByUserId?: string | null; + originKind?: string | null; +} + +/** Creation is independent of who is assigned or responsible for execution. */ +export function isAgentCreatedIssue(issue: IssueCreationSourceFields): boolean { + return Boolean(issue.createdByAgentId) + || !issue.createdByUserId + || !HUMAN_TASK_ORIGIN_KINDS.includes( + (issue.originKind ?? "manual") as typeof HUMAN_TASK_ORIGIN_KINDS[number], + ); +} diff --git a/server/src/__tests__/issues-creation-visibility.test.ts b/server/src/__tests__/issues-creation-visibility.test.ts new file mode 100644 index 00000000000..7b19bd5dd60 --- /dev/null +++ b/server/src/__tests__/issues-creation-visibility.test.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { expect, it } from "vitest"; +import { activityLog, issueComments, issueReadStates, issues } from "@paperclipai/db"; +import { isAgentCreatedIssue } from "@paperclipai/shared"; +import { issueRoutes } from "../routes/issues.js"; +import { issueService } from "../services/issues.js"; +import { describeEmbeddedPostgres, resetCompanyIssueFixtures, routeApp, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js"; + +describeEmbeddedPostgres("issue creation visibility", () => { + const ctx = useEmbeddedPostgres("paperclip-creation-visibility-", { + resetEach: async (db) => { + await db.delete(activityLog); + await resetCompanyIssueFixtures(db); + }, + }); + + it("filters ordinary and compact searches before pagination without changing API defaults", async () => { + const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Creation visibility"); + const other = await seedCompanyWithBoardAccess(ctx.db, "Other company"); + const rows = [ + { id: randomUUID(), companyId, title: "Visibility human", createdByUserId: userId, originKind: "manual" }, + { id: randomUUID(), companyId, title: "Visibility second human", createdByUserId: userId, originKind: "manual" }, + { id: randomUUID(), companyId, title: "Visibility channel", createdByUserId: userId, originKind: "chat_channel" }, + { id: randomUUID(), companyId, title: "Visibility onboarding", createdByUserId: userId, originKind: "onboarding_first_task" }, + { id: randomUUID(), companyId, title: "Visibility inbound email", originKind: "chat_channel", originId: "email:endpoint:thread" }, + { id: randomUUID(), companyId, title: "Visibility outbound email", originKind: "chat_channel", originId: "email-send:tool-call" }, + ...["routine_execution", "task_watchdog", "stranded_issue_recovery", "plugin:workflow:task", "harness_liveness_escalation", "manual"].map((originKind) => ({ id: randomUUID(), companyId, title: `Visibility ${originKind}`, originKind, responsibleUserId: userId })), + { id: randomUUID(), companyId, title: "Visibility manually run routine", createdByUserId: userId, originKind: "routine_execution" }, + ]; + await ctx.db.insert(issues).values([ + ...rows.map((row, index) => ({ ...row, status: "todo", updatedAt: new Date(2026, 9, 13, 0, index) })), + { companyId: other.companyId, title: "Visibility other company", createdByUserId: other.userId }, + { companyId, title: "Visibility private task", createdByUserId: other.userId, visibility: "private" }, + ]); + const app = routeApp(ctx.db, actor, issueRoutes); + const path = `/api/companies/${companyId}/issues`; + const defaults = await request(app).get(`${path}?includeRoutineExecutions=true`).expect(200); + expect(defaults.body).toHaveLength(rows.length); + for (const view of ["", "&view=compact"]) { + const first = await request(app).get(`${path}?includeAgentCreatedTasks=false&q=Visibility&sortField=updated&limit=2${view}`).expect(200); + const second = await request(app).get(`${path}?includeAgentCreatedTasks=false&q=Visibility&sortField=updated&limit=2&offset=2${view}`).expect(200); + expect(first.body).toHaveLength(2); + expect(second.body).toHaveLength(1); + expect([...first.body, ...second.body].every((issue: Parameters[0]) => !isAgentCreatedIssue(issue))).toBe(true); + } + await request(app).get(`${path}?includeAgentCreatedTasks=invalid`).expect(400); + await request(app).get(`${path}?includeParticipatedTasks=invalid`).expect(400); + await request(app).get(`/api/companies/${other.companyId}/issues?includeAgentCreatedTasks=false`).expect(403); + }); + + it("preserves participation but excludes routine ownership and read-only activity", async () => { + const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Inbox participation"); + const ids = Object.fromEntries(["scheduled", "commented", "assigned", "manual", "read", "touched", "channelScheduled", "channelCommented", "channelManual"].map((name) => [name, randomUUID()])); + await ctx.db.insert(issues).values(Object.entries(ids).map(([name, id]) => ({ + id, companyId, title: `Participation ${name}`, status: "todo", originKind: name.startsWith("channel") ? "chat_channel" : "routine_execution", + responsibleUserId: userId, + assigneeUserId: name === "assigned" ? userId : null, + createdByUserId: name === "manual" || name === "channelManual" ? userId : null, + }))); + await ctx.db.insert(issueComments).values([ids.commented!, ids.channelCommented!].map((issueId) => ({ companyId, issueId, authorUserId: userId, body: "I joined this task" }))); + await ctx.db.insert(issueReadStates).values({ companyId, issueId: ids.read!, userId, lastReadAt: new Date() }); + await ctx.db.insert(activityLog).values([ + { companyId, entityType: "issue", entityId: ids.read!, actorType: "user", actorId: userId, action: "issue.read_marked" }, + { companyId, entityType: "issue", entityId: ids.touched!, actorType: "user", actorId: userId, action: "issue.inbox_touched" }, + ]); + const app = routeApp(ctx.db, actor, issueRoutes); + const path = `/api/companies/${companyId}/issues`; + const mine = await request(app).get(`${path}?touchedByUserId=me&inboxArchivedByUserId=me&includeRoutineExecutions=true`).expect(200); + const expected = [ids.commented, ids.assigned, ids.manual, ids.touched, ids.channelCommented, ids.channelManual].sort(); + expect(mine.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected); + const discovery = await request(app).get(`${path}?q=Participation&includeAgentCreatedTasks=false&includeParticipatedTasks=true`).expect(200); + expect(discovery.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected); + const strict = await issueService(ctx.db).list(companyId, { includeAgentCreatedTasks: false }); + expect(strict).toEqual([]); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 66b7d0460b8..7af8d4c1239 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8312,6 +8312,20 @@ export function issueRoutes( const hasPlanDocument = parseOptionalBooleanQuery( req.query.hasPlanDocument, ); + const includeParticipatedTasks = parseOptionalBooleanQuery(req.query.includeParticipatedTasks); + if (includeParticipatedTasks === null) { + res.status(400).json({ error: "includeParticipatedTasks must be true or false when provided" }); + return; + } + if (includeParticipatedTasks && (req.actor.type !== "board" || !req.actor.userId)) { + res.status(403).json({ error: "includeParticipatedTasks requires board authentication" }); + return; + } + const includeAgentCreatedTasks = parseOptionalBooleanQuery(req.query.includeAgentCreatedTasks); + if (includeAgentCreatedTasks === null) { + res.status(400).json({ error: "includeAgentCreatedTasks must be true or false when provided" }); + return; + } const includeLiveDescendantSummary = parseOptionalBooleanQuery( req.query.includeLiveDescendantSummary, ); @@ -8479,6 +8493,8 @@ export function issueRoutes( includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", + includeAgentCreatedTasks, + creationVisibilityUserId: includeParticipatedTasks === true && req.actor.type === "board" ? req.actor.userId : undefined, includeLiveDescendantSummary: includeLiveDescendantSummary === true, hasPlanDocument, q: req.query.q as string | undefined, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 7c627f7b49f..aeb63a0c157 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,4 +1,5 @@ import { automaticReadRecoveryCleanupCondition, touchedByUserCondition } from "./issue-participation.js"; +import { HUMAN_TASK_ORIGIN_KINDS } from "@paperclipai/shared"; import { resolveProjectDefaultWorkspaceId } from "./project-default-workspace.js"; import { type AuthorizationActor, executionWorkspaceReadSqlCondition, projectReadSqlCondition } from "./authorization.js"; import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js"; @@ -1834,6 +1835,9 @@ export interface IssueFilters { originKind?: string; originKindPrefix?: string; originId?: string; + includeAgentCreatedTasks?: boolean; + /** Authenticated inbox discovery can retain existing participation. */ + creationVisibilityUserId?: string; includeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean; includePluginOperations?: boolean; @@ -6197,6 +6201,21 @@ function assertValidAssigneeAgentFilter( } } +function humanCreatedIssueCondition() { + return and( + isNull(issues.createdByAgentId), + isNotNull(issues.createdByUserId), + inArray(issues.originKind, [...HUMAN_TASK_ORIGIN_KINDS]), + )!; +} + +function creationVisibilityCondition(companyId: string, filters?: IssueFilters) { + if (filters?.includeAgentCreatedTasks !== false) return undefined; + return filters.creationVisibilityUserId + ? or(humanCreatedIssueCondition(), touchedByUserCondition(companyId, filters.creationVisibilityUserId))! + : humanCreatedIssueCondition(); +} + async function blockedInboxIssueConditions( dbOrTx: any, companyId: string, @@ -6208,6 +6227,8 @@ async function blockedInboxIssueConditions( notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), ]; if (filters?.readCondition) conditions.push(filters.readCondition); + const creationCondition = creationVisibilityCondition(companyId, filters); + if (creationCondition) conditions.push(creationCondition); const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; @@ -7971,6 +7992,8 @@ export function issueService(db: Db) { } if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId)); if (filters?.readCondition) conditions.push(filters.readCondition); + const creationCondition = creationVisibilityCondition(companyId, filters); + if (creationCondition) conditions.push(creationCondition); const assigneeAgentFilter = parseIssueAssigneeAgentFilter( filters?.assigneeAgentId, ); @@ -8299,6 +8322,8 @@ export function issueService(db: Db) { const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; if (filters?.readCondition) conditions.push(filters.readCondition); + const creationCondition = creationVisibilityCondition(companyId, filters); + if (creationCondition) conditions.push(creationCondition); if (!filters?.q?.trim()) { conditions.push(isNull(issues.conversationAgentId)); if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) { diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 540a8d1b6db..314064ea27a 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -102,6 +102,8 @@ export type IssueListFilters = { originId?: string; descendantOf?: string; createdFromIssueId?: string; + includeAgentCreatedTasks?: boolean; + includeParticipatedTasks?: boolean; includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; @@ -143,6 +145,9 @@ function issueListSearchParams(filters?: IssueListFilters) { if (filters?.originId) params.set("originId", filters.originId); if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId); + if (filters?.includeParticipatedTasks) params.set("includeParticipatedTasks", "true"); + if (filters?.includeAgentCreatedTasks !== undefined) + params.set("includeAgentCreatedTasks", String(filters.includeAgentCreatedTasks)); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index dee2ec3c7a3..0cb145a359f 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -76,10 +76,13 @@ export function BlockedInboxView({ error, refetch, } = useQuery({ - queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary"], + queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary", issueFilters.showAgentCreatedTasks], queryFn: () => issuesApi.list(companyId, { attention: "blocked", + includeRoutineExecutions: true, + includeAgentCreatedTasks: issueFilters.showAgentCreatedTasks, + includeParticipatedTasks: true, includeBlockedInboxAttention: true, includeBlockedBy: true, includeLiveDescendantSummary: true, @@ -98,7 +101,7 @@ export function BlockedInboxView({ filteredRows.map((row) => row.issue), issueFilters, currentUserId, - true, + false, liveIssueIds, workspaceFilterContext, ).map((issue) => issue.id), diff --git a/ui/src/components/IssueFiltersPopover.test.tsx b/ui/src/components/IssueFiltersPopover.test.tsx index 796735a8eb6..ffe62d905ae 100644 --- a/ui/src/components/IssueFiltersPopover.test.tsx +++ b/ui/src/components/IssueFiltersPopover.test.tsx @@ -65,7 +65,7 @@ describe("IssueFiltersPopover", () => { agents={[{ id: "agent-1", name: "Agent One" }]} projects={[{ id: "project-1", name: "Project One" }]} labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter />, ); }); @@ -98,7 +98,7 @@ describe("IssueFiltersPopover", () => { agents={[{ id: "agent-1", name: "Agent One" }]} projects={[{ id: "project-1", name: "Project One" }]} labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter />, ); }); @@ -108,6 +108,9 @@ describe("IssueFiltersPopover", () => { // Status section still renders, Priority section is gated off (PAP-411). expect(popoverContent?.textContent).toContain("Status"); expect(popoverContent?.textContent).not.toContain("Priority"); + const creationToggle = Array.from(popoverContent?.querySelectorAll("label") ?? []).find((label) => label.textContent?.includes("show agent-created tasks")); + expect(creationToggle?.textContent).toBe("show agent-created tasks"); + expect(creationToggle?.querySelector("input")?.checked).toBe(false); }); it("searches long option lists while the popover remains the only scroll owner", () => { diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 5546054e1f9..97b82dfbea3 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -108,7 +108,7 @@ export function IssueFiltersPopover({ labels, currentUserId, enableExternalObjectFilters = true, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, buttonVariant = "ghost", iconOnly = false, creators, @@ -123,7 +123,7 @@ export function IssueFiltersPopover({ labels?: LabelOption[]; currentUserId?: string | null; enableExternalObjectFilters?: boolean; - enableRoutineVisibilityFilter?: boolean; + enableAgentCreatedVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; creators?: CreatorOption[]; @@ -520,13 +520,13 @@ export function IssueFiltersPopover({ /> Live runs only - {enableRoutineVisibilityFilter ? ( + {enableAgentCreatedVisibilityFilter ? ( ) : null} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index f50a3332c04..ab89d8563f5 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -13,6 +13,7 @@ import { issueAgeSeparatorLabel, } from "./IssuesList"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences"; const companyState = vi.hoisted(() => ({ @@ -24,6 +25,8 @@ const dialogState = vi.hoisted(() => ({ })); const mockIssuesApi = vi.hoisted(() => ({ + get: vi.fn(), + listAll: vi.fn(), list: vi.fn(), listLabels: vi.fn(), })); @@ -222,7 +225,7 @@ function createIssue(overrides: Partial = {}): Issue { assigneeUserId: null, responsibleUserId: null, createdByAgentId: null, - createdByUserId: null, + createdByUserId: "person", issueNumber: 1, requestDepth: 0, billingCode: null, @@ -354,6 +357,7 @@ describe("IssuesList", () => { mockInstanceSettingsApi.getExperimental.mockReset(); mockExternalObjectsApi.getIssueSummaries.mockReset(); mockIssuesApi.list.mockResolvedValue([]); + mockIssuesApi.listAll.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} }); @@ -1354,7 +1358,7 @@ describe("IssuesList", () => { agents={[]} projects={[]} viewStateKey="paperclip:test-issues" - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter onUpdateIssue={() => undefined} />, container, @@ -1365,11 +1369,13 @@ describe("IssuesList", () => { status: "backlog", limit: 200, includeRoutineExecutions: true, + includeAgentCreatedTasks: false, }), { signal: expect.any(AbortSignal) }); expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ status: "done", limit: 200, includeRoutineExecutions: true, + includeAgentCreatedTasks: false, }), { signal: expect.any(AbortSignal) }); expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ issues: expect.arrayContaining([ @@ -1561,6 +1567,48 @@ describe("IssuesList", () => { }); }); + it.each([true, false])("retains loaded context while revealing a large automated tree (streamlined=%s)", async (streamlined) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined }); + const parent = createIssue({ id: "human-root", createdByUserId: "person", originKind: "manual" }); + const children = Array.from({ length: 250 }, (_, index) => createIssue({ + id: `automated-child-${index}`, title: `Automated child ${index}`, + parentId: parent.id, createdByUserId: null, createdByAgentId: "agent", originKind: "manual", + })); + let holdNextRead = false; + let releaseRead: (() => void) | undefined; + mockIssuesApi.list.mockImplementation(async (_companyId, filters) => { + if (filters.parentId !== parent.id) return []; + const rows = children.slice(filters.offset ?? 0, (filters.offset ?? 0) + filters.limit); + if (holdNextRead) { + holdNextRead = false; + return new Promise((resolve) => { releaseRead = () => resolve(rows); }); + } + return rows; + }); + const { root } = renderWithQueryClient( + undefined} />, + container, + ); + await waitForAssertion(() => expect(container.querySelectorAll("[data-testid='issue-row']")).toHaveLength(100)); + holdNextRead = true; + act(() => { + setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); + window.dispatchEvent(new Event("scroll")); + }); + await flushAnimationFrame(); + await waitForAssertion(() => { + expect(releaseRead).toBeDefined(); + expect(container.querySelectorAll("[data-testid='issue-row']").length).toBeGreaterThanOrEqual(100); + }); + await act(async () => releaseRead!()); + await waitForAssertion(() => { + expect(container.querySelectorAll("[data-testid='issue-row']")).toHaveLength(251); + expect(container.textContent).toContain("Automated child 249"); + }, 60); + act(() => root.unmount()); + }); + it("keeps rendering local issue batches while the user stays near the bottom", async () => { const manyIssues = Array.from({ length: 420 }, (_, index) => createIssue({ @@ -2014,11 +2062,17 @@ describe("IssuesList", () => { }); }); - it("shows routine-backed issues by default and hides them when the routine filter is toggled off", async () => { + it.each([true, false])("keeps visibility controls and fetching preferences synchronized (streamlined=%s)", async (streamlined) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined }); + function PreferenceProbe() { + const included = useShowAgentCreatedTasks("company-1", "paperclip:test-issues"); + return {String(included)}; + } const manualIssue = createIssue({ id: "issue-manual", identifier: "PAP-10", title: "Manual issue", + createdByUserId: "person", originKind: "manual", }); const routineIssue = createIssue({ @@ -2029,20 +2083,20 @@ describe("IssuesList", () => { }); const { root } = renderWithQueryClient( - undefined} - />, + />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Manual issue"); - expect(container.textContent).toContain("Routine issue"); + expect(container.textContent).not.toContain("Routine issue"); }); await act(async () => { @@ -2055,26 +2109,38 @@ describe("IssuesList", () => { await waitForAssertion(() => { const toggle = Array.from(document.body.querySelectorAll("label")).find( - (label) => label.textContent?.includes("Hide routine runs"), + (label) => label.textContent?.includes("show agent-created tasks"), ); expect(toggle).not.toBeUndefined(); }); await act(async () => { const toggle = Array.from(document.body.querySelectorAll("label")).find( - (label) => label.textContent?.includes("Hide routine runs"), + (label) => label.textContent?.includes("show agent-created tasks"), ); toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); + await waitForAssertion(() => { + expect(container.textContent).toContain("Routine issue"); + expect(container.querySelector("[data-testid='creation-preference']")?.textContent).toBe("true"); + }); + const preferenceKey = taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "paperclip:test-issues" }); + const envelope = JSON.parse(localStorage.getItem(preferenceKey)!); + await act(async () => { + localStorage.setItem(preferenceKey, JSON.stringify({ ...envelope, viewState: { ...envelope.viewState, showAgentCreatedTasks: false } })); + window.dispatchEvent(new StorageEvent("storage", { key: preferenceKey })); + }); await waitForAssertion(() => { expect(container.textContent).not.toContain("Routine issue"); + expect(container.querySelector("[data-testid='creation-preference']")?.textContent).toBe("false"); + const label = Array.from(document.body.querySelectorAll("label")).find((element) => element.textContent?.includes("show agent-created tasks")); + const checkbox = label?.querySelector("[role='checkbox'], input[type='checkbox']"); + expect(checkbox).not.toBeNull(); + expect(checkbox instanceof HTMLInputElement ? checkbox.checked : checkbox?.getAttribute("aria-checked") === "true").toBe(false); }); - - act(() => { - root.unmount(); - }); + act(() => root.unmount()); }); it("blurs the search input on Enter without clearing the query", async () => { diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 4603c567b9f..019c62a7b48 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,3 +1,6 @@ +import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; +import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; +import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; import { AgentIdentity } from "@/components/AgentIdentity"; import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; @@ -495,7 +498,7 @@ interface IssuesListProps { * tokens + wall-clock runtime for every run in the tree. */ parentIssueIdForCostSummary?: string; - enableRoutineVisibilityFilter?: boolean; + enableAgentCreatedVisibilityFilter?: boolean; hasMoreIssues?: boolean; isLoadingMoreIssues?: boolean; mutedIssueIds?: Set; @@ -735,7 +738,7 @@ function StreamlinedIssuesList({ defaultSortField, showProgressSummary = false, parentIssueIdForCostSummary, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, hasMoreIssues = false, isLoadingMoreIssues = false, mutedIssueIds, @@ -822,10 +825,17 @@ function StreamlinedIssuesList({ initialStatuses, ), ); + const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey); + useEffect(() => { + if (!enableAgentCreatedVisibilityFilter) return; + setViewState((previous) => previous.showAgentCreatedTasks === showAgentCreatedTasks + ? previous : { ...previous, showAgentCreatedTasks }); + }, [enableAgentCreatedVisibilityFilter, showAgentCreatedTasks]); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); + const [hierarchyRowBudget, setHierarchyRowBudget] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(initialPreferences.columns); const renderedIssueIdsRef = useRef(""); const initialServerFillRequestedRef = useRef(false); @@ -895,6 +905,8 @@ function StreamlinedIssuesList({ // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. useEffect(() => { + // Contextual children are loaded separately from independent results. + if (enableAgentCreatedVisibilityFilter) return; const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]); const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id)); if (pruned.length !== viewState.collapsedParents.length) { @@ -909,7 +921,8 @@ function StreamlinedIssuesList({ searchFilters ?? {}, "compact", ISSUE_SEARCH_RESULT_LIMIT, - enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { @@ -917,7 +930,7 @@ function StreamlinedIssuesList({ projectId, limit: ISSUE_SEARCH_RESULT_LIMIT, ...searchFilters, - ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues, placeholderData: (previousData) => previousData, @@ -933,7 +946,8 @@ function StreamlinedIssuesList({ searchFilters ?? {}, "compact", ISSUE_BOARD_COLUMN_RESULT_LIMIT, - enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.listCompact(selectedCompanyId!, { @@ -942,7 +956,7 @@ function StreamlinedIssuesList({ projectId, status, limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, - ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues, placeholderData: (previousData: Issue[] | undefined) => previousData, @@ -1202,7 +1216,7 @@ function StreamlinedIssuesList({ searchScopedIssues, viewState, currentUserId, - enableRoutineVisibilityFilter, + enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ); @@ -1211,7 +1225,7 @@ function StreamlinedIssuesList({ searchScopedIssues, viewState, currentUserId, - enableRoutineVisibilityFilter, + enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ]); @@ -1269,7 +1283,7 @@ function StreamlinedIssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); + const activeFilterCount = countActiveIssueFilters(viewState, enableAgentCreatedVisibilityFilter); const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; const boardCompactCards = viewState.boardCardDensity === "compact" @@ -1287,7 +1301,13 @@ function StreamlinedIssuesList({ || viewState.boardColdLaneMode !== "auto" || viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE; - const groupedContent = useMemo(() => { + const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list"; + const hierarchy = useIssueHierarchyContext( + selectedCompanyId, filtered.slice(0, renderedIssueRowLimit), viewState.collapsedParents, + hierarchyEnabled, + hierarchyRowBudget, + ); + const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; } @@ -1379,6 +1399,14 @@ function StreamlinedIssuesList({ companyUserLabelMap, projectById, ]); + const groupedContent = useMemo(() => baseGroupedContent.map((group) => ({ + ...group, + items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list" + ? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues]) + : group.items, + })), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.nestingEnabled, viewState.viewMode]); + const visibleListRowCount = groupedContent.reduce((total, group) => total + group.items.length, 0); + // Flattened visible order (group headers, then tree DFS per group — // collapsed groups keep their header entry but skip their rows) — must @@ -1568,31 +1596,41 @@ function StreamlinedIssuesList({ const previousIssueIds = renderedIssueIdsRef.current; renderedIssueIdsRef.current = nextIssueIds; + const sameResultsOrAppend = previousIssueIds.length > 0 && nextIssueIds.startsWith(previousIssueIds); + if (!sameResultsOrAppend) setHierarchyRowBudget(INITIAL_ISSUE_ROW_RENDER_LIMIT); setRenderedIssueRowLimit((current) => { - const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT); - const listAppended = previousIssueIds.length > 0 - && nextIssueIds.startsWith(previousIssueIds) - && filtered.length >= current; - if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit)); + const nextInitialLimit = Math.min(visibleListRowCount, INITIAL_ISSUE_ROW_RENDER_LIMIT); + // Context arrives asynchronously and must not undo a reveal request. + if (hierarchyEnabled && sameResultsOrAppend) return Math.max(current, nextInitialLimit); + const listAppended = sameResultsOrAppend && visibleListRowCount >= current; + if (listAppended) return Math.min(visibleListRowCount, Math.max(current, nextInitialLimit)); return nextInitialLimit; }); - }, [filtered, viewState.viewMode]); + }, [filtered, visibleListRowCount, viewState.viewMode, hierarchyEnabled]); - const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length; - const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); + const hasMoreHierarchyRows = hierarchyEnabled && hierarchy.hasMore; + const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < visibleListRowCount; + const remainingIssueRowCount = Math.max(visibleListRowCount - renderedIssueRowLimit, 0); const loadMoreIssueRows = useCallback(() => { if (viewState.viewMode !== "list") return; + if (hasMoreHierarchyRows && !hierarchy.isFetching) { + setHierarchyRowBudget((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); + setRenderedIssueRowLimit((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); + return; + } if (hasMoreRenderedRows) { - setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE)); + setRenderedIssueRowLimit((current) => Math.min(visibleListRowCount, current + ISSUE_ROW_RENDER_BATCH_SIZE)); return; } if (hasMoreIssues && !isLoadingMoreIssues) { onLoadMoreIssues?.(); } }, [ - filtered.length, + visibleListRowCount, hasMoreIssues, hasMoreRenderedRows, + hasMoreHierarchyRows, + hierarchy.isFetching, isLoadingMoreIssues, onLoadMoreIssues, viewState.viewMode, @@ -1600,7 +1638,7 @@ function StreamlinedIssuesList({ const canLoadMoreIssues = viewState.viewMode === "list" && !isLoading - && (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues)); + && (hasMoreRenderedRows || (hasMoreHierarchyRows && !hierarchy.isFetching) || (hasMoreIssues && !isLoadingMoreIssues)); useEffect(() => { if (!canLoadMoreIssues) return; @@ -1911,7 +1949,7 @@ function StreamlinedIssuesList({ labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + enableAgentCreatedVisibilityFilter={enableAgentCreatedVisibilityFilter} iconOnly presentation={rowPresentation === "task" ? "streamlined" : "legacy"} /> @@ -2016,7 +2054,8 @@ function StreamlinedIssuesList({ Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.

    )} - {!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && ( + {hierarchy.error ?

    Failed to load task hierarchy: {hierarchy.error.message}

    : null} + {!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && ( ); })} - {(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && ( + {(remainingIssueRowCount > 0 || hasMoreHierarchyRows || hasMoreIssues || isLoadingMoreIssues) && (

    - {isLoadingMoreIssues + {isLoadingMoreIssues || (hierarchyEnabled && hierarchy.isFetching) ? "Loading more tasks..." : remainingIssueRowCount > 0 - ? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} tasks` + ? `Rendering ${Math.min(renderedIssueRowLimit, visibleListRowCount)} of ${visibleListRowCount} tasks` : "Scroll to load more tasks"}

    diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index 5cd5db5358a..cc30fbef84b 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -1,3 +1,7 @@ +import { saveTaskCollectionPreferences } from "../lib/task-collection-preferences"; +import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; +import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; +import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; import { AgentIdentity } from "@/components/AgentIdentity"; import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; @@ -208,10 +212,6 @@ function getViewState(key: string): IssueViewState { return { ...defaultViewState }; } -function saveViewState(key: string, state: IssueViewState) { - localStorage.setItem(key, JSON.stringify(state)); -} - function getInitialViewState( key: string, initialAssignees?: string[], @@ -269,17 +269,6 @@ function loadIssueColumns(key: string): InboxIssueColumn[] { } } -function saveIssueColumns(key: string, columns: InboxIssueColumn[]) { - try { - localStorage.setItem( - getIssueColumnsStorageKey(key), - JSON.stringify(normalizeInboxIssueColumns(columns)), - ); - } catch { - // Ignore localStorage failures. - } -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { if (state.sortField === "workflow") { const ordered = workflowSort(issues); @@ -489,7 +478,7 @@ interface IssuesListProps { * tokens + wall-clock runtime for every run in the tree. */ parentIssueIdForCostSummary?: string; - enableRoutineVisibilityFilter?: boolean; + enableAgentCreatedVisibilityFilter?: boolean; hasMoreIssues?: boolean; isLoadingMoreIssues?: boolean; mutedIssueIds?: Set; @@ -700,7 +689,7 @@ export function IssuesList({ defaultSortField, showProgressSummary = false, parentIssueIdForCostSummary, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, hasMoreIssues = false, isLoadingMoreIssues = false, mutedIssueIds, @@ -766,10 +755,17 @@ export function IssuesList({ const [viewState, setViewState] = useState(() => getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField), ); + const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey); + useEffect(() => { + if (!enableAgentCreatedVisibilityFilter) return; + setViewState((previous) => previous.showAgentCreatedTasks === showAgentCreatedTasks + ? previous : { ...previous, showAgentCreatedTasks }); + }, [enableAgentCreatedVisibilityFilter, showAgentCreatedTasks]); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); + const [hierarchyRowBudget, setHierarchyRowBudget] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => loadIssueColumns(scopedKey)); const renderedIssueIdsRef = useRef(""); const initialServerFillRequestedRef = useRef(false); @@ -801,10 +797,13 @@ export function IssuesList({ const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; - saveViewState(scopedKey, next); + saveTaskCollectionPreferences({ + companyId: selectedCompanyId ?? "__unscoped__", collectionKey: viewStateKey, + legacyViewStorageKey: scopedKey, legacyColumnsStorageKey: getIssueColumnsStorageKey(scopedKey), + }, { viewState: next, columns: visibleIssueColumns }); return next; }); - }, [scopedKey]); + }, [scopedKey, selectedCompanyId, viewStateKey, visibleIssueColumns]); useEffect(() => { if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return; @@ -815,6 +814,8 @@ export function IssuesList({ // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. useEffect(() => { + // Contextual children are loaded separately from independent results. + if (enableAgentCreatedVisibilityFilter) return; const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]); const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id)); if (pruned.length !== viewState.collapsedParents.length) { @@ -829,7 +830,8 @@ export function IssuesList({ searchFilters ?? {}, "compact", ISSUE_SEARCH_RESULT_LIMIT, - enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { @@ -837,7 +839,7 @@ export function IssuesList({ projectId, limit: ISSUE_SEARCH_RESULT_LIMIT, ...searchFilters, - ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues, placeholderData: (previousData) => previousData, @@ -853,7 +855,8 @@ export function IssuesList({ searchFilters ?? {}, "compact", ISSUE_BOARD_COLUMN_RESULT_LIMIT, - enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.listCompact(selectedCompanyId!, { @@ -862,7 +865,7 @@ export function IssuesList({ projectId, status, limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, - ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues, placeholderData: (previousData: Issue[] | undefined) => previousData, @@ -1118,7 +1121,7 @@ export function IssuesList({ searchScopedIssues, viewState, currentUserId, - enableRoutineVisibilityFilter, + enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ); @@ -1127,7 +1130,7 @@ export function IssuesList({ searchScopedIssues, viewState, currentUserId, - enableRoutineVisibilityFilter, + enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ]); @@ -1185,7 +1188,7 @@ export function IssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); + const activeFilterCount = countActiveIssueFilters(viewState, enableAgentCreatedVisibilityFilter); const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; const boardCompactCards = viewState.boardCardDensity === "compact" @@ -1203,7 +1206,13 @@ export function IssuesList({ || viewState.boardColdLaneMode !== "auto" || viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE; - const groupedContent = useMemo(() => { + const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list"; + const hierarchy = useIssueHierarchyContext( + selectedCompanyId, filtered.slice(0, renderedIssueRowLimit), viewState.collapsedParents, + hierarchyEnabled, + hierarchyRowBudget, + ); + const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; } @@ -1295,6 +1304,14 @@ export function IssuesList({ companyUserLabelMap, projectById, ]); + const groupedContent = useMemo(() => baseGroupedContent.map((group) => ({ + ...group, + items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list" + ? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues]) + : group.items, + })), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.nestingEnabled, viewState.viewMode]); + const visibleListRowCount = groupedContent.reduce((total, group) => total + group.items.length, 0); + // Flattened visible order (group headers, then tree DFS per group — // collapsed groups keep their header entry but skip their rows) — must @@ -1484,31 +1501,41 @@ export function IssuesList({ const previousIssueIds = renderedIssueIdsRef.current; renderedIssueIdsRef.current = nextIssueIds; + const sameResultsOrAppend = previousIssueIds.length > 0 && nextIssueIds.startsWith(previousIssueIds); + if (!sameResultsOrAppend) setHierarchyRowBudget(INITIAL_ISSUE_ROW_RENDER_LIMIT); setRenderedIssueRowLimit((current) => { - const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT); - const listAppended = previousIssueIds.length > 0 - && nextIssueIds.startsWith(previousIssueIds) - && filtered.length >= current; - if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit)); + const nextInitialLimit = Math.min(visibleListRowCount, INITIAL_ISSUE_ROW_RENDER_LIMIT); + // Context arrives asynchronously and must not undo a reveal request. + if (hierarchyEnabled && sameResultsOrAppend) return Math.max(current, nextInitialLimit); + const listAppended = sameResultsOrAppend && visibleListRowCount >= current; + if (listAppended) return Math.min(visibleListRowCount, Math.max(current, nextInitialLimit)); return nextInitialLimit; }); - }, [filtered, viewState.viewMode]); + }, [filtered, visibleListRowCount, viewState.viewMode, hierarchyEnabled]); - const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length; - const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); + const hasMoreHierarchyRows = hierarchyEnabled && hierarchy.hasMore; + const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < visibleListRowCount; + const remainingIssueRowCount = Math.max(visibleListRowCount - renderedIssueRowLimit, 0); const loadMoreIssueRows = useCallback(() => { if (viewState.viewMode !== "list") return; + if (hasMoreHierarchyRows && !hierarchy.isFetching) { + setHierarchyRowBudget((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); + setRenderedIssueRowLimit((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); + return; + } if (hasMoreRenderedRows) { - setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE)); + setRenderedIssueRowLimit((current) => Math.min(visibleListRowCount, current + ISSUE_ROW_RENDER_BATCH_SIZE)); return; } if (hasMoreIssues && !isLoadingMoreIssues) { onLoadMoreIssues?.(); } }, [ - filtered.length, + visibleListRowCount, hasMoreIssues, hasMoreRenderedRows, + hasMoreHierarchyRows, + hierarchy.isFetching, isLoadingMoreIssues, onLoadMoreIssues, viewState.viewMode, @@ -1516,7 +1543,7 @@ export function IssuesList({ const canLoadMoreIssues = viewState.viewMode === "list" && !isLoading - && (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues)); + && (hasMoreRenderedRows || (hasMoreHierarchyRows && !hierarchy.isFetching) || (hasMoreIssues && !isLoadingMoreIssues)); useEffect(() => { if (!canLoadMoreIssues) return; @@ -1624,8 +1651,11 @@ export function IssuesList({ const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); - saveIssueColumns(scopedKey, normalized); - }, [scopedKey]); + saveTaskCollectionPreferences({ + companyId: selectedCompanyId ?? "__unscoped__", collectionKey: viewStateKey, + legacyViewStorageKey: scopedKey, legacyColumnsStorageKey: getIssueColumnsStorageKey(scopedKey), + }, { viewState, columns: normalized }); + }, [scopedKey, selectedCompanyId, viewStateKey, viewState]); const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => { if (enabled) { @@ -1803,7 +1833,7 @@ export function IssuesList({ labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + enableAgentCreatedVisibilityFilter={enableAgentCreatedVisibilityFilter} iconOnly /> @@ -1906,7 +1936,8 @@ export function IssuesList({ Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.

    )} - {!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && ( + {hierarchy.error ?

    Failed to load task hierarchy: {hierarchy.error.message}

    : null} + {!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && ( ); })} - {(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && ( + {(remainingIssueRowCount > 0 || hasMoreHierarchyRows || hasMoreIssues || isLoadingMoreIssues) && (

    - {isLoadingMoreIssues + {isLoadingMoreIssues || (hierarchyEnabled && hierarchy.isFetching) ? "Loading more tasks..." : remainingIssueRowCount > 0 - ? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} tasks` + ? `Rendering ${Math.min(renderedIssueRowLimit, visibleListRowCount)} of ${visibleListRowCount} tasks` : "Scroll to load more tasks"}

    diff --git a/ui/src/hooks/useIssueHierarchyContext.ts b/ui/src/hooks/useIssueHierarchyContext.ts new file mode 100644 index 00000000000..39808380c5a --- /dev/null +++ b/ui/src/hooks/useIssueHierarchyContext.ts @@ -0,0 +1,50 @@ +import { loadIssueHierarchyChildren } from "../lib/issue-hierarchy-context"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { ApiError } from "../api/client"; +import { queryKeys } from "../lib/queryKeys"; + +export function useIssueHierarchyContext(companyId: string | null, matches: Issue[], collapsedParents: string[], enabled: boolean, rowBudget: number) { + const parents = [...new Set(matches.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)))]; + const ancestorQueries = useQueries({ queries: parents.map((parentId) => ({ + queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-ancestors", parentId], + enabled: enabled && Boolean(companyId), + staleTime: 30_000, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const ancestors: Issue[] = []; + const visited = new Set(); + let id: string | null = parentId; + while (id && !visited.has(id)) { + visited.add(id); + try { + const parent: Issue = await issuesApi.get(id, { signal }); + if (parent.companyId !== companyId) break; + ancestors.push(parent); + id = parent.parentId; + } catch (error) { + if (error instanceof ApiError && (error.status === 403 || error.status === 404)) break; + throw error; + } + } + return ancestors; + }, + })) }); + const children = useQuery>>({ + queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-children", matches.map((issue) => issue.id), collapsedParents, rowBudget], + enabled: enabled && Boolean(companyId) && matches.length > 0, + staleTime: 30_000, + placeholderData: (previous) => previous ? { + ...previous, issues: previous.issues.filter((issue) => issue.companyId === companyId), + } : undefined, + queryFn: ({ signal }) => loadIssueHierarchyChildren(companyId!, matches, collapsedParents, rowBudget, + (parentId, limit, offset) => issuesApi.list(companyId!, { parentId, limit, offset, includeRoutineExecutions: true }, { signal })), + }); + const queries = [...ancestorQueries, children]; + return { + issues: [...ancestorQueries.flatMap((query) => query.data ?? []), ...(children.data?.issues ?? [])], + hasMore: children.data?.hasMore ?? false, + isFetching: children.isFetching, + error: queries.find((query) => query.error)?.error, + }; +} diff --git a/ui/src/hooks/useShowAgentCreatedTasks.ts b/ui/src/hooks/useShowAgentCreatedTasks.ts new file mode 100644 index 00000000000..cdd6835540c --- /dev/null +++ b/ui/src/hooks/useShowAgentCreatedTasks.ts @@ -0,0 +1,30 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { taskCollectionPreferencesStorageKey, TASK_COLLECTION_PREFERENCES_CHANGED, TASK_COLLECTION_PREFERENCES_VERSION } from "../lib/task-collection-preferences"; + +/** Subscribe the collection's fetching owner to its persisted filter control. */ +export function useShowAgentCreatedTasks(companyId: string | null, collectionKey: string): boolean { + const getSnapshot = useCallback(() => { + try { + const key = taskCollectionPreferencesStorageKey({ companyId: companyId ?? "__unscoped__", collectionKey }); + const current = localStorage.getItem(key); + const legacy = localStorage.getItem(companyId ? `${collectionKey}:${companyId}` : collectionKey); + const envelope = current ? JSON.parse(current) : null; + const valid = envelope?.version === TASK_COLLECTION_PREFERENCES_VERSION + && envelope.companyId === (companyId ?? "__unscoped__") && envelope.collectionKey === collectionKey; + const view = valid ? envelope.viewState : legacy ? JSON.parse(legacy) : null; + return view?.showAgentCreatedTasks === true; + } catch { + return false; + } + }, [companyId, collectionKey]); + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} + +function subscribe(onChange: () => void) { + window.addEventListener(TASK_COLLECTION_PREFERENCES_CHANGED, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(TASK_COLLECTION_PREFERENCES_CHANGED, onChange); + window.removeEventListener("storage", onChange); + }; +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 79d0afbf397..836da02bc75 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -18,7 +18,6 @@ import { buildInboxKeyboardNavEntries, buildInboxDismissedAtByKey, computeInboxBadgeData, - filterInboxIssues, getArchivedInboxSearchIssues, getAvailableInboxIssueColumns, getInboxWorkItemKey, @@ -943,7 +942,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, }).map((issue) => issue.id), ).toEqual(["remote-match"]); @@ -964,7 +963,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, }), ).toEqual([]); @@ -985,7 +984,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, }), ).toEqual([]); @@ -1033,7 +1032,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: ["failed"], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, issueFilterContext: { externalObjectSummaryByIssueId: summaries, @@ -1129,7 +1128,7 @@ describe("inbox helpers", () => { workspaces: ["workspace-1"], liveOnly: true, externalObjectStatuses: [], - hideRoutineExecutions: false, + showAgentCreatedTasks: true, }, }); saveInboxFilterPreferences("company-2", { @@ -1145,7 +1144,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, }); @@ -1162,7 +1161,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: true, externalObjectStatuses: [], - hideRoutineExecutions: false, + showAgentCreatedTasks: true, }, }); expect(loadInboxFilterPreferences("company-2")).toEqual({ @@ -1178,7 +1177,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: true, + showAgentCreatedTasks: false, }, }); }); @@ -1196,7 +1195,7 @@ describe("inbox helpers", () => { projects: ["project-1"], workspaces: ["workspace-1", false], liveOnly: "yes", - hideRoutineExecutions: "yes", + showAgentCreatedTasks: "yes", }, })); @@ -1213,7 +1212,7 @@ describe("inbox helpers", () => { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: false, + showAgentCreatedTasks: false, }, }); }); @@ -1377,13 +1376,6 @@ describe("inbox helpers", () => { expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); }); - it("hides routine execution issues when the hide toggle is enabled", () => { - const manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const }; - const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const }; - - expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue, routineIssue]); - expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue]); - }); it("groups mixed inbox items by type while preserving item order within each group", () => { const items: InboxWorkItem[] = [ diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 368cc213a48..89c03541a15 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -425,17 +425,7 @@ export function shouldResetInboxWorkspaceGrouping( return experimentalSettingsLoaded && groupBy === "workspace" && !isolatedWorkspacesEnabled; } -export function shouldIncludeRoutineExecutionIssue( - issue: Pick, - hideRoutineExecutions: boolean, -): boolean { - return !hideRoutineExecutions || issue.originKind !== "routine_execution"; -} -export function filterInboxIssues(issues: Issue[], hideRoutineExecutions: boolean): Issue[] { - if (!hideRoutineExecutions) return issues; - return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, hideRoutineExecutions)); -} export function matchesInboxIssueSearch( issue: Pick, @@ -505,7 +495,7 @@ export function getInboxSearchSupplementIssues({ remoteIssues, issueFilters, currentUserId, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, liveIssueIds, issueFilterContext = {}, }: { @@ -515,7 +505,7 @@ export function getInboxSearchSupplementIssues({ remoteIssues: Issue[]; issueFilters: IssueFilterState; currentUserId?: string | null; - enableRoutineVisibilityFilter?: boolean; + enableAgentCreatedVisibilityFilter?: boolean; liveIssueIds?: ReadonlySet; issueFilterContext?: IssueFilterWorkspaceContext; }): Issue[] { @@ -531,7 +521,7 @@ export function getInboxSearchSupplementIssues({ remoteIssues, issueFilters, currentUserId, - enableRoutineVisibilityFilter, + enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ) diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index c17d2d1c500..e8c26f54455 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -6,6 +6,7 @@ import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, searchIssueFilterOptions, shouldIncludeIssueFilterWorkspaceOption, @@ -69,6 +70,22 @@ function makeExternalObjectSummary(overrides: Partial = { } describe("issue filters", () => { + it("hides automation by default while an opt-in includes every creation source", () => { + const issues = [makeIssue({ id: "human", createdByUserId: "person" }), makeIssue({ id: "agent", createdByAgentId: "agent" }), makeIssue({ id: "routine", originKind: "routine_execution", createdByUserId: "person" }), makeIssue({ id: "system" })]; + expect(applyIssueFilters(issues, defaultIssueFilterState, null, true).map((issue) => issue.id)).toEqual(["human"]); + expect(applyIssueFilters(issues, { ...defaultIssueFilterState, showAgentCreatedTasks: true }, null, true)).toEqual(issues); + // Membership and task-detail collections can explicitly bypass creation visibility. + expect(applyIssueFilters(issues, defaultIssueFilterState, null, false)).toEqual(issues); + expect(countActiveIssueFilters(defaultIssueFilterState, true)).toBe(0); + expect(countActiveIssueFilters({ ...defaultIssueFilterState, showAgentCreatedTasks: true }, true)).toBe(1); + }); + + it("defaults legacy and invalid preferences to hidden without losing other filters", () => { + expect(normalizeIssueFilterState({ hideRoutineExecutions: false, statuses: ["todo"] })).toMatchObject({ showAgentCreatedTasks: false, statuses: ["todo"] }); + expect(normalizeIssueFilterState({ showAgentCreatedTasks: "true" }).showAgentCreatedTasks).toBe(false); + expect(normalizeIssueFilterState({ showAgentCreatedTasks: true }).showAgentCreatedTasks).toBe(true); + }); + it("searches filter options case-insensitively without mutating the source", () => { const options = [{ name: "Alpha Agent" }, { name: "Release QA" }]; expect(searchIssueFilterOptions(options, " qa ", (option) => option.name)).toEqual([ diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index ce6178102a3..0e7c0c16b98 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -1,4 +1,4 @@ -import type { ExternalObjectSummary, Issue } from "@paperclipai/shared"; +import { isAgentCreatedIssue, type ExternalObjectSummary, type Issue } from "@paperclipai/shared"; export type IssueFilterWorkspaceLookup = { mode?: string | null; @@ -36,7 +36,7 @@ export type IssueFilterState = { * - `none` — issues with zero external objects */ externalObjectStatuses: string[]; - hideRoutineExecutions: boolean; + showAgentCreatedTasks: boolean; }; export const defaultIssueFilterState: IssueFilterState = { @@ -49,7 +49,7 @@ export const defaultIssueFilterState: IssueFilterState = { workspaces: [], liveOnly: false, externalObjectStatuses: [], - hideRoutineExecutions: false, + showAgentCreatedTasks: false, }; export const externalObjectFilterOrder = [ @@ -115,7 +115,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState { workspaces: normalizeIssueFilterValueArray(candidate.workspaces), liveOnly: candidate.liveOnly === true, externalObjectStatuses: normalizeIssueFilterValueArray(candidate.externalObjectStatuses), - hideRoutineExecutions: candidate.hideRoutineExecutions === true, + showAgentCreatedTasks: candidate.showAgentCreatedTasks === true, }; } @@ -210,7 +210,7 @@ export function applyIssueFilters( issues: Issue[], state: IssueFilterState, currentUserId?: string | null, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, liveIssueIds?: ReadonlySet, workspaceContext: IssueFilterWorkspaceContext = {}, ): Issue[] { @@ -218,8 +218,8 @@ export function applyIssueFilters( if (state.liveOnly) { result = result.filter((issue) => liveIssueIds?.has(issue.id) === true); } - if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) { - result = result.filter((issue) => issue.originKind !== "routine_execution"); + if (enableAgentCreatedVisibilityFilter && !state.showAgentCreatedTasks) { + result = result.filter((issue) => !isAgentCreatedIssue(issue)); } if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status)); if (state.priorities.length > 0) result = result.filter((issue) => state.priorities.includes(issue.priority)); @@ -269,7 +269,7 @@ export function applyIssueFilters( export function countActiveIssueFilters( state: IssueFilterState, - enableRoutineVisibilityFilter = false, + enableAgentCreatedVisibilityFilter = false, ): number { let count = 0; if (state.statuses.length > 0) count += 1; @@ -281,6 +281,6 @@ export function countActiveIssueFilters( if (state.workspaces.length > 0) count += 1; if (state.liveOnly) count += 1; if (state.externalObjectStatuses.length > 0) count += 1; - if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1; + if (enableAgentCreatedVisibilityFilter && state.showAgentCreatedTasks) count += 1; return count; } diff --git a/ui/src/lib/issue-hierarchy-context.test.ts b/ui/src/lib/issue-hierarchy-context.test.ts new file mode 100644 index 00000000000..1d63fa27689 --- /dev/null +++ b/ui/src/lib/issue-hierarchy-context.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { includeIssueHierarchyContext, loadIssueHierarchyChildren } from "./issue-hierarchy-context"; + +const task = (id: string, parentId: string | null = null) => ({ id, parentId, companyId: "company" } as Issue); + +describe("task hierarchy context", () => { + it("retains ancestors and descendants without promoting siblings or orphans", () => { + const match = task("human", "agent-parent"); + const context = [task("agent-parent", "grandparent"), task("grandparent"), task("agent-child", "human"), task("grandchild", "agent-child"), task("unrelated"), task("orphan", "missing"), task("sibling", "agent-parent")]; + expect(includeIssueHierarchyContext([match], context).map((issue) => issue.id)).toEqual([ + "human", "agent-parent", "grandparent", "agent-child", "grandchild", + ]); + expect(includeIssueHierarchyContext([], context)).toEqual([]); + }); + it("deduplicates matches and terminates cycles", () => { + const match = task("human", "parent"); + expect(includeIssueHierarchyContext([match], [match, task("parent", "human")]).map((issue) => issue.id)).toEqual(["human", "parent"]); + }); +}); + +describe("bounded hierarchy reads", () => { + it("caps rows and requests, and shares overlapping matching subtrees", async () => { + const root = task("root"); + const child = task("child", "root"); + const calls: string[] = []; + const read = async (id: string, limit: number, offset: number) => { + calls.push(id); + const rows = id === "root" ? [child] : id === "child" + ? Array.from({ length: 500 }, (_, index) => task(`nested-${index}`, "child")) : []; + return rows.slice(offset, offset + limit); + }; + const first = await loadIssueHierarchyChildren("company", [root, child], [], 5, read); + expect(first.issues).toHaveLength(5); + expect(first.hasMore).toBe(true); + expect(calls).toEqual(["root", "child"]); + calls.length = 0; + expect((await loadIssueHierarchyChildren("company", [root, child], [], 10, read)).issues).toHaveLength(10); + expect(calls).toEqual(["root", "child"]); + }); + it("probes collapsed parents without fetching their tree and rejects foreign-company rows", async () => { + const calls: Array<[string, number]> = []; + const rows = await loadIssueHierarchyChildren("company", [task("root")], ["root"], 5, + async (id, limit) => { calls.push([id, limit]); return [task("child", "root")]; }); + expect(rows.issues.map((issue) => issue.id)).toEqual(["child"]); + expect(calls).toEqual([["root", 1]]); + expect(await loadIssueHierarchyChildren("company", [task("root")], [], 1, + async () => [{ ...task("foreign", "root"), companyId: "other" }])).toEqual({ issues: [], hasMore: true }); + }); +}); diff --git a/ui/src/lib/issue-hierarchy-context.ts b/ui/src/lib/issue-hierarchy-context.ts new file mode 100644 index 00000000000..2dc328e1a62 --- /dev/null +++ b/ui/src/lib/issue-hierarchy-context.ts @@ -0,0 +1,64 @@ +import type { Issue } from "@paperclipai/shared"; + +/** Context never promotes an unrelated or orphaned automated task to a result. */ +export function includeIssueHierarchyContext(matches: Issue[], context: Issue[]): Issue[] { + const byId = new Map([...context, ...matches].map((issue) => [issue.id, issue])); + const result = new Map(matches.map((issue) => [issue.id, issue])); + for (const match of matches) { + const visited = new Set([match.id]); + let parentId = match.parentId; + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + const parent = byId.get(parentId); + if (!parent) break; + result.set(parent.id, parent); + parentId = parent.parentId; + } + } + // Descendants are rooted at matches, not at contextual ancestors. + const descendantIds = new Set(matches.map((issue) => issue.id)); + let changed = true; + while (changed) { + changed = false; + for (const issue of context) { + if (!descendantIds.has(issue.id) && issue.parentId && descendantIds.has(issue.parentId)) { + descendantIds.add(issue.id); + result.set(issue.id, issue); + changed = true; + } + } + } + return [...result.values()]; +} + +/** Fetch each parent once and stop at the collection's current rendering budget. */ +export async function loadIssueHierarchyChildren( + companyId: string, + matches: Issue[], + collapsedParents: string[], + rowBudget: number, + readChildren: (parentId: string, limit: number, offset: number) => Promise, +): Promise<{ issues: Issue[]; hasMore: boolean }> { + const context: Issue[] = []; + const queue = matches.map((issue) => ({ issue, offset: 0 })); + const visited = new Set(); + const fetched = new Set(); + let requests = 0; + while (queue.length > 0 && context.length < rowBudget && requests < rowBudget) { + const { issue, offset } = queue.shift()!; + if (offset === 0 && visited.has(issue.id)) continue; + visited.add(issue.id); + const collapsed = collapsedParents.includes(issue.id); + const limit = collapsed ? 1 : Math.min(100, rowBudget - context.length); + const rows = await readChildren(issue.id, limit, offset); + requests += 1; + for (const child of rows) { + if (child.companyId !== companyId || fetched.has(child.id)) continue; + fetched.add(child.id); + context.push(child); + if (!collapsed) queue.push({ issue: child, offset: 0 }); + } + if (!collapsed && rows.length === limit) queue.unshift({ issue, offset: offset + rows.length }); + } + return { issues: context, hasMore: queue.length > 0 }; +} diff --git a/ui/src/lib/task-collection-preferences.ts b/ui/src/lib/task-collection-preferences.ts index 858d1cc44da..4df34bf790c 100644 --- a/ui/src/lib/task-collection-preferences.ts +++ b/ui/src/lib/task-collection-preferences.ts @@ -1,3 +1,5 @@ +export const TASK_COLLECTION_PREFERENCES_CHANGED = "paperclip:task-collection-preferences-changed"; + export const TASK_COLLECTION_PREFERENCES_VERSION = 1 as const; type StorageLike = Pick; @@ -79,6 +81,9 @@ export function saveTaskCollectionPreferences window.dispatchEvent(new Event(TASK_COLLECTION_PREFERENCES_CHANGED))); + } // Dual-write during migration so older builds using the same worktree keep // reading the latest presentation state. if (location.legacyViewStorageKey) { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 88ae042b349..972647bc8e1 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1114,17 +1114,19 @@ function StreamlinedInbox({ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "compact", "inbox-supplement", + issueFilters.showAgentCreatedTasks, "live-descendant-summary", ], queryFn: () => issuesApi.listCompact(selectedCompanyId!, { q: normalizedSearchQuery, limit: 25, + includeAgentCreatedTasks: issueFilters.showAgentCreatedTasks, + includeParticipatedTasks: true, includeRoutineExecutions: true, includeLiveDescendantSummary: true, }).then((rows) => rows as Issue[]), enabled: shouldUseIssueSearchSupplement, - placeholderData: (previousData) => previousData, }); const liveIssueIds = useMemo( () => collectLiveIssueIds(liveRuns, [ @@ -1155,11 +1157,11 @@ function StreamlinedInbox({ externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]); const visibleMineIssues = useMemo( - () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + () => applyIssueFilters(mineIssues, issueFilters, currentUserId, false, liveIssueIds, issueFilterContext), [mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const visibleTouchedIssues = useMemo( - () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, false, liveIssueIds, issueFilterContext), [touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const unreadTouchedIssues = useMemo( @@ -1466,7 +1468,7 @@ function StreamlinedInbox({ remoteIssues: remoteIssueSearchResults, issueFilters, currentUserId, - enableRoutineVisibilityFilter: true, + enableAgentCreatedVisibilityFilter: false, liveIssueIds, issueFilterContext, }), @@ -2485,7 +2487,7 @@ function StreamlinedInbox({ labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" iconOnly presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} @@ -2590,7 +2592,7 @@ function StreamlinedInbox({ labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" iconOnly presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 0653d275848..ac11e1ba02f 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,3 +1,4 @@ +import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useEffect, useMemo, useCallback, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useLocation, useNavigate, useSearchParams } from "@/lib/router"; @@ -179,6 +180,7 @@ function OrganizationIssues({ } return urlSearch; }, [searchOverride, urlSearch, location.search]); + const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, "paperclip:issues-view"); const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0); const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined; @@ -254,6 +256,7 @@ function OrganizationIssues({ workspaceIdFilter ?? "__all__", "compact", "with-routine-executions", + showAgentCreatedTasks, "infinite", issuePageSize, ], @@ -261,6 +264,7 @@ function OrganizationIssues({ participantAgentId, workspaceId: workspaceIdFilter, includeRoutineExecutions: true, + includeAgentCreatedTasks: showAgentCreatedTasks, limit: issuePageSize, offset: pageParam, sortField: "updated", @@ -323,7 +327,7 @@ function OrganizationIssues({ toolbarContext={toolbarContext} initialSearch={syncedSearch} onSearchChange={handleSearchChange} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter hasMoreIssues={hasMoreServerIssues} onLoadMoreIssues={loadMoreServerIssues} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index 21d1a885e15..b925ac6d2ef 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -974,17 +974,19 @@ export function Inbox() { ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "compact", "inbox-supplement", + issueFilters.showAgentCreatedTasks, "live-descendant-summary", ], queryFn: () => issuesApi.listCompact(selectedCompanyId!, { q: normalizedSearchQuery, limit: 25, + includeAgentCreatedTasks: issueFilters.showAgentCreatedTasks, + includeParticipatedTasks: true, includeRoutineExecutions: true, includeLiveDescendantSummary: true, }).then((rows) => rows as Issue[]), enabled: shouldUseIssueSearchSupplement, - placeholderData: (previousData) => previousData, }); const liveIssueIds = useMemo( () => collectLiveIssueIds(liveRuns, [ @@ -1015,11 +1017,11 @@ export function Inbox() { externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]); const visibleMineIssues = useMemo( - () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + () => applyIssueFilters(mineIssues, issueFilters, currentUserId, false, liveIssueIds, issueFilterContext), [mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const visibleTouchedIssues = useMemo( - () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, false, liveIssueIds, issueFilterContext), [touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const unreadTouchedIssues = useMemo( @@ -1321,7 +1323,7 @@ export function Inbox() { remoteIssues: remoteIssueSearchResults, issueFilters, currentUserId, - enableRoutineVisibilityFilter: true, + enableAgentCreatedVisibilityFilter: false, liveIssueIds, issueFilterContext, }), @@ -2350,7 +2352,7 @@ export function Inbox() { labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" iconOnly /> @@ -2448,7 +2450,7 @@ export function Inbox() { labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" iconOnly /> diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 8ce3fe22b7a..c65a04d77d7 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,3 +1,4 @@ +import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -186,6 +187,7 @@ function ProjectTilePicker({ /* ── List (issues) tab content ── */ function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) { + const showAgentCreatedTasks = useShowAgentCreatedTasks(companyId, "paperclip:project-issues-view"); const queryClient = useQueryClient(); const { data: agents } = useQuery({ @@ -218,8 +220,8 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan }); const { data: issues, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.listByProject(companyId, projectId), - queryFn: () => issuesApi.list(companyId, { projectId }), + queryKey: [...queryKeys.issues.listByProject(companyId, projectId), showAgentCreatedTasks], + queryFn: () => issuesApi.listAll(companyId, { projectId, includeRoutineExecutions: true, includeAgentCreatedTasks: showAgentCreatedTasks }), enabled: !!companyId, }); const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]); @@ -242,6 +244,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan projects={projects} liveIssueIds={liveIssueIds} projectId={projectId} + enableAgentCreatedVisibilityFilter viewStateKey="paperclip:project-issues-view" onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} /> diff --git a/ui/storybook/generated/feature-index.json b/ui/storybook/generated/feature-index.json index b7d02a273be..88bf72fdfc0 100644 --- a/ui/storybook/generated/feature-index.json +++ b/ui/storybook/generated/feature-index.json @@ -171,7 +171,7 @@ { "id": "inbox-search", "title": "Inbox, decisions, and search", - "description": "Personal triage, decision queues, unread/blocked views, and search.", + "description": "Personal triage, decision queues, unread/blocked views, search, and personal task toasts.", "capabilities": [ { "id": "inbox-search/views", @@ -192,6 +192,11 @@ "id": "inbox-search/search", "label": "Search", "description": "query company work and follow a result to its original record." + }, + { + "id": "inbox-search/toasts", + "label": "Toasts", + "description": "receive live task notifications only for personal participation or explicit mentions." } ] }, diff --git a/ui/storybook/stories/tasks/external-objects.stories.tsx b/ui/storybook/stories/tasks/external-objects.stories.tsx index 7e45ad9b13d..2a0069bc1ac 100644 --- a/ui/storybook/stories/tasks/external-objects.stories.tsx +++ b/ui/storybook/stories/tasks/external-objects.stories.tsx @@ -647,7 +647,7 @@ function FilterPopoverWithExternalChecked() { projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))} labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId="user-board" - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" creators={[ { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, diff --git a/ui/storybook/stories/tasks/issue-management.stories.tsx b/ui/storybook/stories/tasks/issue-management.stories.tsx index ae060fdb15d..e147873165a 100644 --- a/ui/storybook/stories/tasks/issue-management.stories.tsx +++ b/ui/storybook/stories/tasks/issue-management.stories.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { creationVisibilityStories } from "../../support/issue-creation-visibility"; import type { Issue, IssueLabel, Project } from "@paperclipai/shared"; import type { RunForIssue } from "@/api/activity"; import { useQueryClient } from "@tanstack/react-query"; @@ -594,7 +595,7 @@ function OpenFiltersPopover() { projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))} labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId="user-board" - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter buttonVariant="outline" creators={[ { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, @@ -771,7 +772,7 @@ function IssueManagementStories() { viewStateKey={issueListViewKey} onUpdateIssue={() => undefined} createIssueLabel="issue" - enableRoutineVisibilityFilter + enableAgentCreatedVisibilityFilter /> @@ -939,3 +940,20 @@ export const IssuePropertiesRelationshipBadgesInline: Story = { name: "IssueProperties - relationship badges inline", render: () => , }; + + +export const AgentCreatedTasksHidden: Story = { + ...creationVisibilityStories.hidden, + name: "Agent-created tasks — hidden by default", + play: creationVisibilityStories.hidden.play, +}; +export const AgentCreatedTasksIncluded: Story = { + ...creationVisibilityStories.included, + name: "Agent-created tasks — explicit opt-in", + play: creationVisibilityStories.included.play, +}; +export const AgentCreatedTasksFilter: Story = { + ...creationVisibilityStories.filter, + name: "Agent-created tasks — filter control", + play: creationVisibilityStories.filter.play, +}; diff --git a/ui/storybook/support/issue-creation-visibility.tsx b/ui/storybook/support/issue-creation-visibility.tsx new file mode 100644 index 00000000000..1c6f81cd3e4 --- /dev/null +++ b/ui/storybook/support/issue-creation-visibility.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import type { StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { useQueryClient } from "@tanstack/react-query"; +import { IssuesList } from "@/components/IssuesList"; +import { defaultIssueFilterState } from "@/lib/issue-filters"; +import { saveTaskCollectionPreferences } from "@/lib/task-collection-preferences"; +import { queryKeys } from "@/lib/queryKeys"; +import { createIssue, storybookAgents, storybookAuthSession, storybookCompanies, storybookProjects } from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +type Story = StoryObj; + +function hydrateStorybookQueries(queryClient: ReturnType) { + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); + queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); + queryClient.setQueryData(queryKeys.agents.list(companyId), storybookAgents); + queryClient.setQueryData(queryKeys.projects.list(companyId), storybookProjects); + queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: false, enableStreamlinedUi: true }); +} + + +const creationVisibilityTasks = [ + createIssue({ id: "visibility-human", identifier: "PAP-1701", title: "Human request", createdByUserId: "user-board", createdByAgentId: null }), + createIssue({ id: "visibility-agent", identifier: "PAP-1702", title: "Agent root task", createdByUserId: null, createdByAgentId: "agent-codex" }), + createIssue({ id: "visibility-channel", identifier: "PAP-1707", title: "Chat channel task", originKind: "chat_channel", createdByUserId: "user-board", createdByAgentId: null }), + createIssue({ id: "visibility-routine", identifier: "PAP-1703", title: "Scheduled routine run", originKind: "routine_execution", createdByUserId: null, createdByAgentId: null, responsibleUserId: "user-board" }), + createIssue({ id: "visibility-child", identifier: "PAP-1704", title: "Agent subtask", parentId: "visibility-human", createdByUserId: null, createdByAgentId: "agent-codex" }), + createIssue({ id: "visibility-ancestor", identifier: "PAP-1705", title: "Agent ancestor", createdByUserId: null, createdByAgentId: "agent-codex" }), + createIssue({ id: "visibility-human-child", identifier: "PAP-1706", title: "Human follow-up", parentId: "visibility-ancestor", createdByUserId: "user-board", createdByAgentId: null }), +].map((issue) => ({ ...issue, status: "todo" as const, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, executionWorkspaceId: null, currentExecutionWorkspace: null, startedAt: null, labelIds: [], labels: [] })); + +function CreationVisibilityStory({ included = false }: { included?: boolean }) { + const queryClient = useQueryClient(); + const viewStateKey = `storybook:agent-created-tasks:${included ? "included" : "hidden"}`; + useState(() => { + hydrateStorybookQueries(queryClient); + saveTaskCollectionPreferences({ companyId, collectionKey: viewStateKey, legacyViewStorageKey: `${viewStateKey}:${companyId}` }, { + viewState: { ...defaultIssueFilterState, showAgentCreatedTasks: included, viewMode: "list", groupBy: "none", sortField: "updated", sortDir: "desc", nestingEnabled: true, collapsedParents: [], collapsedGroups: [] }, + columns: ["status", "id", "assignee", "updated"], + }); + const listKey = queryKeys.issues.list(companyId); + const ancestor = creationVisibilityTasks.find((issue) => issue.id === "visibility-ancestor")!; + queryClient.setQueryData([...listKey, "hierarchy-ancestors", ancestor.id], [ancestor]); + queryClient.setQueryData([...listKey, "hierarchy-children", ["visibility-human", "visibility-human-child"], [], 100], { issues: [creationVisibilityTasks.find((issue) => issue.id === "visibility-child")!], hasMore: false }); + return true; + }); + return ( +
    + undefined} + /> +
    + ); +} + +const hidden: Story = { + name: "Agent-created tasks — hidden by default", + render: () => , + parameters: { docs: { description: { story: "Production task collection with simulated creation metadata and hierarchy reads. Agent and routine roots are hidden; agent subtasks and ancestors remain as context. Server participation and pagination are covered separately." } } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Human request")).toBeVisible(); + await expect(canvas.getByText("Agent subtask")).toBeVisible(); + await expect(canvas.getByText("Agent ancestor")).toBeVisible(); + await expect(canvas.queryByText("Agent root task")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Scheduled routine run")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Chat channel task")).not.toBeInTheDocument(); + }, +}; + +const included: Story = { + name: "Agent-created tasks — explicit opt-in", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Agent root task")).toBeVisible(); + await expect(canvas.getByText("Scheduled routine run")).toBeVisible(); + await expect(canvas.getByText("Chat channel task")).toBeVisible(); + }, +}; + +const filter: Story = { + name: "Agent-created tasks — filter control", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Human request")).toBeVisible(); + canvas.getByTitle("Filter").click(); + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByRole("checkbox", { name: /show agent-created tasks/ })).not.toBeChecked(); + }, +}; + +export const creationVisibilityStories = { hidden, included, filter }; From a0651c96f8b4110d8fbd3f8e446fa2770cd9388b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 13 Oct 2026 07:43:46 -0700 Subject: [PATCH 14/85] Add database-free process liveness probes (#16101) Expose process liveness without database or credential queries while preserving hostname and runtime identity guards. Deep health and sleep/wake readiness checks retain their existing behavior. Co-Authored-By: Paperclip --- ...-instance-sleep-and-scheduler-inventory.md | 7 ++ maps/agent-access-map/instance-operations.md | 8 ++ maps/feature-map/instance-operations.md | 6 + server/src/__tests__/liveness.test.ts | 109 ++++++++++++++++++ server/src/__tests__/openapi-routes.test.ts | 20 +++- server/src/app.ts | 4 + server/src/middleware/idle-admission.ts | 6 + server/src/routes/liveness.ts | 20 ++++ server/src/routes/openapi.ts | 24 ++++ 9 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/liveness.test.ts create mode 100644 server/src/routes/liveness.ts diff --git a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md index 8177461f43b..0ae5e2a499f 100644 --- a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md +++ b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md @@ -399,6 +399,13 @@ flowchart LR explicit deep readiness still tests DB health, and signed claim/bootstrap mutations retain their existing validation and tracking. + Implementation progress: `/api/liveness` reports startup state and the public + commit without auth/session resolution, SQL, or provider I/O. It is mounted + after hostname and signed-bootstrap validation, before standby/auth, and its + exact GET/HEAD route stays reachable without adding a sleep hold. `/api/health` + retains database readiness and signed bootstrap. Callers must opt into the + liveness endpoint; this does not claim all external probes are migrated. + - [ ] **DB-05 — Audit external probes and configured endpoints.** Inspect the fleet gateway, uptime monitors, deployment checks, DB pool settings, and Neon suspend configuration. The [ECS example](../../docker/ecs-task-definition.json) diff --git a/maps/agent-access-map/instance-operations.md b/maps/agent-access-map/instance-operations.md index d51cccf323f..9728d1085b3 100644 --- a/maps/agent-access-map/instance-operations.md +++ b/maps/agent-access-map/instance-operations.md @@ -88,6 +88,14 @@ Review: `baseline`. API: `present` — representative operation IDs: - `GET /api/health`. +- `GET /api/liveness` and `HEAD /api/liveness` — public process startup status + and build commit only; no database readiness or workspace identity. + +Liveness is anonymous and runs before actor/session resolution. Hostname and +signed-bootstrap endpoint validation still apply. It grants no instance control +authority. [Liveness tests](../../server/src/__tests__/liveness.test.ts) cover idle +drain, invalid ambient credentials, unavailable SQL, and startup recovery with +local Express fixtures; no live provider or agent access is claimed. Agent access: `partial` — REST health plus local CLI doctor. diff --git a/maps/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md index 0e02f78a36d..e83ef87450f 100644 --- a/maps/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -14,6 +14,12 @@ Agent access: [companion](../agent-access-map/instance-operations.md). - `diagnostics`: use doctor/health output to distinguish readiness from a listening port. - `backup`: create and inspect backups and verify restore only into a separate disposable target. +Routine HTTP monitoring may use `GET /api/liveness` (or `HEAD`). It returns only +process startup status and the public build commit without SQL or authentication. +It remains reachable during an idle drain. Use `/api/health` for explicit database +readiness, signed bootstrap, and authorized diagnostics; liveness does not +establish database health or claim a workspace. + ## How to get to it (user POV) ### `operator-cli` diff --git a/server/src/__tests__/liveness.test.ts b/server/src/__tests__/liveness.test.ts new file mode 100644 index 00000000000..d8ce096df56 --- /dev/null +++ b/server/src/__tests__/liveness.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { livenessRoutes } from "../routes/liveness.js"; +import { healthRoutes } from "../routes/health.js"; +import { privateHostnameGuard } from "../middleware/private-hostname-guard.js"; +import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js"; +import { cloudWarmStandbyMiddleware } from "../middleware/cloud-warm-standby.js"; +import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js"; +import { idleWorkSnapshot, startTaskDrain, stopTaskDrain } from "../services/task-admission.js"; +import { resetStartupRecoveryStateForTests, setStartupRecoveryPhase } from "../startup-recovery-state.js"; +import { getServerInfoSnapshot } from "../server-info.js"; + +afterEach(() => { stopTaskDrain(); resetStartupRecoveryStateForTests(); }); + +function fixture(standby = false) { + const db = { execute: vi.fn().mockRejectedValue(new Error("database offline")) } as unknown as Db; + const auth = vi.fn((_req, _res, next) => next()); + const health = healthRoutes(db, { + deploymentMode: "authenticated", deploymentExposure: "public", authReady: true, + companyDeletionEnabled: false, runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "fixture-token" }, + isWarmStandby: () => standby, + }); + const app = express(); + app.use(idleAdmissionMiddleware); + app.use(privateHostnameGuard({ enabled: true, allowedHostnames: ["allowed.example"], bindHost: "0.0.0.0" })); + app.use(cloudRuntimeIdentityMiddleware(db)); + app.use("/api/liveness", livenessRoutes()); + app.use(cloudWarmStandbyMiddleware(() => standby, health)); + app.use(auth); + app.use("/api/health", health); + app.get("/api/private", (_req, res) => res.sendStatus(401)); + trackIdleRequestHandlers(app); + const http = request.agent(app).set("host", "allowed.example"); + return { app, http, db, auth }; +} + +describe("database-free process liveness", () => { + it.each([false, true])("does not resolve auth or query SQL with an unavailable database (standby: %s)", async standby => { + const f = fixture(standby); + for (let i = 0; i < 3; i++) { + const response = await f.http.get("/api/liveness") + .set("authorization", "Bearer invalid-fixture") + .set("cookie", "session_token=invalid-fixture") + .set("x-paperclip-cloud-tenant-token", "invalid-fixture"); + expect(response.status).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + const serverInfo = getServerInfoSnapshot(); + expect(response.body).toEqual({ status: "ok", commit: serverInfo.git.available ? serverInfo.git.fullSha : null }); + } + const head = await f.http.head("/api/liveness").expect(200); + expect(head.text).toBeUndefined(); + expect(f.auth).not.toHaveBeenCalled(); + expect(f.db.execute).not.toHaveBeenCalled(); + expect(idleWorkSnapshot().active).toBe(0); + if (!standby) { + await f.http.get("/api/health").expect(503); + expect(f.db.execute).toHaveBeenCalledOnce(); + await f.http.get("/api/private").expect(401); + expect(f.auth).toHaveBeenCalledTimes(2); + } + }); + + it.each(["starting", "recovering"] as const)("reports %s until startup recovery is complete", async phase => { + const f = fixture(); + setStartupRecoveryPhase(phase); + await f.http.get("/api/liveness").expect(503).expect(response => expect(response.body.status).toBe("starting")); + setStartupRecoveryPhase("ready"); + await f.http.get("/api/liveness").expect(200); + expect(f.auth).not.toHaveBeenCalled(); + expect(f.db.execute).not.toHaveBeenCalled(); + }); + + it("retains hostname and signed bootstrap endpoint validation", async () => { + const f = fixture(); + await f.http.get("/api/liveness").set("host", "denied.example").expect(403); + await f.http.get("/api/liveness").set("x-paperclip-cloud-runtime-identity", "invalid-fixture") + .expect(400).expect({ error: "cloud_runtime_identity_wrong_endpoint" }); + expect(f.auth).not.toHaveBeenCalled(); + expect(f.db.execute).not.toHaveBeenCalled(); + }); + + it("stays reachable during drain without exempting writes or other paths", async () => { + const f = fixture(); + const before = idleWorkSnapshot(); + startTaskDrain({ purpose: "idle", ttlMs: 60_000 }); + for (const path of ["/api/liveness", "/api/liveness/"]) { + await f.http.get(path).expect(200); + await f.http.head(path).expect(200); + await f.http.post(path).expect(503); + } + await f.http.get("/api/liveness/details").expect(503); + await f.http.get("/api/private").expect(503); + expect(idleWorkSnapshot()).toEqual(before); + expect(f.auth).not.toHaveBeenCalled(); + expect(f.db.execute).not.toHaveBeenCalled(); + }); + + it("mounts the production route after host and bootstrap validation and before standby/auth", () => { + const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8"); + const route = source.indexOf('app.use("/api/liveness", livenessRoutes())'); + expect(route).toBeGreaterThan(source.indexOf("privateHostnameGuard({")); + expect(route).toBeGreaterThan(source.indexOf("app.use(cloudRuntimeIdentityMiddleware(db))")); + expect(route).toBeLessThan(source.indexOf("app.use(cloudWarmStandbyMiddleware(")); + expect(route).toBeLessThan(source.indexOf("actorMiddleware(db,")); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index a4093e52e25..3ab8e3d3dee 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -55,6 +55,7 @@ const apiPrefixes: Record = { "folders.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", + "liveness.ts": "/api/liveness", "inbox-agent-policy.ts": "/api", "inbox-dismissals.ts": "/api", "instance-database-backups.ts": "/api", @@ -87,9 +88,9 @@ const apiPrefixes: Record = { }; const ROUTE_LITERAL_PATTERN = - /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g; -const ROUTE_ARRAY_PATTERN = /router\.(get|post|put|patch|delete)\(\s*\[([^\]]+)\]/g; -const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/; + /router\.(get|head|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g; +const ROUTE_ARRAY_PATTERN = /router\.(get|head|post|put|patch|delete)\(\s*\[([^\]]+)\]/g; +const ROUTER_METHOD_PATTERN = /router\.(get|head|post|put|patch|delete)\(/; const HTTP_METHODS = new Set([ "get", "put", @@ -183,7 +184,7 @@ function resolveMountedPath(file: string, prefix: string, routePath: string) { ) { return routePath; } - if ((file === "companies.ts" || file === "health.ts") && routePath === "/") { + if ((file === "companies.ts" || file === "health.ts" || file === "liveness.ts") && routePath === "/") { return prefix; } if (file === "companies.ts" || file === "health.ts") { @@ -296,6 +297,17 @@ function loadSpecRoutes() { } describe("openapi routes", () => { + it("documents anonymous liveness separately from database readiness", () => { + const spec = buildOpenApiSpec() as any; + for (const method of ["get", "head"]) { + const operation = spec.paths["/api/liveness"][method]; + expect(operation.security).toEqual([]); + expect(operation.responses[503]).toBeDefined(); + } + expect(Object.keys(spec.paths["/api/liveness"].get.responses[200].content["application/json"].schema.properties)) + .toEqual(["status", "commit"]); + expect(spec.paths["/api/health"].get.responses[503]).toBeDefined(); + }); it("documents only writable agent update fields and lifecycle status requests", () => { const document = buildOpenApiSpec() as any; const properties = document.paths["/api/agents/{id}"].patch.requestBody.content["application/json"].schema.properties; diff --git a/server/src/app.ts b/server/src/app.ts index 5aa7c79b1db..b81bb16b9dc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -69,6 +69,7 @@ import { } from "./services/company-import-transfers.js"; import { companyTransferRunService } from "./services/company-transfer-runs.js"; import { healthRoutes } from "./routes/health.js"; +import { livenessRoutes } from "./routes/liveness.js"; import { cloudRuntimeIdentityMiddleware } from "./middleware/cloud-runtime-identity.js"; import { cloudControlMiddleware } from "./middleware/cloud-control.js"; import { cloudRoutes } from "./routes/cloud.js"; @@ -606,6 +607,9 @@ export async function createApp( const publicMcpIngress = Router(); app.use(cloudRuntimeIdentityMiddleware(db)); + // Routine probes stop before session/actor resolution, even after claim. + // Keep hostname validation and the signed-bootstrap endpoint guard above. + app.use("/api/liveness", livenessRoutes()); // A signed claim above commits identity before any normal request can seed // company data. Unclaimed probes bypass session resolution as well as SQL. app.use(cloudWarmStandbyMiddleware(isWarmStandby, health, staticUi)); diff --git a/server/src/middleware/idle-admission.ts b/server/src/middleware/idle-admission.ts index 9540d704dd0..96958837330 100644 --- a/server/src/middleware/idle-admission.ts +++ b/server/src/middleware/idle-admission.ts @@ -25,6 +25,12 @@ function isControlRequest(req: Request) { /** Install before body parsers, auth, webhooks and tool ingress. */ export const idleAdmissionMiddleware: RequestHandler = (req, res, next) => { + // This exact route is synchronous and has no auth, SQL, or accepted work. + // Probing it must not prevent an otherwise quiescent instance from draining. + if (req.path.replace(/\/$/, "") === "/api/liveness" && ["GET", "HEAD"].includes(req.method)) { + next(); + return; + } const controlRequest = isControlRequest(req); if (!controlRequest && isIdleTaskDrainActive()) { res.set("Retry-After", "1").status(503).json({ error: "instance_preparing_to_sleep" }); diff --git a/server/src/routes/liveness.ts b/server/src/routes/liveness.ts new file mode 100644 index 00000000000..d3b638ddca3 --- /dev/null +++ b/server/src/routes/liveness.ts @@ -0,0 +1,20 @@ +import { Router, type RequestHandler } from "express"; +import { getServerInfoSnapshot, type ServerInfoSnapshot } from "../server-info.js"; +import { getStartupRecoveryState } from "../startup-recovery-state.js"; + +/** Routine probes report process readiness without auth, SQL, or provider I/O. + * Database readiness and signed bootstrap remain on /api/health. */ +export function livenessRoutes(serverInfo: ServerInfoSnapshot = getServerInfoSnapshot()) { + const router = Router(); + const commit = serverInfo.git.available ? serverInfo.git.fullSha : null; + const respond: RequestHandler = (_req, res) => { + const ready = getStartupRecoveryState().phase === "ready"; + res.set("Cache-Control", "no-store").status(ready ? 200 : 503).json({ + status: ready ? "ok" : "starting", + commit, + }); + }; + router.head("/", respond); + router.get("/", respond); + return router; +} diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 25b6c7278f0..532e3a66a22 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1331,6 +1331,8 @@ const RUNTIME_TOOLS_OPERATIONS = new Set([ const PUBLIC_OPERATIONS = new Set([ "GET /api/agent-avatars/{version}/{palette}/{file}", "GET /api/health", + "GET /api/liveness", + "HEAD /api/liveness", "GET /api/openapi.json", "GET /api/board-claim/{token}", "POST /api/cli-auth/challenges", @@ -1871,6 +1873,28 @@ function applyDocumentFixups(document: any): any { // ─── Health ────────────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/liveness", + tags: ["health"], + summary: "Process startup status without database access", + description: "Runs before actor/session resolution. Reports only in-memory startup recovery and the public build commit. Use /api/health for explicit database readiness or signed bootstrap. No workspace identity or control authority is granted.", + responses: { + 200: r.ok(z.object({ status: z.literal("ok"), commit: z.string().nullable() }).strict()), + 503: { description: "Startup recovery is not complete", content: { + "application/json": { schema: z.object({ status: z.literal("starting"), commit: z.string().nullable() }).strict() }, + } }, + }, +}); + +registry.registerPath({ + method: "head", + path: "/api/liveness", + tags: ["health"], + summary: "Process startup status without a response body", + responses: { 200: { description: "Process startup recovery is complete" }, 503: { description: "Startup recovery is not complete" } }, +}); + // Shared by the healthy and database-unreachable responses: full details // (including serverInfo) ride only on board/agent-actor responses. const healthServerInfoSchema = z From 69f4bfc3c543ae8d60746b0c0f61f90ca90aed7a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:45:19 -0500 Subject: [PATCH 15/85] docs: add external bot integration skill (#16181) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - External agents need a connection, a work lifecycle, and clear authority. > - The Dot integration exposed gaps in setup, idle work, recovery, and Cloud delivery. > - Future integrations need a practical procedure that preserves those lessons. > - This pull request adds a core skill and six focused reference documents. > - The skill gives contributors concrete prompts, test cases, and failure diagnostics. ## Linked Issues or Issue Description **Issue type** Missing documentation. **Where is the issue?** Repository contributor skills for external agent integration. **What's wrong?** The integration process has no single reusable guide for provider discovery, both work directions, setup, managed Runner execution, and live proof. Lessons from Dot are spread across implementation changes. **Suggested fix** Add `create-external-bot-integration` under `.agents/skills`. Keep the core workflow short. Put detailed contracts, examples, tests, diagnostics, and dated evidence in references. Related public implementation: Refs #15402, #15414, #15581, #15691, #15722, #15734. A search found no duplicate skill PR. ## What Changed - Add a core skill for external agents and its Codex interface metadata. - Add references for the release contract, Runner architecture, onboarding, qualification tests, deployment, and the Dot case study. - Include idle intake, native tool use, scoped authority, receipt recovery, and resource cleanup. - Mark provider facts and code paths as dated evidence. Use public PRs for the source trail. - Change only eight files under `.agents/skills/create-external-bot-integration`. ## Verification - Skill validator: passed. - Interface metadata, Markdown fences, and 49 local links and anchors: passed. - `git diff --check`: passed. - PR file scope: verified that all eight paths are inside the skill folder. - `pnpm build` and `pnpm -r typecheck`: passed in the isolated worktree. - `pnpm exec vitest run --project @paperclipai/skills-catalog packages/skills-catalog/src/shipped-catalog.test.ts`: 10 tests passed after the description-budget fix. - The broad local `pnpm test:run` attempt reported 12 native-session lease test failures. It is not a passing full-suite result. One isolated lease case passed. The PR changes no runtime files. - Final commit `bd7aed82f623ee279c800af2b8b87f7bedd6be5c`: all GitHub checks completed, with 55 success results and 2 skipped results. - Greptile reviewed that exact commit at 5/5. There are no open review threads. ## Risks - Provider behavior and code paths can change. The skill requires current code and official documentation checks before use. - The case study describes one integration campaign. It does not claim that every provider supports the same transport or capabilities. - This change adds contributor instructions and metadata. It changes no application code or database schema. ## Model Used OpenAI Codex, GPT-6, with reasoning, filesystem access, shell tools, and code review support. The exact serving endpoint ID 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 --- .../create-external-bot-integration/SKILL.md | 152 ++++++++++++ .../agents/openai.yaml | 4 + .../references/conformance-tests.md | 211 ++++++++++++++++ .../references/dot-case-study.md | 188 +++++++++++++++ .../references/onboarding-and-prompts.md | 200 ++++++++++++++++ .../references/release-contract.md | 141 +++++++++++ .../references/runner-architecture.md | 226 ++++++++++++++++++ .../troubleshooting-and-deployment.md | 215 +++++++++++++++++ 8 files changed, 1337 insertions(+) create mode 100644 .agents/skills/create-external-bot-integration/SKILL.md create mode 100644 .agents/skills/create-external-bot-integration/agents/openai.yaml create mode 100644 .agents/skills/create-external-bot-integration/references/conformance-tests.md create mode 100644 .agents/skills/create-external-bot-integration/references/dot-case-study.md create mode 100644 .agents/skills/create-external-bot-integration/references/onboarding-and-prompts.md create mode 100644 .agents/skills/create-external-bot-integration/references/release-contract.md create mode 100644 .agents/skills/create-external-bot-integration/references/runner-architecture.md create mode 100644 .agents/skills/create-external-bot-integration/references/troubleshooting-and-deployment.md diff --git a/.agents/skills/create-external-bot-integration/SKILL.md b/.agents/skills/create-external-bot-integration/SKILL.md new file mode 100644 index 00000000000..6694d9643cb --- /dev/null +++ b/.agents/skills/create-external-bot-integration/SKILL.md @@ -0,0 +1,152 @@ +--- +name: create-external-bot-integration +description: >- + Build and qualify external bots such as Dot in Paperclip. Use for two-way work, + invitation, identity, wakeup, Runner lifecycle, native tools, recovery, and + Cloud delivery. CLI adapters and app-only connectors use their existing skills. +--- + +# Create an External Bot Integration + +Make an existing external agent a useful Paperclip participant: Paperclip can +assign work to it, and it can initiate governed work from its own conversation. +It uses its relevant native capabilities, returns inspectable results to +Paperclip, and participates in normal admission, authority, and finalization. + +This workflow incorporates the September 30–October 9, 2026 Dot integration. +Provider facts and code paths in the references are dated evidence, not a claim +that every future bot supports the same APIs. Verify the current implementation +and official provider documentation before choosing a transport or changing code. + +## 1. Establish the release contract before narrowing the implementation + +Read [release-contract.md](references/release-contract.md). Infer already-stated +requirements and authorization from the conversation. Ask only about consequential +unknowns; do not ask the user to repeat “two way,” Cloud, or use of Runner. + +Record the target environment, acting identity, capabilities, allowed human setup +steps, delivery/stop expectations, and checkable finish line. Keep one compact +matrix in the existing plan or task. Follow repository conventions if creating a +plan file. “Works” must name its level: prototype, real provider, real Runner, +managed sandbox, deployed tenant, or unattended first-time onboarding. + +Reuse the current implementation and qualification evidence. Inspect the relevant +checkout, instructions, app/Cloud PRs, and Runner artifact. Do not recreate work +already merged or apply an old plan to a different deployed revision. + +## 2. Prove the uncertain provider boundary first + +Identify supported ways to authenticate, wake the agent, load tools, correlate +work, return results, and stop or revoke authority. Check events/subscriptions as +well as direct APIs and chat channels. Do not assume that a bot mention wakes +another bot, that MCP notifications wake an offline agent, or that a plugin listed +in settings is callable in the agent's current conversation. + +Use a synthetic company and stable public HTTPS endpoint. Start with a scripted +client for deterministic protocol tests, then the actual provider account: + +1. Fresh connection → callable read tool → verified event round trip. +2. Paperclip assignment → autonomous acceptance → persisted result → finalization. +3. Idle bot request → one governed intake → useful action → finalization. + +If idle autonomy is in scope, the third journey is a first milestone, not an +optional expansion. Do not make the user create a placeholder task for the bot. +Label every nudge, operator repair, simulated dependency, and manual tool call. +A successful assisted run does not prove prompt-only first-time setup. + +## 3. Reuse Runner while keeping placement and capability separate + +Read [runner-architecture.md](references/runner-architecture.md) before changing +execution or authority. For Paperclip, prefer the existing Runner infrastructure: +shared lifecycle, receipts, recovery, and a future sandbox boundary for workspace +tools. The bot's model and native tools can remain entirely at its provider. + +Use the ordinary configured managed execution environment on Cloud. One admitted +assignment reuses its Runner across messages and tool calls; idle connection +traffic does not allocate compute. A sandbox hosting Runner does not grant the +bot filesystem or shell access. Never add a fallback to the control-plane host. + +Implement dedicated agent identity, durable assignment/operation state, current +authority checks, and normal finalization. Treat a proposed alternative backend +as an explicit architecture decision with preserved invariants and evidence of +benefit, not as a reaction to the latest question about overhead. + +## 4. Build onboarding as a watched, resumable workflow + +Read [onboarding-and-prompts.md](references/onboarding-and-prompts.md). Start at +the natural UI entry: invite an external agent, select the provider, copy a setup +prompt, give it to the bot, and watch real connection checks. Reuse the product's +copy-prompt component and provider-independent invitation flow. + +Distinguish OAuth grant, runtime tool availability, subscription verification, +event confirmation, and agent readiness. Generate or renew pending prompts +automatically where safe; preserve connected agents. Finish with an explicit Done +action. Use stories for waiting, expiry, errors, pause, readiness, and recovery, +then verify the real rendered transitions and persistence. + +The prompt must tell the bot to use its own relevant tools/apps/skills and return +results. The Paperclip catalog governs Paperclip calls, not the bot's entire +runtime. Limit qualification restrictions to the specific test and Paperclip +destination. Never turn “use this test connection” into “use no other tools.” + +## 5. Qualify both correctness and the ordinary user experience + +Read [conformance-tests.md](references/conformance-tests.md) and select the rows +required by the release contract. Reuse existing Runner and Product E2E fixtures; +add regression cases for uncovered contracts instead of building a parallel test +system. Keep simulated protocol, actual provider, and actual deployment evidence +separate. A provider saying “done” is not proof of task disposition or cleanup. + +Test a lost response, duplicate wake, follow-up at completion, real controller +restart, authority loss, and cleanup. For recovery, compare the original run, +assignment, session, process identity, and lease. A restart test in which the +controller never restarted is invalid. Unknown mutation outcomes require +reconciliation, not a new request ID and a second execution. + +If setup needs a reboot, app reattachment, or manual inbox nudge, record the +functional success and the remaining onboarding defect separately. Do not quietly +teach the tester to navigate around a product defect and then call it self-serve. + +## 6. Deliver one identifiable candidate and verify the deployed path + +Read [troubleshooting-and-deployment.md](references/troubleshooting-and-deployment.md) +when setting up a test drive, diagnosing a failure, or deploying. Preserve one +environment manifest: checkout/revision, origin, local port, data directory, +tenant, image/Runner artifact, active task, and owned resources. Keep secrets out. +Retain the environment while the user/provider is testing; do not expire it +silently or reset the OAuth database behind a cached client registration. + +Use the repository's adapter, eval, PR, and release workflows when available. +The Paperclip `create-agent-adapter` skill covers shared configuration surfaces; +`paperclip-runner-release` coordinates a release; `test-it-for-real` validates the +actual experience. Discover their installed locations rather than assuming they +are siblings of this skill. This skill adds external-bot-specific contracts. + +Carry authorized merge and rollout through the app, ingress, and Runner artifact +dependencies. Verify the serving revision and repeat the main journeys on the +real tenant with ordinary defaults. Distinguish implementation defects, provider +behavior, infrastructure failures, and test/evidence defects. Bound retries by +the operation and observed cause; preserve the same resumable deployment request. + +Report supported capabilities, exact live evidence, interventions, unsupported +provider controls, remaining gates, and resource disposition. Never equate unknown +cost with zero cost, or revoked Paperclip access with confirmed global bot stop. +This skill supplies procedure, not new permission to message, grant access, spend, +publish, or deploy. Apply the current user's authorization and host policies. + +## References + +- [Release contract](references/release-contract.md): scope, capability matrix, + provider research, architecture decision, and finish-line template. +- [Runner architecture](references/runner-architecture.md): identity, state, + admission, receipts, follow-ups, recovery, files, and concrete Paperclip anchors. +- [Onboarding and prompts](references/onboarding-and-prompts.md): UI states, + consent, lifetime, tool loading, native capabilities, and reusable prompt text. +- [Conformance tests](references/conformance-tests.md): runnable journey recipes, + failure matrix, evidence schema, and release gates. +- [Troubleshooting and deployment](references/troubleshooting-and-deployment.md): + network planes, specific symptom-to-action diagnostics, stable environments, + managed execution, and release provenance. +- [Dot case study](references/dot-case-study.md): dated incidents, exact historical + protocol/tool names, observed limitations, and source trail. Read when comparing + a new provider to Dot or checking why a rule exists. diff --git a/.agents/skills/create-external-bot-integration/agents/openai.yaml b/.agents/skills/create-external-bot-integration/agents/openai.yaml new file mode 100644 index 00000000000..8614fc0219e --- /dev/null +++ b/.agents/skills/create-external-bot-integration/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Create External Bot Integration" + short_description: "Build and qualify external agents in Paperclip" + default_prompt: "Use $create-external-bot-integration to build and qualify this external agent through Paperclip Runner, including onboarding and both directions of work." diff --git a/.agents/skills/create-external-bot-integration/references/conformance-tests.md b/.agents/skills/create-external-bot-integration/references/conformance-tests.md new file mode 100644 index 00000000000..c079fc4c457 --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/conformance-tests.md @@ -0,0 +1,211 @@ +# External-bot conformance and qualification + +## Contents + +- [Keep the evidence levels explicit](#keep-the-evidence-levels-explicit) +- [Minimum real-provider journeys](#minimum-real-provider-journeys) +- [Select additional rows from the capability contract](#select-additional-rows-from-the-capability-contract) +- [Record evidence that survives a handoff](#record-evidence-that-survives-a-handoff) +- [Historical Paperclip test entry point](#historical-paperclip-test-entry-point) + +## Keep the evidence levels explicit + +| Level | What it proves | What it does not prove | +| --- | --- | --- | +| Unit / contract tests | Schema, policy, state transitions, selected races | Real provider or deployed routing | +| Scripted client + actual Rust/DB | End-to-end protocol and ordinary tool/finalizer integration | Provider tool loading, wake behavior, browser setup | +| Scripted client + managed sandbox | Remote artifact/transport, placement, cleanup | Actual external bot or Cloud tenant ingress | +| Actual bot + test drive | Provider behavior, real event/tool loop | Published app image or fresh tenant behavior | +| Actual bot + deployed tenant | Named deployed candidate and selected journeys | All accounts, long-duration reliability, public distribution | + +For UI, a Storybook fixture proves a rendered state, not that the backend reaches +that state. A deep link that bypasses onboarding does not prove discoverability. + +Use the current repo's `paperclip-evals`, `add-runner-eval`, and +`add-product-e2e-eval` procedures when relevant and available. Locate an existing +case before adding a new fixture. The recipes below specify outcomes; adapt their +tool calls to the actual provider. Do not infer live success from this document. + +## Minimum real-provider journeys + +### C01 — first connection from a fresh invitation + +Start with a synthetic company and no binding for the new agent. Enter through +ordinary agent creation, not its runtime deep link. Generate the prompt and give +it to the real bot. Use only the human actions agreed in the release contract. + +Require all of: correct scoped grant, actual read tool invocation, verified +subscription, correlated readiness challenge acknowledged, binding persisted, +agent ready, UI Done, and state still correct after refresh. Do not create a work +task or allocate a sandbox for the readiness challenge. Save the action log and +final screenshot. Record app attachment, reboot, manual consent, or inbox nudges +as interventions. A repaired setup can pass functionally while failing first-try UX. + +### C02 — Paperclip assigns useful work with no chat nudge + +Create an ordinary task with a fresh synthetic nonce. Example: + +```text +Calculate 17 + 25. Write a task document named integration-proof containing +“17 + 25 = 42” and the nonce . Read it back and finish using the +assignment's completion contract. Use no workspace or attachment access. +``` + +Assign through the normal UI/API and wait for the supported event path. Verify +explicit acceptance, agent/run attribution, document contents and revision count, +canonical completion receipt, run success, task disposition, and cleanup. A +nonce-specific artifact proves the correct task was processed. Do not submit its +result through a connector yourself and describe that as the bot's execution. + +### C03 — bot initiates work while idle + +With no active assignment, tell the real bot: + +```text +Create a Paperclip task titled and assign it to my responsible +human owner. Find that person using the connection's identity/people tools. +Start governed work yourself if necessary, then return the created task link. +``` + +Require one visible bot-authored intake, ordinary admission, actual human assignee, +one created target task, and intake completion. Repeat the intake request with the +same stable request ID before and after completion: no additional task/run. A +different prompt with the same ID must fail. Simulate admission failure: the bot +gets an actionable terminal result rather than waiting forever for an offer. + +### C04 — use native capabilities and bring back the result + +Use a user-authorized synthetic source in a native tool already connected to the +bot, deliberately absent from the Paperclip assigned-app catalog. Ask a question +whose answer requires that source. Require the actual native read and a useful +result persisted on the Paperclip task. Keep data/destination authority explicit; +do not create a new third-party grant solely to make this test pass. + +Run once after a narrowly scoped qualification test ends. Failure if the bot says +it lacks the tool merely because Paperclip does not list it. Also verify the +bot cannot substitute another Paperclip identity to bypass a denied operation. + +### C05 — follow-up plus a real controller restart + +Have the bot accept a task and report readiness once, then wait for a normal task +comment. Capture run/assignment/session, sandbox lease, Runner marker/PID/birth +fingerprint, and controller identity. Restart the owned controller and verify +that it actually changed. Wait through a realistic bounded reconnect gap. + +Post the resume instruction as an ordinary Paperclip comment. Require the same +assignment/session/Runner, read of that exact comment, one document result, one +finalization, one task run, and released resources. A dev supervisor that kept +the controller alive invalidates the experiment. A new run completing the task +is retry evidence, not recovery evidence. + +Separately force the comment to arrive at completion: unread comments survive +for the next turn; comments read by the active assignment do not run twice. + +### C06 — stop and revoked authority + +Cancel a synthetic active assignment, then attempt an effect using its old +authority. Verify rejection, fence delivery/acknowledgment, terminal run/assignment +reconciliation, and resource release. Repeat protocol tests for pause, reassignment, +membership loss, revoked grant, refresh replay, and budget stop as applicable. +Do not report global provider termination without provider-level evidence. + +## Select additional rows from the capability contract + +| ID / boundary | Stimulus | Required observation | +| --- | --- | --- | +| O01 pending renewal | Expire code while dialog open; reopen after refresh | Fresh usable prompt, same agent, no error-as-normal-flow. | +| O02 race | Pair while another tab renews | Completed binding preserved; no two active grants or renewal loop. | +| O03 lifecycle | Preparing, verifying, manually paused, awaiting approval | Setup allowed only in legitimate setup states; Done waits for readiness. | +| O04 provider runtime | Install/update plugin while bot conversation active | Actual new action usable; catalog refresh/attachment instructions truthful. | +| O05 lifetime | Rotate refresh token; replay old token; simulate long inactivity | Policy-specific renewal works; replay/revocation deny access; expired grants not revived. | +| O06 optional avatar | Same upload twice, other agent/company, restricted token, export/import | Self-scoped, normalized, idempotent; no private asset ID portability leak. | +| D01 event duplication | Duplicate and reorder signed event deliveries | Same mailbox/assignment processed; no duplicate effects. | +| D02 event delay | Deliver after acceptance deadline or during reconnect | Stale offer rejected/reconciled; no new authority from an old event. | +| D03 resubscribe | Subscription expires with outstanding work | Reverification and fresh reference to existing work; no replayed effects. | +| D04 cursor concurrency | Commit two mailbox writers around an inbox read | Cursor cannot skip a later-committed result. | +| D05 callback authority | Failed ownership challenge, wrong signature, replay where applicable | No unverified subscription or unauthorized state transition; correct signing material stays secret. | +| D06 callback egress | Private-address destination, redirect escape, timeout, repeated failure | Destination policy enforced; bounded attempts and visible failure; no internal-network access. | +| R01 response loss | Drop response after a document mutation | Same ID retrieves same effect/receipt; revision count stays one. | +| R02 ambiguity | Kill controller after external mutation starts | Unknown outcome surfaced; no automatic second effect. | +| R03 definite error | Read a nonexistent file/skill | Terminal bounded error; later tools and finish still work. | +| R04 stale controller | Late callback after ownership generation changes | Cannot overwrite newer state; late effect evidence retained. | +| R05 missing checkpoint | Remove/mismatch recovery evidence after dispatch | Reconciliation required; no replacement assignment disguised as resume. | +| R06 completion order | Finish with pending tools or changed report | Rejected until contract satisfied; canonical result retained. | +| R07 shutdown | Acknowledged shutdown before monitor's next poll; absent exit code | Clean exit recognized only with authenticated terminal evidence. | +| A01 identity | Cross-company IDs, wrong agent/resource, invalid pairing hint | Denied by server; no authority selected by caller fields. | +| A02 review/people | Review another agent's task; assign to a human | Legitimate mode-specific access works without granting unrelated task writes. | +| A03 live grant loss | Revoke setting/grant during a protected read | No new protected response after revocation. | +| A04 API fallback | Try file download/upload through generic API tool | Same attachment/workspace rules apply; no alternate-path bypass. | +| S01 skills/apps | Pinned synthetic skill; assigned gateway tool; tool revocation | Correct version/digest and normal gateway auth/receipts; stale catalog can't override authority. | +| F01 attachment | Answer only on page two; corrupt/hash-changed/oversized files | Correct bounded paging and integrity errors; no data from another task. | +| F02 artifact | Produce and register output | Downloaded bytes and SHA-256 match; task links the usable work product. | +| W01 workspace | Traversal, symlink, instance config, credential access | Confined or denied across every file and upload path. | +| W02 commands | Detached descendants, timeout, cancellation | Supervisor and descendants contained/terminated; unavailable platforms fail closed. | +| W03 concurrent edits | Two writes with same observed hash | At most one commits; second sees conflict. | +| C07 placement | Idle reads; many tools/messages in one active task | No idle compute; same admitted Runner/lease reused. | +| C08 artifact mismatch | Managed image contains pre-provider Runner | Capability/digest check rejects before advertising work. | +| C09 terminal failure | Allocation timeout, failed admission, orphan offer | Clear terminal receipt, no blocked future work, tracked cleanup. | +| C10 deploy | Fresh tenant and shipped images through real ingress | OAuth, anonymous consent, both work directions, and cleanup pass. | + +Long-duration subscription/refresh tests need real provider time as well as fake +clock unit tests. Select a duration consistent with the release claim; a five-minute +demo does not establish multi-day availability. Do not leave a paid soak running +without authorization, ownership, limits, and a supported monitoring mechanism. + +## Record evidence that survives a handoff + +Use one row per case and candidate; do not combine incompatible partial runs into +one green campaign. A useful record is: + +```yaml +case: C05 +candidate: + appCommit: + ingressCommit: + appImageDigest: + runnerArtifactDigest: +environment: +client: actual-provider # or scripted +startingState: +identities: + run: + assignment: + session: + lease: + controllerBefore: + controllerAfter: + processFingerprintBefore: + processFingerprintAfter: +interventions: [] +observed: +evidence: +cleanup: +outcome: PASS # FAIL / BLOCKED / UNVERIFIED / NOT_APPLICABLE +limits: +``` + +Keep secrets, callback signing keys, access/refresh tokens, pairing codes, and +private source data out of reports and public PRs. Internal IDs may belong in a +private qualification record; follow the repo's public issue/PR rules. + +## Historical Paperclip test entry point + +The October 9 integration used `server/src/__tests__/dot-runner.test.ts` for +actual Rust/PostgreSQL tests and an opt-in private Daytona case: + +```sh +pnpm exec vitest run server/src/__tests__/dot-runner.test.ts -t 'private Daytona Rust' +``` + +That candidate read `PAPERCLIP_DOT_DAYTONA_LIVE=1`, `DAYTONA_API_KEY`, an immutable +`PAPERCLIP_DOT_DAYTONA_IMAGE`, optional `PAPERCLIP_DOT_DAYTONA_RUNNER_BINARY`, and +`PAPERCLIP_DOT_DAYTONA_EVIDENCE`. Locate the current fixture and inspect its flags, +cost bounds, dependencies, and `finally` cleanup before running. Do not print +credentials. This case used a scripted MCP client; it never replaced real Dot +qualification. The command/flags are historical anchors, not a guaranteed current CLI. + +Run the smallest decisive tests while iterating, then the repository-required +typecheck/test/build and review gates on the final candidate. A stale broad suite +running across source edits is not evidence for the final revision. Separate +unrelated infrastructure failures from product regressions, retain failed attempts, +and re-run affected cases after fixes. diff --git a/.agents/skills/create-external-bot-integration/references/dot-case-study.md b/.agents/skills/create-external-bot-integration/references/dot-case-study.md new file mode 100644 index 00000000000..d544d11f85b --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/dot-case-study.md @@ -0,0 +1,188 @@ +# Dot integration: dated case study and source trail + +## Contents + +- [What the user actually wanted](#what-the-user-actually-wanted) +- [Incidents and the reusable judgment](#incidents-and-the-reusable-judgment) +- [Historical Dot protocol and tools](#historical-dot-protocol-and-tools) +- [What was and was not proven](#what-was-and-was-not-proven) +- [Sources and how to inspect them](#sources-and-how-to-inspect-them) + +This is evidence from September 30–October 9, 2026, reviewed from raw records on +October 13. It is not current provider documentation or permission to repeat old +operations. Use it to recognize failure modes and locate prior implementation. +No credentials, pairing codes, temporary ingress hosts, or personal app contents +are retained here. + +## What the user actually wanted + +- The existing personal Dot, not a replacement generic API agent. +- Paperclip could initiate work; Dot could read/write and initiate work while idle. +- Shared new Runner infrastructure. +- Eventually a full company participant: people, tasks, skills, apps, optional files. +- An external-agent invitation with a copy prompt and live checks. +- Persistent Paperclip connection until revoked. +- Cloud deployment with managed execution and no arbitrary host execution. +- Dot uses its own relevant tools and returns the result to Paperclip. + +Idle autonomy was explicit on day one and in the first production plan. Shipping +task-only access was an implementation omission, not missing user specification. +Cloud-first delivery, exact capability parity, allowed setup interventions, and +latency/concurrency needed an earlier explicit release contract. + +## Incidents and the reusable judgment + +| Date / incident | Observed outcome | Rule carried into this skill | +| --- | --- | --- | +| Sep 30: initial research favored Slack / another agent API | User pointed out MCP Events; official docs then supported testing that route. | Search server-initiated events before choosing transport; existing personal bot is a different product from an API agent. | +| Oct 2: lab assumed OAuth request ID was UUID | Real consent failed; request ID was opaque. | Preserve protocol identifiers as specified. | +| Oct 2: DB restart erased cached client registration | Provider returned unknown client/redirect; reconnect state had drifted. | Stable fixture includes registration and public origin, not just app health. | +| Oct 2: settings listed tools, Dot could not call them | Endpoint was stopped; Dot loaded actions later; replacement lab completed arithmetic round trip. | Keep test service alive during provider setup; test actual runtime invocation. | +| Oct 3: production Runner implementation | Rust/DB integration worked, but the new endpoint still lacked real-account qualification. | Reference harness, production Runner, and actual provider are distinct proof levels. | +| Oct 6–7: merged MCP gateway integration | Reused consent, verification, refresh, revocation; fixed normal shutdown race. | Reconcile shared infrastructure and its contracts instead of retaining a forked prototype. | +| Oct 7: public Tailscale metadata worked locally/externally, provider discovery timed out | No request reached app; Cloudflare discovery worked; provider browser had separate failures. | Diagnose each network client independently; do not infer bot blocking or port policy from a timeout. | +| Oct 7: uncompressed large bundle | Public download improved from 11.3 s to 2.9 s after compression; discovery problem remained. | Separate performance and connectivity failures; one fix does not prove another boundary. | +| Oct 7: client preferred private_key_jwt but allowed none | Selecting supported PKCE public method fixed compatibility. | Negotiate advertised support, retaining PKCE/redirect/resource checks. | +| Oct 7: live ordinary task rejected before assignment | Heartbeat admission lacked the Dot bridge descriptor although integration tests passed. | Enter through ordinary product scheduling, not only a broker fixture. | +| Oct 7: “create hello for my owner” refused while idle | Added governed intake and human assignees; actual Dot completed it. | Both directions are first-class acceptance journeys. | +| Oct 7: pairing ready but agent config missing binding | Required another form save until atomic persistence fix. | One logical setup operation must persist all required state. | +| Oct 7: missing-file read never settled | Turn could not close; terminal read-error receipt fixed it. | Distinguish definite failure from ambiguous mutation outcome. | +| Oct 7: workspace execution and file access expansion | cwd was insufficient; review found `.paperclip` leakage and API bypasses; macOS commands later disabled. | Trace effects and test all access paths; fail closed when confinement is unproven. | +| Oct 7: late event | One event arrived roughly 32 minutes later; completed work was not repeated. | Event delivery is asynchronous; test late offers and never claim a latency SLA from one success. | +| Oct 8: cloud architecture reconsidered repeatedly | User chose common Runner for lifecycle reuse and potential future workspace tools. | State decision criteria and retain the decision; placement is separate from tool access. | +| Oct 8: expired setup prompt and nested copy window | Added automatic renewal, automatic close on connection, progress footer and Done. | Normal asynchronous progress should not require manual repair. | +| Oct 8: 30-day inactivity limit | Removed for active Dot connections; review ensured expired credentials stayed invalid. | Separate persistent grant policy from token/code/lease lifetimes. | +| Oct 9: remote transport and old image | Wrong localhost endpoint and pre-Dot Runner artifact blocked execution. | Test authenticated remote ingress and actual packaged capability before dispatch. | +| Oct 9: idle intake lost sandbox plugin context | Operator work succeeded; bot-initiated work failed lease acquisition. | Both entry paths must use configured application services. | +| Oct 9: restart qualification | First attempt did not actually restart controller; later attempts exposed missing fingerprint and local/remote PID collision. | Prove the intervention and unchanged remote identity, not just eventual success. | +| Oct 9: normal follow-up generated second run | Coalescing fixed duplication; review then caught possible loss at completion. | Consume a wake only after exact comment delivery/read evidence. | +| Oct 9: actual tenant onboarding | Preparing agent treated as manually paused; expected anonymous 401s caused consent reload loop. | Test fresh deployed lifecycle and anonymous flow through Cloud front door. | +| Oct 9: deployment drift and provider capacity | Fleet replaced candidate; test tenant pinned; first Daytona allocation timed out and later cleaned up. | Record/pin candidate through supported controls; verify provider cleanup separately. | +| Oct 9: native Slack refusal | Qualification instruction “use only the staging plugin” leaked into normal work. | Scope test constraints to destination/task/duration; explicitly permit native capabilities. | + +## Historical Dot protocol and tools + +Transport was authenticated MCP inward and signed MCP Events outward. The +qualified event was `paperclip.dot.mailbox_updated`. Subscription methods included +`events/list`, `events/subscribe`, and `events/unsubscribe`; these are protocol +methods, not extra callable tools. The experiment used MCP version `2026-07-28`. +Verify current support and account eligibility instead of copying that version. + +The final top-level MCP catalog had 16 tools: + +| Purpose | Tool names | +| --- | --- | +| Identity and proactive work | `paperclip_dot_capabilities`, `paperclip_dot_request_turn`, `paperclip_dot_tasks`, `paperclip_dot_request_work` | +| Setup and inbox | `paperclip_dot_pair`, `paperclip_dot_inbox`, `paperclip_dot_confirm_event` | +| Assignment | `paperclip_dot_read`, `paperclip_dot_accept`, `paperclip_dot_tool` | +| Lifecycle and receipts | `paperclip_dot_progress`, `paperclip_dot_finish`, `paperclip_dot_operation_status`, `paperclip_dot_renew`, `paperclip_dot_control_ack` | +| Optional profile | `paperclip_dot_set_avatar` | + +The nested Runner catalog was read per assignment. Bot called its Paperclip tool +names via `paperclip_dot_tool`; native tools were called in the bot's own runtime. +Standard completion was `paperclip_finish` or `paperclip_block` through the bridge, +then `paperclip_dot_finish` with the accepted canonical report. Do not collapse +these steps into an unverified conversational “done.” + +Historical constants help reproduce old cases, not set new-provider defaults: + +- Instance settings: **Assistant connections (MCP)** and **OpenAI Dot**, with + authenticated sign-in and a stable `PAPERCLIP_PUBLIC_URL`. The old + `PAPERCLIP_ENABLE_OPENAI_DOT` environment flag no longer enabled the provider. + Self-hosted Dot did not require the general Runner picker flag; Cloud additionally + required native Runner configuration and the managed execution plugin. +- Pairing code: 15 minutes, one use. Access token: 15 minutes. +- Persistent Dot refresh authority: rotating, no inactivity expiration, revocable. +- Acceptance window: 10 minutes. Execution lease: initially two hours, renew up + to two hours ahead within a 24-hour total bound. +- Broker operations: initially 200, expanded to 4,000. One active assignment. +- Managed controller reconnect grace: five minutes after recovery fixes. +- Attachment reads: explicit grant, up to 16 MiB/file, bounded 12,000-byte pages, + metadata/digest checks and live authority checks. +- Cloud workspace access: off. Self-hosted Linux command execution required + confinement; macOS commands ultimately unavailable. +- Avatar: `paperclip_dot_set_avatar` accepted `{ "imageBase64": "" }` + or `null` to restore the preset. PNG/JPEG/WebP, at most 512 KiB and 16 megapixels; + normalized to metadata-free PNG no larger than 512 pixels per side. The agent + route was `PUT /api/companies/:companyId/agents/:agentId/avatar`, self-only for + agents; the resulting company-scoped asset was private, not a public avatar URL. + +These were implementation choices subject to later change. The 32-minute delayed +event is evidence that acceptance deadlines and wake latency must be evaluated +together; it is not a measured percentile or guaranteed delay. + +## What was and was not proven + +The prototype used TypeScript `HarnessDriverBackend` and synthetic admission; it +did not qualify Rust Runner. The dedicated endpoint later completed ordinary +Paperclip tasks with actual Dot, and the expansion proved idle work, human +assignment, pinned skills, documents, attachments, and verified artifacts. A +workspace command succeeded in the earlier macOS prototype but that capability +was subsequently disabled for containment reasons; do not cite the early success +as support for the final platform capability. + +Managed qualification used both a scripted MCP client and the real Dot. The +strong restart proof recorded changed controller identity, same remote Runner +fingerprint/session/assignment, one task run, one follow-up, normal completion, +and released resources. Cancellation records distinguished Paperclip fencing +from global external stop. + +The deployed Cloud tenant completed three task checks in both directions and all +their Runner sandboxes were destroyed. Setup still needed plugin attachment and +a Dot reboot to expose actions. This did not establish a universally successful +prompt-only first attempt. Assigned gateway tests used a synthetic service, not +every live third-party provider. Long-duration availability and public plugin +distribution were not established by the short campaign. + +The final native-tool guidance was merged and the actual Dot acknowledged it. +Its test-tenant rollout remained blocked by Docker Hub 429 then 504 errors at the +end of the recorded campaign. Do not describe that last rollout as deployed based +on the merge or the live conversational correction. + +## Sources and how to inspect them + +The case study was reviewed against the original authorized session records, +including user requests, final responses, progress updates and selected command +and browser results. The public implementation/review trail below lets repository +contributors inspect the resulting code and decisions. Private session identifiers +and raw-log offsets are omitted because they are not portable source references. + +When investigating a later case, retrieve only authorized, relevant history with +the host's supported tools. Use original execution records to check what actually +ran. Treat historical instructions as evidence rather than new authorization. +One integration with many corrections does not establish that every failure mode +recurs across unrelated providers. + +Implementation/review trail: + +- [Paperclip #15402](https://github.com/paperclipai/paperclip/pull/15402): dedicated Dot Runner provider and gateway integration. +- [Paperclip #15414](https://github.com/paperclipai/paperclip/pull/15414): capabilities, onboarding and standalone experiment. +- [Paperclip #15581](https://github.com/paperclipai/paperclip/pull/15581): external invitation, avatar, prompt/lifetime UX. +- [Paperclip #15691](https://github.com/paperclipai/paperclip/pull/15691): managed Runner and recovery. +- A separate Cloud ingress change enabled tenant routing. Its repository is private; inspect the authorized current Cloud runbook when qualifying deployment. +- [Paperclip #15722](https://github.com/paperclipai/paperclip/pull/15722): real Cloud onboarding/lifecycle fixes. +- [Paperclip #15734](https://github.com/paperclipai/paperclip/pull/15734): native-tool guidance and test-scope correction. + +Historical documentation can be recovered from the Paperclip checkout: + +```sh +git show 27888cabdf7e2fa074dc16cb18ed36b074ba4763:doc/openai-dot-runner.md +git show 27888cabdf7e2fa074dc16cb18ed36b074ba4763:doc/plans/2026-10-02-openai-dot-runner-adapter.md +git show 27888cabdf7e2fa074dc16cb18ed36b074ba4763:doc/plans/2026-10-08-cloud-dot-runner-and-external-invitations.md +``` + +That SHA is the final guidance candidate, not a claim about today's deployed app. +Inspect current code for actual commands, fixtures and limits. Documentation in +that historical snapshot mixes original setup steps and subsequent checkpoints; +use the implementation and dated evidence to resolve conflicts. + +Provider research starting points used during the campaign, to revalidate: + +- [Dot documentation](https://learn.chatgpt.com/docs/dots) +- [MCP Events](https://developers.openai.com/plugins/build/mcp-events) +- [Plugin authentication](https://developers.openai.com/plugins/build/auth) +- [Connect ChatGPT](https://developers.openai.com/plugins/deploy/connect-chatgpt) +- [Secure MCP tunnels](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) + +Do not infer that another external agent supports MCP Events, Dot's tool names, +private-plugin onboarding, or the same approval/distribution rules. diff --git a/.agents/skills/create-external-bot-integration/references/onboarding-and-prompts.md b/.agents/skills/create-external-bot-integration/references/onboarding-and-prompts.md new file mode 100644 index 00000000000..c6a2f34fea9 --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/onboarding-and-prompts.md @@ -0,0 +1,200 @@ +# Onboarding, consent, and agent instructions + +## Contents + +- [Design from the user's entry point](#design-from-the-users-entry-point) +- [Model the states the user will actually encounter](#model-the-states-the-user-will-actually-encounter) +- [Separate setup approvals from repeated intervention](#separate-setup-approvals-from-repeated-intervention) +- [Separate the four lifetimes](#separate-the-four-lifetimes) +- [Generate instructions the receiving agent can execute](#generate-instructions-the-receiving-agent-can-execute) +- [Test prompts must constrain the destination without disabling the bot](#test-prompts-must-constrain-the-destination-without-disabling-the-bot) +- [Keep optional identity polish narrowly scoped](#keep-optional-identity-polish-narrowly-scoped) + +## Design from the user's entry point + +For the Dot flow, the approved entry was **New Agent → Invite an external agent → +Dot / Hermes / Other**, not a hidden Runner provider configuration. Use the current +product's equivalent. A provider preset may customize its logo, instructions and +checks; it must not silently redesign another provider's enrollment protocol. +Hermes originally retained the existing external-agent prompt. + +Use the same square selection cards as the harness picker, actual provider logos, +the shared copy-prompt component, and the normal dialog footer. Preserve agent +details on Back. Animate changes in dialog size using the design system and honor +reduced motion. Follow current repository design/token instructions. + +Show prerequisite features and evaluate their actual server-side availability. +Dot's standalone experiment did not implicitly enable every Runner provider. +Cloud's managed execution also required its native Runner configuration. Distinguish +those environment prerequisites from the user-facing provider picker; do not make +users discover a disabled backend only after they have completed provider consent. + +Make the user-facing sequence short: + +1. Choose the bot and create/resume its agent invitation. +2. Copy the complete prompt and give it to that bot. +3. Grant any required scoped consent through the supported flow. +4. Watch connection, task updates, and a harmless test event. +5. Choose Done when both the connection and agent are ready. + +A readiness probe should not create a visible work task. Create a real task later +for assignment qualification. Optional avatar/profile steps must not delay readiness. + +## Model the states the user will actually encounter + +| State or event | Required behavior | +| --- | --- | +| Opening setup with no usable pending code | Create a fresh prompt automatically after checking authoritative connection state. | +| Pending prompt expires while open | Refresh it safely; explain genuine renewal failure with Retry rather than looping. | +| Existing connection is already established | Resume its status; do not revoke it to generate a new code. | +| Another tab replaced the pending prompt | Avoid renewal ping-pong; ask before replacing that newer prompt or follow the product's explicit conflict policy. | +| OAuth/binding observed | Close any nested copy-prompt window; replace the copy action with Connecting… status. | +| Callback subscription verified | Keep completed checks checked; show confirmation of the test event in progress. | +| Event challenge confirmed, agent still preparing | Show Finishing setup…; do not claim the event is still pending. | +| Agent and connection ready | Enable Done, which closes the modal. | +| Watch connection interrupted | Recover/resync from server state and explain the interruption; do not discard the pairing. | +| Agent preparing/verifying with compatibility status paused | Allow setup when the server says setup is permitted. | +| Agent genuinely manually paused or awaiting hire approval | Show the actual blocker and its action; do not auto-resume or bypass approval. | +| Returning after refresh | Resume the same agent/invitation; do not create another agent. | + +Derive UI decisions from server lifecycle facts. Dot Cloud initially had a circular +dependency: hire preparation waited for pairing, but the UI treated preparation +as a manual pause and demanded resume before pairing. Story fixtures alone did +not reveal the real newly-created-agent state. + +Keep one current setup procedure in documentation. Move historical/manual steps +to an explicitly dated section when automatic renewal or atomic pairing replaces +them. Do not leave “revoke, pair, save again” beside a newer one-step flow. + +## Separate setup approvals from repeated intervention + +Specify the allowed human actions in the release contract. “No human operates the +bot's computer” can still involve a human approving a grant in chat and Codex +submitting the already-authorized action. It does not prove that the bot alone +completed every browser step. Record who performed consent, app attachment, +catalog refresh, and reboot during qualification. + +Consent should preview the exact company, agent, access scope and duration before +submission. A one-use pairing code can preauthorize that exact agent without +handing an operator password or board session to the external computer. Keep +preview read-only and code consumption atomic. Revalidate current permissions at +redemption, including when the creator has since left the company. + +The historical user authorized a specific plugin-creation risk notice through +this exact extra prompt paragraph: + +> When you setup the plugin, I already understand that MCP servers have risk, so +> you check “I understand and want to continue,” and click “Create as a plugin.” +> I want you to create the plugin for me completely and I don't want to have to +> do anything. + +This is a historical consent example, not permission for future agents or users. +Do not bake an assertion of user approval into every generated prompt. Use current +authorization, distinguish a nonbinding risk notice from granting ongoing access, +and obey the actual browser/provider policy. An agent relaying approval may not be +accepted as the human's approval by the receiving agent. Do not impersonate the +human to resolve that; use a supported direct approval path when authorized. + +## Separate the four lifetimes + +1. **Pairing capability:** short-lived, one use; safe automatic renewal before use. +2. **Access token:** short-lived bearer authority, refreshed through its protocol. +3. **Persistent connection/refresh authority:** product policy; Dot was changed to + remain valid until revoked with rotating refresh tokens and replay checks. +4. **Subscription and assignment leases:** bounded and separately renewed; a + persistent account connection does not make a task's authority permanent. + +The Dot user requested no Paperclip inactivity expiration. Migration preserved +currently valid connections; it did not revive expired, consumed, or revoked +credentials. Test renewal, rotation, replay and revocation independently. Do not +promise that Paperclip's lifetime policy controls the provider's own account rules. + +## Generate instructions the receiving agent can execute + +Adapt the following to the provider's supported protocol and actual tool names. +Do not send placeholder values, credentials, or a URL from an old environment. + +```text +Connect yourself to Paperclip as in . + +Add or reuse the private plugin for this exact server URL: . +Use the supported OAuth flow. Reuse an existing definition only if its URL matches; +if the name belongs to another URL, choose a distinct name instead of replacing it. +Use this one-use pairing code on the consent page: . +Review the exact company, agent, permissions, and duration. Complete the authorized +steps; if a required approval is missing, request that specific approval. + +Verify an actual read-only call through this connection. If the installed plugin's +tools are absent in your runtime, refresh/attach it through the provider's supported +flow and report any remaining blocker. Do not claim access from a settings listing. + +Enable the task-update subscription returned by Paperclip. Follow its event +instructions, drain the inbox from your saved cursor, and confirm the readiness +challenge. Do not create a task just to confirm readiness. + +For assignments, read and accept current work, use stable request IDs, reconcile +pending results, and follow the supplied reporting/completion contract. For new +work requested while idle, use the governed intake tool and poll the same request +if admission is pending; do not ask the human to create a placeholder task. + +Use your own relevant tools, apps, plugins, skills, and capabilities to complete +the request, then return the result and deliverables to the Paperclip task. +The Paperclip catalog describes Paperclip operations. It does not disable your +native tools or require those tools to appear in that catalog. Respect the tools' +existing permissions and approvals. Keep Paperclip operations within the current +connection's authorized company, agent, and task; do not use another identity to +bypass a denied Paperclip operation. + +Report which connection checks actually succeeded and any exact blocker. +``` + +Put native-tool guidance at the points where it changes behavior: setup prompt, +server onboarding text, event instructions, capabilities, and each assignment. +Test rendered plain-text documents as well as JSON fields. The Dot follow-up +initially added guidance to the JSON manifest but omitted the `onboarding.txt` +renderer; agents receiving only that URL still missed it. + +Do not copy every instruction into every tool description. Keep one common source +where package boundaries permit. Use a lightweight dependency-free shared export +for UI copy to avoid importing a heavy shared barrel. The standalone Runner may +need its own small instruction constant with a clearly documented alignment point. + +Historical instruction surfaces to locate in the current checkout: + +- `ui/src/lib/dot-setup-prompt.ts` +- `ui/src/lib/agent-onboarding-prompt.ts` +- `server/src/routes/access.ts` and its actual onboarding text renderer +- `server/src/services/dot-runner-broker.ts` pairing/event/capability responses +- `packages/paperclip-runner/src/backends/runtime-context.ts` +- `packages/shared/src/external-agent-guidance.ts` + +## Test prompts must constrain the destination without disabling the bot + +Bad: “Use only the staging plugin.” Dot carried this into later work and refused +to use its already-connected native Slack. + +Use a narrowly scoped qualification instruction: + +```text +For qualification task only, send Paperclip operations through + for . This selects the Paperclip destination; +it does not restrict your relevant native tools. Do not alter other connections. +After this case, these test-only restrictions end. Return results to its task +using the normal reporting and completion tools. +``` + +If a case deliberately excludes native tools to isolate one transport, say so +for that case only, then end the restriction explicitly. Run a subsequent native +tool case to detect leakage of the test restriction into normal behavior. + +## Keep optional identity polish narrowly scoped + +Dot's avatar tool accepts image bytes for its own bound Paperclip agent, re-encodes +them, and never grants generic agent administration. Validate format, byte and +pixel limits; remove metadata; make replay safe. A private asset ID cannot simply +be exported to another company. Preserve portable name/character/palette and omit +or remap company-local image IDs on import. + +Do not invent an avatar-export API or extract an unrelated image just because the +UI has an avatar. Ask the bot to upload its current avatar only if available; lack +of that capability must not block connection or task qualification. diff --git a/.agents/skills/create-external-bot-integration/references/release-contract.md b/.agents/skills/create-external-bot-integration/references/release-contract.md new file mode 100644 index 00000000000..25da66f587d --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/release-contract.md @@ -0,0 +1,141 @@ +# Release contract and provider discovery + +## Contents + +- [Make the requested outcome testable](#make-the-requested-outcome-testable) +- [Define capability parity rather than promising “full-fledged”](#define-capability-parity-rather-than-promising-full-fledged) +- [Research only what can change the next decision](#research-only-what-can-change-the-next-decision) +- [Choose the execution model once, using stated criteria](#choose-the-execution-model-once-using-stated-criteria) +- [Keep a finite delivery sequence](#keep-a-finite-delivery-sequence) + +## Make the requested outcome testable + +An external bot is an existing agent whose execution is hosted elsewhere. It may +have memory, a browser, skills, accounts, and tools Paperclip neither supplies nor +controls. Decide whether this integration connects that existing agent or creates +a separate API agent. They can have different identity, billing, tools, and memory. + +Do not substitute a provider's generic agent API for the user's personal bot +without making that distinction explicit. Dot's initial investigation considered +both; the user wanted the actual existing Dot. + +Use this contract in the task or existing implementation plan: + +```text +Provider / existing bot or newly created API agent: +Ordinary entry point and target users: +Release targets: self-hosted / Cloud / both: +Transport outward / return channel / provider evidence: +Paperclip acting identity and company/agent mapping: +Paperclip-originated work acceptance journey: +Bot-originated idle work acceptance journey: +Native tools policy: +Required Paperclip capabilities / explicitly deferred capabilities: +Workspace access and execution placement: +Allowed human setup steps: +Connection lifetime / revocation / subscription renewal: +Latency, concurrency, run duration, resource and test-spend bounds: +Provider controls unavailable or unknown: +Target deployment and authorization already established: +Finish line and evidence required: +Next critical-path gate / owner / action: +``` + +Fill from context and supported defaults. Ask only where an unknown changes the +product, authority, cost, or acceptance. Do not turn the template into a mandatory +questionnaire. If the user says “two way,” keep both directions in the finish line. +If Cloud is deferred, state that before delivering an apparently complete agent. + +## Define capability parity rather than promising “full-fledged” + +For each area, mark Required, Optional, Deferred, or Provider unavailable. Add +whether it works idle, during a run, after completion, and after revocation. + +| Area | Questions that affect implementation | +| --- | --- | +| Identity | Is the caller a company agent or a human's personal assistant? Can one external bot bind multiple agents? | +| Idle intake | Can the bot create governed work when no task is assigned? Does the user see the intake and its attribution? | +| Coordination | Search/read tasks; create and assign to agents or people; comment and edit authorized cross-task documents? | +| Native tools | Can the bot use its existing Slack/browser/computer? What permissions remain provider-owned? | +| Assigned tools | Pinned Paperclip skills, gateway apps, dynamically granted tools, permission changes during a turn? | +| Attachments | Which task/company files may leave Paperclip, how are pages/integrity checked, and can access be revoked mid-read? | +| Workspace | Files, shell, Git, artifact publishing; whose machine; explicit settings; which platforms? | +| Human interaction | Questions, approval requests, replies, denial, resumption, and visibility in the task thread? | +| Lifecycle | Follow-up, interruption, cancellation, restart, reconnect, lease renewal, and a terminal failure path? | +| Observability | Acceptance, progress, operation results, persisted output, task disposition; which provider data is absent? | +| Economics | Provider usage/cost versus managed Runner cost; admission budget checks when provider usage is unknown? | +| Identity presentation | Provider logo, agent name/avatar, export/import portability; optional profile work must not block connection. | + +Three separate grants deserve explicit treatment: persistent agent connection, +access to task attachments, and Paperclip workspace execution. One does not imply +the others. For Dot, attachment and workspace settings were independently off by +default and were never inherited automatically by a newly hired teammate. + +## Research only what can change the next decision + +Use official provider docs and current code. Record the source, date, fact, and +remaining experiment. Classify claims as documented, observed with real provider, +simulated, inferred, or unknown. Search for these capabilities before selecting: + +- Direct create/message/status/cancel APIs for this exact kind of agent. +- Out-of-band events, subscription discovery/renewal, callbacks and verification. +- In-session notifications versus wakeup when no live conversation is connected. +- Bot-to-bot mentions, DMs, channel permissions, and automated-message filtering. +- Tool/plugin installation, OAuth discovery, public-client support, runtime loading. +- Structured tool calls/results, correlation IDs, idempotency, and result polling. +- Provider-native session identity, resume, interruption, usage, and rate limits. +- Private testing, workspace distribution, public review, and account/admin limits. + +An HTTP success from a notification endpoint proves receipt at that layer. It +does not prove that the agent woke, loaded tools, accepted work, or finished. +Browser automation can help investigate and qualify setup; do not accidentally +make an undocumented UI automation path the production transport. + +For a proposed chat transport, send one authorized synthetic automated message +and require the bot's correlated callback. A human-written mention succeeding is +not evidence that messages from another bot will wake it. + +## Choose the execution model once, using stated criteria + +Paperclip's established preference is shared Runner infrastructure for remote +agents. It preserves existing lifecycle/receipt semantics and a sandbox boundary +if workspace capabilities are granted later. Remote reasoning does not eliminate +the need for run coordination, nor does using Runner imply local model execution. + +Evaluate alternatives against common admission/finalization, authority, recovery, +compute waiting cost, operating complexity, and future workspace requirements. +Document the choice and trigger for revisiting it. Do not alternate between a +Runner, worker fleet, and database-only backend just because each was questioned. + +For the Dot Cloud release the decision was one managed Runner per admitted +assignment, no sandbox for idle MCP traffic, and no exposed workspace tools. It +accepted compute while waiting as the cost of reusing the normal lifecycle. +Do not generalize “one sandbox per assignment” to providers/releases that have an +explicitly different approved execution contract. + +## Keep a finite delivery sequence + +1. **Feasibility:** actual provider wake/read/report loop on synthetic data. +2. **Governed vertical slice:** normal task + idle intake through actual Runner. +3. **Product onboarding:** natural entry, copy prompt, real watched readiness. +4. **Reliability:** duplicate/lost response, follow-up race, restart, stop, cleanup. +5. **Selected capabilities:** native apps, assigned apps/skills/files as promised. +6. **Deployed acceptance:** ordinary app image, Runner artifact, tenant ingress. + +Some work can overlap; the key is not building a broad tool catalog before the +uncertain provider boundary and both entry paths work. Split reviewable changes +when they span app, ingress, execution, or broad authority changes. Record their +dependencies instead of declaring an arbitrary count of PRs required. + +Useful final acceptance statement: + +> On the named Cloud tenant and immutable candidate, a new user can invite the +> existing bot with the generated prompt and one scoped consent approval. Without +> a chat nudge, an ordinary assigned task saves a verifiable result and completes. +> From idle, the bot can initiate work and assign a task to the responsible human. +> A follow-up and real controller restart preserve one assignment. Revocation +> denies further Paperclip actions, and every managed resource is accounted for. + +Adapt this to the actual release. If plugin attachment or reboot is required, +include it as a supported visible step or report an onboarding gap; do not claim +the stricter prompt-only journey passed. diff --git a/.agents/skills/create-external-bot-integration/references/runner-architecture.md b/.agents/skills/create-external-bot-integration/references/runner-architecture.md new file mode 100644 index 00000000000..93c6176a6b7 --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/runner-architecture.md @@ -0,0 +1,226 @@ +# Runner architecture and correctness boundaries + +## Contents + +- [Separate the bot, connection, controller, and workspace](#separate-the-bot-connection-controller-and-workspace) +- [Identity and authority](#identity-and-authority) +- [Durable state and operation contract](#durable-state-and-operation-contract) +- [Follow-up delivery must be coordinated with ordinary wake scheduling](#follow-up-delivery-must-be-coordinated-with-ordinary-wake-scheduling) +- [Recovery needs identity evidence, not merely a reachable process](#recovery-needs-identity-evidence-not-merely-a-reachable-process) +- [Revocation and native powers are different boundaries](#revocation-and-native-powers-are-different-boundaries) +- [Files and commands are a separate capability decision](#files-and-commands-are-a-separate-capability-decision) +- [Paperclip implementation anchors](#paperclip-implementation-anchors) + +## Separate the bot, connection, controller, and workspace + +```text +External provider + Bot: reasoning, native browser/computer/apps, private memory + | authenticated tools / signed wake events +Paperclip control plane + MCP + OAuth + pairing + durable mailbox + operation broker + | authenticated Runner protocol +Managed execution environment (Cloud) + Rust Runner: assignment lifecycle, catalog validation, durable receipts + | semantic tool requests / correlated results +Paperclip authority services + Task/company permissions, mutations, app gateway, result finalization +``` + +Application tools execute through the existing authorized services. If filesystem +or shell tools are enabled, their execution target must be the assigned confined +workspace. Moving Rust into a sandbox does not magically move a server-side file +handler there. Trace the actual effect destination for every workspace operation. + +Dot's model runs at OpenAI. Runner does not create its personal memory, run its +model, control its computer, or guarantee interruption of other native tools. +Do not invent model/session/usage fields to satisfy a local-provider schema. + +## Identity and authority + +Use a dedicated agent-purpose grant/resource where the provider is acting as an +employee. Personal assistant grants must not silently supply board/operator +permissions to a run. Bind authorization to company, agent, active grant, binding +generation, assignment, and run/turn. A supplied companyId or agentId is not proof +of authority. Recheck live permissions at execution and before returning protected +data, not only at initial pairing. + +Pairing uses an operator-issued, expiring, one-use capability for a specific +agent. Redemption checks current membership/role, feature prerequisites, agent +lifecycle, company/resource hints, expiry and revocation. Preview does not consume +the code. Grant creation, code consumption, and saving the binding reference must +be atomic. Otherwise setup can display ready while the scheduler still sees an +unpaired agent. Saving the form a second time is not an acceptable hidden step. + +Persistent connection identity and per-turn mutation authority are different: + +| Request | Expected behavior | +| --- | --- | +| Pair, inspect own capability/inbox status | Connection-scoped operation; no managed assignment needed. | +| Start work while idle | Durable, agent-attributed intake; ordinary admission before task mutation tools. | +| Read/accept offered assignment | Current binding and offer, explicit acceptance, no arbitrary run selection. | +| Invoke a task/app tool | Same admitted assignment and live permission checks; normal receipts/audit. | +| Update own optional avatar | Narrow self-only operation; do not grant generic agent-config authority. | +| Finish | Accepted canonical result, settled required operations, normal task finalizer. | +| Acknowledge a fence | Narrow control operation may remain available while task tools are denied. | + +Idle intake must retain plugin/environment context from the configured application +services. In Dot, constructing a separate heartbeat service without the sandbox +plugin registry made idle requests fail even though operator-assigned work ran. +Test both entry points against the same managed environment. + +## Durable state and operation contract + +Persist distinct facts for grant/binding, subscription, mailbox item, assignment, +operation receipt, run/controller ownership, and final result. Avoid a single +boolean “connected” or “done.” Enforce active-assignment concurrency in storage +as well as the UI. Dot's initial contract allowed one active assignment per binding. + +An illustrative lifecycle is: + +```text +intake/admission pending -> offered -> accepted -> working -> result accepted + -> turn finished -> task disposition committed -> resource cleanup +``` + +Any active phase can instead become fenced, expired, failed, or reconciliation +required. These are conceptual states; use the repository's actual enums. + +Every side-effecting external request needs a stable request identity plus an +argument digest and authority generation. Implement these semantics: + +- Same identity + same arguments: return the same pending/settled receipt. +- Same identity + different arguments: reject a conflict. +- Completed intake retried: return its original receipt, never another task. +- Old authority generation: deny new execution, even if a historical receipt exists. +- Definite read failure: terminal bounded error; later tools and finish can proceed. +- Effect possibly happened but result was lost: explicit unknown/pending outcome + and reconciliation. Do not reissue under a fresh ID to make progress. + +Persist reservation before effects where appropriate. Use existing transactional +mutation receipts and external-effect reconciliation. Do not promise universal +exactly-once execution across arbitrary remote services. Distinguish command +acknowledgment from completion of the tool's effect. + +Do not block the Runner command lane waiting for a semantic tool result delivered +on that same lane. Reserve/admit promptly, deliver a pending receipt, then settle +with a correlated event. Bound pending operations, results, bytes, and authority +duration. Pagination and control acknowledgments must still work at limits. + +Serialize mailbox cursor visibility with writers so a cursor cannot skip a result +whose transaction commits later. Wake events should contain references, not task +contents, credentials, or copied app data. An event receiver drains the durable +mailbox and validates current authority rather than trusting a stale event body. + +Treat event callbacks as an authenticated protocol, not arbitrary URLs to POST to. +Use the provider's current ownership challenge and signing specification. Keep +callback signing material out of prompts, descriptors, logs and task data. Enforce +the callback destination policy, including redirect and DNS/IP handling, so a +subscription cannot turn the control plane into an internal-network request proxy. +Apply bounded delivery timeouts/retries and record failures without logging secrets. +Where receiving signed callbacks, validate the expected signature, timestamp and +replay policy before changing state. Test the actual directions this provider uses; +do not invent a symmetric webhook API merely because the integration is two way. + +## Follow-up delivery must be coordinated with ordinary wake scheduling + +A normal comment can enter two paths: active-assignment mailbox delivery and the +ordinary scheduler. If both remain independent, a finished bot can receive a +second assignment for a comment it already handled. If the normal wake is simply +discarded when the mailbox entry is made, the comment can be lost at completion. + +The Dot fix retained the normal wake as deferred until the bot successfully read +that exact comment ID through `get_task_history`. Then the existing run consumed +it. If the assignment ended before the read, normal admission kept the unread +comment for another turn. An inbox reference or webhook acknowledgment did not +prove the comment was read. + +Test both sides of this race, including controller recovery. Keep explicit new +session requests, interaction replies, and self-authored comments under their +normal policy; don't blanket-suppress every wake for a connected bot. + +## Recovery needs identity evidence, not merely a reachable process + +Record and compare the run, session, assignment, binding generation, controller +generation, managed lease, process marker, and trustworthy process birth identity. +On Linux the process fingerprint can include boot identity and start information. +Read remote identity from the sandbox, not a same-numbered PID on the controller. + +Before advertising external work, require the evidence needed to recover it. +Recovery reads the checkpoint from the owning execution target and validates it +against saved authority. Missing/invalid state after dispatch fails closed for +reconciliation; a new process or conversation must not masquerade as a resume. + +Incoming operations need a durable draining/replay mechanism owned by the current +controller. An in-memory forwarding optimization alone can leave valid requests +stranded after restart. Fence old controllers and callbacks so a late result +cannot overwrite the new generation's journal. Preserve late effect evidence. + +A verified terminal/shutdown receipt can establish a clean exit when a remote +monitor lacks an exit code. An unexplained process exit still requires recovery. +Configure a bounded reconnect grace consistent with the deployment lifecycle; +Dot used five minutes after shorter restart gaps exposed failures. Do not copy +that number as a universal provider requirement. + +Terminal run reconciliation must clear/fence a still-active assignment, notify the +bot, and release resources. Otherwise a failed run can block every future task +even though its old tools are already denied. + +## Revocation and native powers are different boundaries + +Cancellation, pause, reassignment, membership loss, expired run authority, and +grant revocation must prevent subsequent Paperclip effects. Cached tool results +or UI binding references cannot restore revoked access. Old-grant revocation must +not revoke a replacement binding accidentally. + +Report “Paperclip authority revoked” separately from “external bot stopped.” A +fence acknowledgment proves receipt, not termination of all provider activity. +Likewise, sandboxing the Paperclip Runner does not confine the bot's native Slack +or provider computer. Those tools retain their own permissions. If strict total +capability isolation is required, use a suitably isolated bot/account and verify +the provider's actual controls; Paperclip cannot enforce it with prompt text. + +## Files and commands are a separate capability decision + +Do not add workspace tools merely to make an external agent look like a coding +CLI. A bot can research or write code on its own computer without Paperclip +workspace access. The first Dot Cloud release intentionally offered none. + +When the release includes workspace tools: + +- Resolve paths within the admitted workspace; reject traversal, absolute paths, + symlinks/escapes, and Paperclip instance state such as `.paperclip`. +- Check reads, writes, API downloads, uploads, and artifact publication. A generic + `call_api` path must not bypass attachment/workspace grants. +- Serialize optimistic hash checks with writes. Two concurrent writes must not + both commit against the same observed hash. +- A process working directory is not a sandbox. Use a proven OS/container boundary; + test outside-workspace access, environment secrets, detached descendants, timeout, + cancellation and cleanup. No unrestricted fallback when confinement is unavailable. +- In the historical Dot implementation, Linux used bubblewrap/private PID + namespaces. macOS command execution was disabled after detached descendants + escaped reliable supervision; file tools and artifacts remained available. +- For attachments, enforce current task/company, bounded pages and total bytes, + integrity checks, and live revocation before returning each page. Treat filenames + and contents as untrusted data. Test the generic API escape path too. + +## Paperclip implementation anchors + +Paths below existed in the qualified October 9 candidate. Locate their current +equivalents before editing; do not paste line numbers from historical notes. + +| Concern | Historical anchor | +| --- | --- | +| Bot broker and tool definitions | `server/src/services/dot-runner-broker.ts` | +| Prototype public MCP bridge | `server/src/services/public-mcp/dot-runner.ts` | +| Rust assignment backend | `packages/paperclip-runner/runner/crates/runner-core/src/dot_provider_backend.rs` | +| TypeScript Runner driver | `packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts` | +| Driver/backend selection | `packages/paperclip-runner/src/backends/native-backend-factory.ts` | +| Per-turn instructions | `packages/paperclip-runner/src/backends/runtime-context.ts` | +| Governed tool authority | `server/src/services/native-runtime/paperclip-runner-tool-authority.ts` | +| Admission, wake, terminal reconciliation | `server/src/services/heartbeat.ts`, `server/src/services/heartbeat/queue.ts`, `server/src/services/heartbeat/run-lifecycle.ts` | +| Real Rust/DB and managed qualification | `server/src/__tests__/dot-runner.test.ts` | + +Keep the standalone Runner package independent of server/DB implementation +imports. Extend the actual production backend and negotiated capability contract, +not just a convenient reference harness. diff --git a/.agents/skills/create-external-bot-integration/references/troubleshooting-and-deployment.md b/.agents/skills/create-external-bot-integration/references/troubleshooting-and-deployment.md new file mode 100644 index 00000000000..df851f386af --- /dev/null +++ b/.agents/skills/create-external-bot-integration/references/troubleshooting-and-deployment.md @@ -0,0 +1,215 @@ +# Troubleshooting, test environments, and Cloud delivery + +## Contents + +- [Start with the failing boundary](#start-with-the-failing-boundary) +- [Stable public ingress is part of the test fixture](#stable-public-ingress-is-part-of-the-test-fixture) +- [Concrete OAuth and routing checks for the Dot implementation](#concrete-oauth-and-routing-checks-for-the-dot-implementation) +- [Symptom → discriminator → next action](#symptom--discriminator--next-action) +- [Keep one reproducible environment manifest](#keep-one-reproducible-environment-manifest) +- [Coordinate all Cloud pieces](#coordinate-all-cloud-pieces) +- [Classify a failure before retrying](#classify-a-failure-before-retrying) +- [Close the environment deliberately](#close-the-environment-deliberately) + +## Start with the failing boundary + +Do not use “OAuth is broken” for every setup failure. Trace these independent paths: + +```text +A. Provider backend -> public origin -> discovery, MCP, token exchange +B. Provider browser -> authorization page -> preview/consent -> provider callback +C. Agent runtime -> loaded plugin actions -> authenticated read/tool calls +D. Paperclip event worker -> verified callback -> bot wake -> mailbox/challenge +E. Paperclip scheduler -> managed lease -> Runner -> tools -> finalizer -> cleanup +``` + +For each, collect a timestamp, exact failing operation, status/visible error, +correlated access log, and which party observed it. The developer's browser, the +provider's browser, and the provider's backend can have different reachability. +An absent request in complete ingress logs means that attempt did not reach the +app; it does not identify the upstream blocking rule. + +## Stable public ingress is part of the test fixture + +Prefer one stable HTTPS origin on port 443 reachable by both provider clients. +Keep issuer/resource, token exchange, authorization redirect, and callback +configuration consistent. Do not rotate tunnel hostnames as the first response to +every failure: each new URL can require a different plugin, fresh registration, +new pairing prompt, and subscription cleanup. + +Self-hosted testing may need a tunnel or authorized public reverse proxy. Dot +eventually used a stable public proxy forwarding to the test machine. That is an +observed useful arrangement, not a requirement to provision new infrastructure +for every integration. Use the user's supported deployment/network options. + +Tailscale-specific diagnostic: local MagicDNS may resolve a hostname to a private +tailnet address while outside clients use Funnel. Test public ingress independently; +local curl success does not establish public access. Tailscale Serve and Funnel +have different reachability. Never infer a provider port ban from one timeout. + +`ERR_BLOCKED_BY_CLIENT` alone does not identify Cloudflare or a server bot filter. +Use server logs and the actual consent page, not only `/api/health`: Dot once +blocked the health navigation while the required sign-in page loaded. Do not +bypass browser security warnings or a provider's access controls. Use supported +configuration or an authorized reachable endpoint and preserve the exact uncertainty. + +## Concrete OAuth and routing checks for the Dot implementation + +These routes describe the October 9 candidate; discover current equivalents. + +| Surface | Historical expectation | +| --- | --- | +| `/mcp/runner` unauthenticated | `401` and `WWW-Authenticate` with resource metadata; not an HTML sign-in page. | +| `/.well-known/oauth-protected-resource/mcp/runner` | Public JSON for the exact resource and authorization server. | +| `/.well-known/oauth-authorization-server/mcp/runner/oauth` | Public JSON for issuer and endpoints. | +| `/mcp/runner/oauth` | Dedicated agent-resource issuer; personal MCP issuer remained separate. | +| `/dot-connect/:requestId` | Public pairing page, scoped by request and one-use pairing capability. | +| `/api/dot-mcp/requests/:requestId` and exact pairing operations | Publicly routable aliases with request/code checks; reject personal assistant request IDs. | +| Normal board, connection management, ordinary personal consent | Retain normal session/role requirements. | + +For read-only health checks, substitute the configured origin; do not send tokens: + +```sh +curl --max-time 15 -sS -D - -o /tmp/dot-resource-metadata.json \ + 'https://YOUR_CONFIGURED_ORIGIN/.well-known/oauth-protected-resource/mcp/runner' +curl --max-time 15 -sS -D - -o /tmp/dot-auth-metadata.json \ + 'https://YOUR_CONFIGURED_ORIGIN/.well-known/oauth-authorization-server/mcp/runner/oauth' +``` + +Inspect JSON and redirect behavior, then exercise the actual provider discovery. +Do not interpret these curl checks as provider qualification. Keep browser cookies +and tokens out of pasted logs; use the existing authenticated test client when a +protected operation must be inspected. + +ChatGPT's historical client metadata preferred `private_key_jwt` while also +advertising `none`. Paperclip originally rejected it instead of negotiating its +supported public-client method with PKCE. Inspect the complete advertised methods; +do not disable authentication or exact redirect/resource validation to work around +a preference mismatch. Keep S256 PKCE and current registration checks. + +An optional `PAPERCLIP_MCP_AUTHORIZATION_ORIGIN` supported a different authorized +browser ingress to the same instance, while `PAPERCLIP_PUBLIC_URL` retained the +MCP resource/issuer/token origin. This was a workaround for different client +reachability, not an OAuth requirement. Use only explicitly configured trusted +origins and verify callback/CSRF/issuer behavior. Prefer a single working origin. + +## Symptom → discriminator → next action + +| Symptom | Evidence to inspect | Action and proof | +| --- | --- | --- | +| OAuth discovery times out | Public metadata vs actual provider access logs | Fix the failing ingress/client path; do not keep editing valid metadata when no request arrives. | +| Unknown client or redirect URI after restart | Persisted registration vs provider's cached client | Restore the intended persistent fixture or re-register through supported setup. Do not reset DB repeatedly. | +| Invalid request ID | Actual ID representation | Treat protocol IDs as opaque unless contract says otherwise; the lab incorrectly required a UUID. | +| Grant exists but provider is not connected | Complete same browser flow through callback/token exchange | A server grant row alone is not completed OAuth. Avoid mixing separate callback sessions. | +| Tools listed in settings, absent to bot | Actual tool invocation in active conversation | Supported refresh/attach/restart, bounded once per diagnosis; preserve endpoint and mark intervention. | +| Subscription verified but no bot action | Delivery record, mailbox, challenge/acceptance receipt | Separate callback receipt from model execution. Report latency; inspect actionable work before resending. | +| Ready connection, task admission says unpaired | Saved agent binding/config revision | Fix atomic pairing persistence; do not require another manual form save. | +| Agent created paused before pairing | Lifecycle preparing/verifying vs manual pause | Use lifecycle-specific setup eligibility; preserve actual pause/approval gates. | +| Anonymous consent spins/reloads | Background session probes returning expected 401 | Public consent must tolerate them without global reload; protected board routes retain session recovery. | +| Ordinary assignment works, idle work cannot acquire sandbox | Service construction / plugin context | Use app-configured admission service and test both entry points. | +| Remote Runner connects to localhost | Launched transport endpoint vs private managed ingress | Use the authenticated remote transport supplied by the execution target. | +| Image lacks provider support | Actual packaged Runner digest/capabilities | Fail before dispatch; publish compatible artifact and test installed path. | +| Recovery finds sandbox but no process identity | Marker, fingerprint and original checkpoint | Capture identity before dispatch; reconcile missing evidence rather than launch replacement work. | +| Remote PID has implausible start time | Whether controller inspected its own PID table | Obtain birth identity from the remote sandbox, not a colliding local PID. | +| Finished task starts again after comment | Mailbox + scheduler wake receipt | Consume only when exact comment read succeeds; test unread-at-finish and read-before-finish. | +| Missing file leaves run waiting forever | Pending tool receipt and error dispatch | Settle definite read failures; preserve unknown outcome only for genuinely ambiguous effects. | +| Terminal run blocks all new tasks | Assignment still active despite terminal run | Reconcile/fence it and release resources without claiming external stop. | +| Cancelled job has creating/pending-cleanup sandbox | Provider allocation and deletion receipts | Track until destroyed or explicitly pending with owner; retry only per provider semantics. | +| New deployment vanishes mid-test | Fleet auto-rollout vs direct candidate | Use supported test-tenant pinning with authorization; record current serving revision. | + +## Keep one reproducible environment manifest + +Before a test drive or deployment, resolve current worktree/artifacts and reuse a +suitable isolated checkout. Do not reset the user's primary checkout. Create a new +managed worktree when appropriate, preserving unrelated work. Record: + +```text +Repository / worktree / branch / app SHA: +Runner artifact platform / digest / advertised capabilities: +Ingress repository / SHA / relevant routing version: +App image / immutable digest: +Local host / port / data directory / process ownership: +Public origin / authorization origin / forwarding target: +Tenant / company / agent identifiers (private evidence only): +Sign-in entry and credential reference (no secret values): +Grant/binding status / subscription / pending code expiry (no code value): +Current case / run / assignment / lease: +Owner of each server, proxy, build, sandbox, and cleanup: +Environment retention agreement / next gate: +``` + +Use synthetic fixtures, not a production database clone containing private company +data or signing keys. Verify launcher arguments, selected instance, port mapping, +and data persistence after restart. A label such as `default` is not proof of +process ownership. Never kill unrelated databases or clear shared IPC globally. +If resource pressure blocks the fixture, inspect owned processes or use the +approved remote test path; preserve unrelated sessions. + +Verify sign-in is usable before handing over a URL. Deliver access/resume details +through an appropriate private path. If the user is still testing or the bot is +loading tools, keep the endpoint alive under the agreed bounds. Stopping it made +the first “tools unavailable” diagnosis premature. Announce an intentional expiry +and explain how to resume; do not assume a stopped shell process remains a service. + +## Coordinate all Cloud pieces + +Cloud qualification requires the tenant app, public ingress, managed provider +plugin/configuration, and packaged Runner artifact. The UI flag alone is not +enablement. Inspect guards in UI, scheduler, backend, remote launcher, and Cloud +front door. Use scoped route exceptions that retain endpoint authentication rather +than removing browser-session guards wholesale. + +Pairing and MCP must remain reachable without a managed job. If a sleeping tenant +returns a waking-up page, wait for the actual lifecycle transition, then test the +correct metadata response. Delay creating expiring pairing codes until the tenant +image is deployed and serving. A 3.4 GB image transfer consumed meaningful setup +time during Dot qualification; do not burn the code's lifetime watching a build. + +Record whether a test overrides the sandbox binary with a locally built artifact. +That proves the code can run, not that the published default image is correct. +Match platform and provider capability; a Mac controller's current binary cannot +stand in for a Linux sandbox artifact. Digest/capability checks should fail clearly +before an assignment is offered. + +For the historical private `paperclip-cloud` repository, the supported targeted +deployment CLI was `scripts/deploy-stack.mjs --plain`, with +`--resume ` for the same operation. Inspect current help/runbook before +using it. Do not invent or replay old tenant IDs, deployment requests, or secrets. +Preserve one request through prepare/build/check/deploy/verify; duplicated requests +make provenance and recovery harder. No fleet-default change is implied by a +single test-tenant deployment. + +## Classify a failure before retrying + +- **Product:** deterministic missing context, bad state transition, wrong endpoint, + lost receipt. Reproduce, fix, and rerun the affected contract. +- **Provider:** tool loading, wake delay, refusal, unsupported control. Capture the + actual provider result and test a supported recovery; don't manufacture success. +- **Infrastructure:** capacity, registry 429/504, image transfer, npm resolver, + provider allocation. Verify whether work/effects started; use bounded recovery. +- **Evidence/test:** controller didn't restart, stale build, nonce visible in prompt, + wrong image, scripted client presented as real. Correct the experiment. + +For an unchanged transient build failure, one targeted infrastructure retry is a +reasonable initial bound. If it fails again, report the precise remaining gate +or investigate the new cause; do not loop reruns or change registry/security +configuration without a task-grounded reason. Resume the existing deployment. +If code changes, re-evaluate which earlier evidence applies to the new candidate. + +Keep broad test runs tied to a frozen revision. Stop superseded owned runs when +safe, retain their results, and run the affected suites plus required final gates. +Do not claim a full local suite passed because failing tests passed individually; +state focused results and final CI separately. + +## Close the environment deliberately + +After the final task, inspect leases and actual provider resources. Expired lease +status alone is weaker than a successful sandbox deletion receipt. Preserve a +requested review instance with access and resumption information; release owned +disposable compute. For an authorized reset, account for provider account link, +Paperclip binding/grant, event subscription/automation, and cached configuration. +Disconnecting one layer can leave an enabled provider automation behind. + +Report qualified, review-ready, merged, deployed, and canary-verified separately. +Never say an instance received a fix until its serving revision and relevant +ordinary-use behavior establish it. From f4b24bf695343aa9d75d44327b35f92af4c263d1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 13 Oct 2026 09:48:30 -0500 Subject: [PATCH 16/85] Keep published Codex prerequisite proof separate from Claude Co-Authored-By: Paperclip --- doc/plans/2026-10-12-claude-runner-default.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 43e6e4ae646..45a1c1d801a 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -1151,3 +1151,34 @@ The served controller is ad5875fa with the recorded 52486d6b lookup repair and Full workspace typecheck/tests/build and final packaged acceptance remain unrun on the reconciled head; they belong to the still pending cloud/full-check handoff after dependency and local acceptance gates are satisfied. + + +## Published prerequisite evidence remains separate — 2026-10-13 + +The source owner reports public canary `2026.1013.0-canary.6` on +`60bc0d5fee9a6e48db08fc016521fee3d9f57d67`, following merged prerequisite +d67e3507. The 60bc object is available locally and its own commit changes only +the release workflow and its verification tests (#16149). This checkpoint does +not merge that later master into the Claude branches or run a new workflow. +The source owner reports four native producers, publication and real onboarding +smoke passing, plus clean Apple Silicon installation and two native Codex task +runs through the supported API. Its fresh staging pair remains separately bound +to literal d67/cloud38392212140-1 and reuses its provider session. + +Those are prerequisite/Codex measurements, not qualification of our untested +Claude package. The source owner's published saved-account Connect UI still +uses a Legacy CLI probe that fails on its clean local PATH; its private Codex +#15683 fix remains separate. Its released gpt-5.6-luna costs are unpriced, and +an additional positive-budget control correctly fails closed. Restoring its +budget to default zero changed the configuration revision/session; local same +provider-session reuse is therefore not claimed. Four authorized Codex turns +were used, its QA agents are paused, and physical sandbox teardown/invoices +remain unconfirmed. None of its $25 allowance funds this Claude lane. + +Our public heads remain c60bf4b/4440df2/173a73e, with newer reconciled fixes +committed locally. Claude budget-check/retry approval is still pending; no new +paid check, retry, hold release, foreign process cleanup or borrowed environment +was performed. Current-source broad checks, final packages, local useful-task +and revision proof and fresh PR review remain gates. The old source merge timer +was canceled by its human; this chat has no authority to recreate it or promote +production defaults. From 833769ab557255b11dd8f650f43ed7333653f5e4 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 13 Oct 2026 07:55:27 -0700 Subject: [PATCH 17/85] perf(chat): schedule Telegram maintenance from commits and deadlines (#16094) Replace periodic Telegram discovery and maintenance scans with bounded startup recovery, connection-specific commit notifications, and durable action deadlines. Preserve ownership, inactive subscription parking, retry and cleanup recovery, and atomic credential binding updates. Verified full typecheck/build, 30 focused scheduler tests, 12 database-backed recovery cases, all current-head CI checks, and Greptile 5/5 with no unresolved threads. Co-Authored-By: Paperclip --- ...-instance-sleep-and-scheduler-inventory.md | 15 +- maps/agent-access-map/connection-setup.md | 8 + maps/feature-map/connection-setup.md | 6 + packages/db/src/work-signals.test.ts | 28 ++- packages/db/src/work-signals.ts | 23 +- .../app-private-hostname-gate.test.ts | 1 + server/src/__tests__/chat-action-work.test.ts | 37 ++- .../chat-channels.integration.test.ts | 233 +++++++++++++++++- server/src/app.ts | 4 +- server/src/services/chat-action-work.ts | 29 ++- server/src/services/chat-channels.ts | 8 +- .../services/chat-channels/configuration.ts | 14 +- .../chat-channels/discord/gateway-events.ts | 2 +- .../chat-channels/resource-reconciliation.ts | 2 +- .../services/chat-channels/setup-probes.ts | 2 +- .../chat-channels/telegram/maintenance.ts | 135 +++++----- .../src/services/chat-work-notifications.ts | 4 +- .../src/services/delivery-work-coordinator.ts | 6 +- .../services/delivery-work-notifications.ts | 6 +- server/src/services/tool-access.ts | 15 +- 20 files changed, 479 insertions(+), 99 deletions(-) diff --git a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md index 0ae5e2a499f..e479786f8b9 100644 --- a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md +++ b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md @@ -456,7 +456,20 @@ Longer polling intervals alone do not complete an item. for both stale-claim expiry and any saved retry time. Startup recovers unfinished requests. Claim and settlement writes signal in their own short transactions, with agent wakeup outside those transactions. Empty and terminal queues disarm. - Remaining **1s** maintenance: provider runtimes, Telegram maintenance, + Telegram maintenance now has separate commit-driven discovery and action + workers. Discovery restores legacy subscription obligations on startup and + after endpoint or chat-connection configuration commits, without recurring + empty scans. Startup walks keyset pages with at most 100 endpoints per run, + so endpoints beyond the first page remain discoverable. Configuration commits + carry connection hints through outer settlement and uncertainty recovery; + they only revisit the changed connection. Failed discovery retains its page, + and concurrent edits queue a fresh scoped pass. Existing action rows provide retry and stale-claim + deadlines. Stop-subscription repair parks while the endpoint or connection + is inactive; registration and removal keep their existing eligibility. + The explicit bounded recovery helper remains available. Provider subscription + proof, credential ownership, and idempotent provider mutations stay in the + Telegram service. Provider responses are simulated in the PostgreSQL tests. + Remaining **1s** maintenance: provider runtimes, run milestone/wake-notice projection, GitHub operation recovery, and Teams protocol maintenance. GitHub pre-ingress recovery must still inspect provider history: a lost callback has no local queue row. diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md index 99e430698db..dc93187f5d0 100644 --- a/maps/agent-access-map/connection-setup.md +++ b/maps/agent-access-map/connection-setup.md @@ -181,6 +181,14 @@ own policy and access review. Gaps: Provider endpoint, represented agent and linked sender are operator choices today. +Internal recovery: Telegram discovery walks bounded pages at startup. Configuration +commits only queue the changed connection; durable maintenance rows supply retry deadlines. Inactive subscription +repair is parked until an endpoint or connection change. This creates no agent +management permission or new API. Local database-backed provider-fixture tests in +[channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) +cover recovery, rollback, retries and idle query silence; they do not establish +live provider or agent access. + ## `failure` Review: `baseline`. diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md index 808f7d952ce..f98f29b4721 100644 --- a/maps/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -132,6 +132,12 @@ Automated: [setup routing](../../ui/src/pages/apps/chat/ChatEndpointSetup.routin [identity confirmation](../../ui/src/pages/apps/chat/ChatIdentityConfirm.test.tsx) cover shared UI logic with fixtures. Provider-specific live setup is not implied. +[Channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) +also covers bounded Telegram subscription recovery at startup and after committed +configuration changes, saved retry deadlines, rollback, and parking pending +repair while an endpoint or connection is inactive. The provider responses are +fixtures. Settled maintenance leaves no discovery timer or database scans. + Manual: complete setup for one named provider, reload endpoint settings, link the intended sender, and send a disposable message. Verify one task and a useful reply in the originating conversation. Inspect an unlinked sender's denial and diff --git a/packages/db/src/work-signals.test.ts b/packages/db/src/work-signals.test.ts index 9bc3f738b1c..13bd4b8552a 100644 --- a/packages/db/src/work-signals.test.ts +++ b/packages/db/src/work-signals.test.ts @@ -48,6 +48,24 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await db.execute(sql`select * from work_signal_test where id = 'rollback'`)).toHaveLength(0); }); + it("coalesces scoped hints and publishes them only when the outer transaction settles", async () => { + const settled: string[][] = []; + const unsubscribe = subscribeDatabaseWork(db, "scoped", (event, hints) => { + if (event === "settled") settled.push([...(hints ?? [])]); + }); + try { + await db.transaction(async tx => { + await signalDatabaseWork(tx, "scoped", "connection-a"); + await tx.transaction(async nested => { + await signalDatabaseWork(nested, "scoped", "connection-b"); + await signalDatabaseWork(nested, "scoped", "connection-a"); + }); + expect(settled).toEqual([]); + }); + expect(settled).toEqual([["connection-a", "connection-b"]]); + } finally { unsubscribe(); } + }); + it("keeps outer ownership after a caught savepoint rollback", async () => { const events: string[] = []; const unsubscribe = subscribeDatabaseWork(db, "savepoint", event => events.push(event)); @@ -92,19 +110,25 @@ const support = await getEmbeddedPostgresTestSupport(); }) as typeof raw.transaction; const owner = installDatabaseWorkSignals(raw); const topic = `late-${outcome}`; + const settled: string[][] = []; + const unsubscribe = subscribeDatabaseWork(owner, topic, (event, hints) => { + if (event === "settled") settled.push([...(hints ?? [])]); + }); try { await expect(owner.transaction(async tx => { - await signalDatabaseWork(tx, topic); + await signalDatabaseWork(tx, topic, "connection-late"); await tx.execute(sql`insert into work_signal_test values (${topic})`); })).rejects.toThrow("lost transaction response"); await reconcileDatabaseWork(owner, topic); expect(databaseWorkPending(owner, topic)).toBe(true); expect(await db.execute(sql`select * from work_signal_test where id = ${topic}`)).toHaveLength(0); + settled.length = 0; release(); await serverDone.catch(() => {}); await reconcileDatabaseWork(owner, topic); + expect(settled).toEqual([["connection-late"]]); expect(databaseWorkPending(owner, topic)).toBe(false); expect(await db.execute(sql`select * from work_signal_test where id = ${topic}`)).toHaveLength(outcome === "commit" ? 1 : 0); - } finally { release(); await serverDone?.catch(() => {}); } + } finally { release(); await serverDone?.catch(() => {}); unsubscribe(); } }); it("recovers after a real backend disconnect without replaying the queue write", async () => { diff --git a/packages/db/src/work-signals.ts b/packages/db/src/work-signals.ts index 219ea924468..4abb2570a5e 100644 --- a/packages/db/src/work-signals.ts +++ b/packages/db/src/work-signals.ts @@ -3,16 +3,16 @@ import { sql } from "drizzle-orm"; // Process-local hints about existing durable queues. No SQL triggers, tables, // background timers, or driver query replay. Only opted-in transactions fetch // an XID, so unrelated transactions keep their existing SQL behavior. -type Listener = (event: "intent" | "settled") => void; -type Transaction = { topics: Set; xid?: Promise; uncertain: boolean }; +type Listener = (event: "intent" | "settled", hints?: ReadonlySet) => void; +type Transaction = { topics: Set; hints: Map>; xid?: Promise; uncertain: boolean }; type Scope = { listeners: Map>; transactions: Set; root: Connection }; type Context = { scope: Scope; transaction?: Transaction }; type Connection = { transaction: (...args: any[]) => Promise; execute: (...args: any[]) => Promise }; const contexts = new WeakMap(); -function publish(scope: Scope, topic: string, event: "intent" | "settled" = "settled") { +function publish(scope: Scope, topic: string, event: "intent" | "settled" = "settled", hints?: ReadonlySet) { for (const listener of scope.listeners.get(topic) ?? []) { - try { listener(event); } + try { listener(event, hints); } catch (error) { process.emitWarning(`Database work listener failed: ${String(error)}`); } } } @@ -30,7 +30,7 @@ function install(connection: Connection, context: Context) { connection.transaction = async function (callback, ...options) { // Nested savepoints belong to the outermost transaction. Even a released // savepoint must not wake a consumer before its parent commits. - const state = context.transaction ?? { topics: new Set(), uncertain: false }; + const state = context.transaction ?? { topics: new Set(), hints: new Map>(), uncertain: false }; try { const result = await transaction.call(this, async (tx: Connection) => { install(tx, { scope: context.scope, transaction: state }); @@ -47,20 +47,25 @@ function install(connection: Connection, context: Context) { } throw error; } finally { - if (!context.transaction) for (const topic of state.topics) publish(context.scope, topic); + if (!context.transaction) for (const topic of state.topics) publish(context.scope, topic, "settled", state.hints.get(topic)); } }; } /** Await BEFORE the queue write, on its transaction. Nested transactions work. */ -export async function signalDatabaseWork(connection: object, topic: string): Promise { +export async function signalDatabaseWork(connection: object, topic: string, hint?: string): Promise { const context = contexts.get(connection); if (!context) return; // Lightweight service test doubles have no DB lifecycle. const state = context.transaction; if (!state) throw new Error("Database work must be registered inside a transaction"); state.topics.add(topic); + if (hint !== undefined) { + let hints = state.hints.get(topic); + if (!hints) state.hints.set(topic, hints = new Set()); + hints.add(hint); + } context.scope.transactions.add(state); - publish(context.scope, topic, "intent"); + publish(context.scope, topic, "intent", state.hints.get(topic)); if (!state.xid) { state.xid = (connection as Connection).execute(sql`select pg_current_xact_id()::text as xid`) .then((rows: { xid: string }[]) => rows[0]!.xid); @@ -91,7 +96,7 @@ export async function reconcileDatabaseWork(owner: object, topic: string): Promi throw new Error("Unexpected database transaction status"); } scope.transactions.delete(state); - for (const affected of state.topics) publish(scope, affected); + for (const affected of state.topics) publish(scope, affected, "settled", state.hints.get(affected)); } } diff --git a/server/src/__tests__/app-private-hostname-gate.test.ts b/server/src/__tests__/app-private-hostname-gate.test.ts index 1c46075d2aa..0dbde42a5a9 100644 --- a/server/src/__tests__/app-private-hostname-gate.test.ts +++ b/server/src/__tests__/app-private-hostname-gate.test.ts @@ -107,6 +107,7 @@ describe("createChatReconciliationCoordinator", () => { // scheduled method returns; app shutdown must also join those workers. expect(source).toContain("await chatChannels.shutdown()"); expect(source).not.toContain("chatChannels.processPendingSlackSessionSyncs()"); + expect(source).not.toContain("chatChannels.processPendingChatMaintenance()"); const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8"); const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("), service.indexOf("async function processPendingDeliveries(")); diff --git a/server/src/__tests__/chat-action-work.test.ts b/server/src/__tests__/chat-action-work.test.ts index 294715f922f..067d6d54e1b 100644 --- a/server/src/__tests__/chat-action-work.test.ts +++ b/server/src/__tests__/chat-action-work.test.ts @@ -20,6 +20,10 @@ function setup() { coordinators.push(coordinator); const service = { processFailedChatRunRetries: vi.fn(async () => {}), + processPendingTelegramActions: vi.fn(async () => {}), + processTelegramSubscriptionDiscovery: vi.fn(async () => {}), + scheduleTelegramSubscriptionDiscovery: vi.fn(), + hasPendingTelegramSubscriptionDiscovery: vi.fn(() => false), processPendingProviderEffects: vi.fn(async () => {}), processPendingGitHubWebhookIngress: vi.fn(async () => {}), processPendingSlackSessionSyncs: vi.fn(async () => {}), @@ -31,17 +35,40 @@ function setup() { nextChatActionAt: vi.fn(async (): Promise => null), nextVerificationMessageAt: vi.fn(async (): Promise => null), }; - const runs = [service.processFailedChatRunRetries, service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts, + const runs = [service.processFailedChatRunRetries, service.processPendingTelegramActions, service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts, service.processPendingReceiptReactions, service.processPendingSlackSessionStops, service.processPendingVerificationMessages]; return { owner, service, runs, coordinator, enabled, onError, start: () => registerChatActionWork(coordinator, service, enabled) }; } describe("chat action scheduling", () => { + it("retains configuration commits during discovery and retries discovery failures", async () => { + const s = setup(); + let release!: () => void; + s.service.processTelegramSubscriptionDiscovery.mockImplementationOnce(() => new Promise(resolve => { release = resolve; })); + const work = s.start(); + await vi.advanceTimersByTimeAsync(1); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1); + await s.owner.transaction(tx => notifyChatEndpointWork(tx, "connection-fixture")); + expect(s.service.scheduleTelegramSubscriptionDiscovery).toHaveBeenCalledWith("connection-fixture"); + s.service.processTelegramSubscriptionDiscovery.mockRejectedValueOnce(new Error("discovery unavailable")); + release(); + await work.ready; + await vi.advanceTimersByTimeAsync(1); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(2); + expect(s.onError).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(3_600_000); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(3); + expect(s.coordinator.nextWakeAt()).toBeNull(); + }); it("does one startup scan per lane and no more SQL or timers during an idle hour", async () => { const s = setup(); await s.start().ready; await vi.advanceTimersByTimeAsync(3_600_000); for (const run of s.runs) expect(run).toHaveBeenCalledTimes(1); - expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(8); + expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(9); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1); + expect(s.service.scheduleTelegramSubscriptionDiscovery).not.toHaveBeenCalled(); expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1); expect(s.coordinator.nextWakeAt()).toBeNull(); expect(vi.getTimerCount()).toBe(0); @@ -58,9 +85,12 @@ describe("chat action scheduling", () => { await s.owner.transaction(tx => notifyChatVerificationWork(tx)); await vi.advanceTimersByTimeAsync(1); for (const run of s.runs) expect(run).toHaveBeenCalledTimes(2); - await s.owner.transaction(tx => notifyChatEndpointWork(tx)); + // Queue claims/settlements do not rediscover every configured endpoint. + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1); + await s.owner.transaction(tx => notifyChatEndpointWork(tx, "connection-fixture")); await vi.advanceTimersByTimeAsync(1); for (const run of s.runs) expect(run).toHaveBeenCalledTimes(3); + expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); it("restores durable deadlines and reschedules when a foreground send moves a retry earlier", async () => { @@ -101,6 +131,7 @@ describe("chat action scheduling", () => { await s.start().ready; await vi.advanceTimersByTimeAsync(2000); expect(s.service.nextChatActionAt).not.toHaveBeenCalled(); + expect(s.service.processTelegramSubscriptionDiscovery).not.toHaveBeenCalled(); for (const run of s.runs) expect(run).not.toHaveBeenCalled(); s.enabled.mockReturnValue(true); s.service.processPendingSlackTaskStarts.mockRejectedValueOnce(new Error("DB unavailable")); diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 0b5fe88772b..fdf06753b93 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,5 +1,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js"; +import type { Db } from "@paperclipai/db"; import * as activityLogModule from "../services/activity-log.js"; +import * as connectionCredentialBindings from "../services/connection-credential-bindings.js"; import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js"; import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; @@ -33061,7 +33063,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { try { for (const kind of Object.keys(CHAT_ACTION_QUEUES) as (keyof typeof CHAT_ACTION_QUEUES)[]) { const [action] = await db.insert(chatActions).values({ companyId: f.companyId, endpointId: lane.endpoint.id, - kind, providerActionId: randomUUID(), status: "received", updatedAt: at }).returning(); + kind, providerActionId: randomUUID(), status: "received", updatedAt: at, + ...(kind === "telegram_maintenance" ? { payload: { operation: "register_commands" } } : {}) }).returning(); const set = (status: string, result: Record = {}) => db.update(chatActions) .set({ status, result, updatedAt: at }).where(eq(chatActions.id, action!.id)); const due = async () => db.select({ id: chatActions.id }).from(chatActions) @@ -70635,6 +70638,195 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }; } + it.each(["startup", "connection_commit", "rollback", "retry", "stale_claim"] as const)( + "recovers Telegram maintenance from committed work and becomes SQL quiet: %s", + async mode => { + const lane = await draftFixture(); + const onError = vi.fn(); + const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }); + const maintenance = () => db.select().from(chatActions).where(and( + eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"), + sql`${chatActions.payload}->>'operation' = 'stop_subscription'`, + )); + try { + await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id), + eq(chatActions.kind, "telegram_stop_subscription"))); + lane.setSubscriptionInfo({}); + if (mode === "connection_commit" || mode === "rollback") { + await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false }); + } + if (mode === "stale_claim") { + // Leave committed legacy work behind before starting the workers. + await lane.context.service.processTelegramSubscriptionDiscovery(); + const [action] = await maintenance(); + expect(action).toBeDefined(); + await db.update(chatActions).set({ status: "processing", updatedAt: new Date(Date.now() - 120_000) }) + .where(eq(chatActions.id, action!.id)); + } + let failedAt = 0; + let sends = 0; + if (mode === "retry") lane.setMaintenanceHook(async method => { + if (method === "setWebhook" && sends++ === 0) { + failedAt = Date.now(); + throw new Error("temporary subscription transport outage"); + } + if (method === "setWebhook") expect(Date.now() - failedAt).toBeGreaterThanOrEqual(1000); + return undefined; + }); + await registerChatActionWork(coordinator, lane.context.service, () => true).ready; + if (mode === "connection_commit" || mode === "rollback") { + expect(lane.maintenanceRequests).toEqual([]); + if (mode === "rollback") { + await expect(db.transaction(async tx => { + await toolAccessService(tx as unknown as Db).updateConnection(lane.endpoint.connectionId, { enabled: true }); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(lane.maintenanceRequests).toEqual([]); + throw new Error("rollback connection wake"); + })).rejects.toThrow("rollback connection wake"); + await vi.waitFor(() => { + expect(coordinator.nextWakeAt()).toBeNull(); + expect(idleWorkSnapshot().active).toBe(0); + }, { timeout: 10_000 }); + expect(lane.maintenanceRequests).toEqual([]); + expect(await maintenance()).toEqual([]); + } + await db.transaction(async tx => { + await toolAccessService(tx as unknown as Db).updateConnection(lane.endpoint.connectionId, { enabled: true }); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(lane.maintenanceRequests).toEqual([]); + }); + } + await vi.waitFor(async () => { + const actions = await maintenance(); + expect(actions).toHaveLength(1); + expect(actions[0]?.status).toBe("processed"); + expect(actions[0]?.result.attempts).toBe(mode === "retry" ? 2 : 1); + expect(coordinator.nextWakeAt()).toBeNull(); + expect(idleWorkSnapshot().active).toBe(0); + }, { timeout: 10_000 }); + expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(mode === "retry" ? 2 : 1); + expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id), + eq(chatActions.kind, "telegram_stop_subscription"), eq(chatActions.status, "processed")))).toHaveLength(1); + const scans = vi.spyOn(db, "select"); + try { + await new Promise(resolve => setTimeout(resolve, 1200)); + expect(scans).not.toHaveBeenCalled(); + } finally { scans.mockRestore(); } + expect(onError).not.toHaveBeenCalled(); + } finally { await coordinator.stop(); await lane.close(); } + }, + ); + + it("commits chat connection credentials atomically before waking maintenance", async () => { + const lane = await draftFixture(); + const onError = vi.fn(); + const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }); + const bindings = () => db.select({ secretId: companySecretBindings.secretId, configPath: companySecretBindings.configPath }) + .from(companySecretBindings).where(and(eq(companySecretBindings.companyId, lane.fixture.companyId), + eq(companySecretBindings.targetType, "tool_connection"), eq(companySecretBindings.targetId, lane.endpoint.connectionId))) + .orderBy(asc(companySecretBindings.configPath)); + let release!: () => void; + let entered!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const rebuilding = new Promise(resolve => { entered = resolve; }); + let update: Promise | undefined; + const original = connectionCredentialBindings.syncConnectionCredentialBindings; + const spy = vi.spyOn(connectionCredentialBindings, "syncConnectionCredentialBindings"); + try { + await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id), + eq(chatActions.kind, "telegram_stop_subscription"))); + lane.setSubscriptionInfo({}); + await lane.context.service.processTelegramSubscriptionDiscovery(); + await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false }); + await registerChatActionWork(coordinator, lane.context.service, () => true).ready; + const before = await bindings(); + expect(before.length).toBeGreaterThan(0); + spy.mockImplementation(async (database, connection, refs, client = database) => { + if (connection.id === lane.endpoint.connectionId) { + // Hold the real binding rebuild after its delete boundary. Another + // connection must still see the old bindings until outer commit. + await client.delete(companySecretBindings).where(and(eq(companySecretBindings.companyId, connection.companyId), + eq(companySecretBindings.targetType, "tool_connection"), eq(companySecretBindings.targetId, connection.id))); + entered(); + await held; + } + return original(database, connection, refs, client); + }); + update = toolAccessService(db).updateConnection(lane.endpoint.connectionId, { name: "Renamed fixture", enabled: true }); + await rebuilding; + expect(await bindings()).toEqual(before); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(lane.maintenanceRequests).toEqual([]); + release(); + await update; + await vi.waitFor(async () => { + expect(await db.select({ status: chatActions.status }).from(chatActions).where(and( + eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"), + sql`${chatActions.payload}->>'operation' = 'stop_subscription'`, + ))).toEqual([{ status: "processed" }]); + expect(coordinator.nextWakeAt()).toBeNull(); + }, { timeout: 10_000 }); + expect(await bindings()).toEqual(before); + expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(1); + expect(onError).not.toHaveBeenCalled(); + } finally { release(); await update; spy.mockRestore(); await coordinator.stop(); await lane.close(); } + }); + + it.each(["endpoint", "connection"] as const)("parks pending Telegram subscription repair while its %s is inactive", async inactive => { + const lane = await draftFixture(); + const onError = vi.fn(); + const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }); + const actions = () => db.select().from(chatActions).where(and( + eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"), + sql`${chatActions.payload}->>'operation' = 'stop_subscription'`, + )); + try { + await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id), + eq(chatActions.kind, "telegram_stop_subscription"))); + lane.setSubscriptionInfo({}); + await lane.context.service.processTelegramSubscriptionDiscovery(); + expect(await actions()).toEqual([expect.objectContaining({ status: "received" })]); + if (inactive === "endpoint") { + await lane.context.service.configure(lane.endpoint.id, { action: "pause" }, "owner-user"); + } else { + await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false }); + } + await registerChatActionWork(coordinator, lane.context.service, () => true).ready; + await vi.waitFor(() => { + expect(coordinator.nextWakeAt()).toBeNull(); + expect(idleWorkSnapshot().active).toBe(0); + }, { timeout: 10_000 }); + expect(await lane.context.service.nextChatActionAt("telegram_maintenance")).toBeNull(); + expect(lane.maintenanceRequests).toEqual([]); + expect(await actions()).toEqual([expect.objectContaining({ status: "received" })]); + const scans = vi.spyOn(db, "select"); + try { + await new Promise(resolve => setTimeout(resolve, 1200)); + expect(scans).not.toHaveBeenCalled(); + } finally { scans.mockRestore(); } + if (inactive === "endpoint") { + await lane.context.service.configure(lane.endpoint.id, { action: "resume" }, "owner-user"); + } else { + await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: true }); + } + await vi.waitFor(async () => { + const rows = await actions(); + expect(rows.filter(row => row.status === "processed")).toEqual([ + expect.objectContaining({ result: expect.objectContaining({ attempts: 1 }) }), + ]); + // Endpoint pause/resume advances its ownership generation. The old + // obligation must settle without touching the provider again. + expect(rows.filter(row => row.status !== "processed")).toEqual(inactive === "endpoint" ? [ + expect.objectContaining({ status: "cancelled", result: expect.objectContaining({ code: "telegram_maintenance_credentials_superseded" }) }), + ] : []); + expect(coordinator.nextWakeAt()).toBeNull(); + expect(idleWorkSnapshot().active).toBe(0); + }, { timeout: 10_000 }); + expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(1); + expect(onError).not.toHaveBeenCalled(); + } finally { await coordinator.stop(); await lane.close(); } + }); + it("durably stops during the first draft RPC without cancelling a run, and never reuses its ID after restart", async () => { const lane = await draftFixture(); try { @@ -70850,11 +71042,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, ); - it.each(["explicit", "empty", "omitted"] as const)( + it.each(["explicit", "empty", "omitted", "worker"] as const)( "automatically repairs an existing %s subscription after restart without dropping updates", - async (shape) => { + async (mode) => { + const shape = mode === "worker" ? "explicit" : mode; const lane = await draftFixture(); const prefixIds: string[] = []; + const onError = vi.fn(); + const coordinator = mode === "worker" ? createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }) : null; try { // Scheduler-only fixtures model a full already-confirmed first page. // They have no credentials; no provider qualification is claimed for @@ -70862,7 +71057,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const [templateEndpoint] = await readChatEndpointsById(lane.endpoint.id); const [templateConnection] = await readToolConnectionsById(lane.endpoint.connectionId); const fingerprint = createHash("sha256").update("[]").digest("hex"); - for (let index = 0; index < 25; index++) { + for (let index = 0; index < (mode === "worker" ? 105 : 25); index++) { const id = `00000000-${randomUUID().slice(9)}`; expect(id < lane.endpoint.id).toBe(true); const connectionId = randomUUID(); @@ -70959,7 +71154,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const targetIndex = eligible.findIndex( ({ id }) => id === lane.endpoint.id, ); - expect(targetIndex).toBeGreaterThanOrEqual(25); + expect(targetIndex).toBeGreaterThanOrEqual(mode === "worker" ? 105 : 25); const discoveryPasses = Math.floor(targetIndex / 25) + 1; const targetActions = () => db @@ -70972,7 +71167,17 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { sql`${chatActions.payload}->>'operation' = 'stop_subscription'`, ), ); - for (let page = 1; page <= discoveryPasses; page++) { + if (coordinator) { + await registerChatActionWork(coordinator, lane.context.service, () => true).ready; + // Startup recovery uses successive bounded pages. The target is + // beyond a complete 100-row confirmed prefix and is not in page one. + expect(await targetActions()).toEqual([]); + await vi.waitFor(async () => { + expect((await targetActions())[0]?.status).toBe("processed"); + expect(coordinator.nextWakeAt()).toBeNull(); + }, { timeout: 30_000 }); + expect(onError).not.toHaveBeenCalled(); + } else for (let page = 1; page <= discoveryPasses; page++) { await lane.context.service.processPendingDeliveries(); if (page < discoveryPasses) { expect(await targetActions()).toEqual([]); @@ -71025,6 +71230,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ({ method }) => method === "setWebhook", ); expect(mutations).toHaveLength(1); + if (coordinator) { + // A committed edit must not rediscover another connection, even + // when its durable proof is absent. Startup covers that inventory. + const foreignEndpoint = prefixIds[0]!; + await db.delete(chatActions).where(and(eq(chatActions.endpointId, foreignEndpoint), + eq(chatActions.kind, "telegram_stop_subscription"))); + await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { name: "Scoped Telegram edit" }); + await vi.waitFor(() => { + expect(coordinator.nextWakeAt()).toBeNull(); + expect(idleWorkSnapshot().active).toBe(0); + }, { timeout: 10_000 }); + expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, foreignEndpoint), + eq(chatActions.kind, "telegram_maintenance")))).toEqual([]); + expect(lane.maintenanceRequests.filter(({ method }) => method === "setWebhook")).toHaveLength(1); + } expect(mutations[0]!.body).toEqual({ url: lane.configuredWebhook.url, secret_token: lane.configuredWebhook.secret_token, @@ -71071,6 +71291,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ).toHaveLength(1); } finally { try { + await coordinator?.stop(); if (prefixIds.length) await db .update(chatEndpoints) diff --git a/server/src/app.ts b/server/src/app.ts index b81bb16b9dc..c45961b2034 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1269,7 +1269,9 @@ export async function createApp( await chatChannels.reconcileProviderRuntimes(); await chatChannels.voice.reconcile(); }, - processPendingDeliveries: () => chatChannels.processPendingChatMaintenance(), + // Chat delivery, action, and Telegram discovery workers now own these + // duties. Keep the coordinator's other maintenance lanes independent. + processPendingDeliveries: async () => undefined, processFailedGitHubWebhookDeliveries: () => chatChannels.processFailedGitHubWebhookDeliveries(), projectRunMilestones: () => diff --git a/server/src/services/chat-action-work.ts b/server/src/services/chat-action-work.ts index 6d1847526ed..880c35ee887 100644 --- a/server/src/services/chat-action-work.ts +++ b/server/src/services/chat-action-work.ts @@ -1,5 +1,5 @@ import { and, eq, inArray, lte, or, sql } from "drizzle-orm"; -import { chatActions, chatEndpoints, type Db } from "@paperclipai/db"; +import { chatActions, chatEndpoints, toolConnections, type Db } from "@paperclipai/db"; import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; import { CHAT_ACTION_QUEUES, type ScheduledChatAction } from "./chat-work-notifications.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; @@ -50,7 +50,17 @@ function pendingChatAction(kind: ScheduledChatAction) { ? inArray(chatActions.status, ["received", "processing"]) : or(inArray(chatActions.status, ["received", "processing"]), and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`)); - return and(eq(chatActions.kind, kind), status); + // Subscription repair is parked while its current endpoint/connection is + // inactive. Registration and removal retain their existing dispatch rules. + const telegramEligible = kind === "telegram_maintenance" ? sql`( + ${chatActions.payload}->>'operation' <> 'stop_subscription' or exists ( + select 1 from ${chatEndpoints} inner join ${toolConnections} + on ${toolConnections.id} = ${chatEndpoints.connectionId} and ${toolConnections.companyId} = ${chatEndpoints.companyId} + where ${chatEndpoints.id} = ${chatActions.endpointId} and ${chatEndpoints.companyId} = ${chatActions.companyId} + and ${chatEndpoints.provider} = 'telegram' and ${chatEndpoints.status} = 'active' + and ${toolConnections.status} = 'active' and ${toolConnections.enabled} = true + ))` : undefined; + return and(eq(chatActions.kind, kind), status, telegramEligible); } export function dueChatAction(kind: ScheduledChatAction, now = Date.now()) { @@ -67,6 +77,10 @@ export async function nextChatActionAt(db: Db, kind: ScheduledChatAction): Promi type ChatActionService = { processFailedChatRunRetries(): Promise; + processPendingTelegramActions(): Promise; + processTelegramSubscriptionDiscovery(signal?: AbortSignal): Promise; + scheduleTelegramSubscriptionDiscovery(connectionId: string): void; + hasPendingTelegramSubscriptionDiscovery(): boolean; processPendingProviderEffects(): Promise; processPendingGitHubWebhookIngress(): Promise; processPendingSlackSessionSyncs(): Promise; @@ -86,6 +100,7 @@ export function registerChatActionWork( ) { const actions = [ { kind: "failed_run_retry", run: service.processFailedChatRunRetries }, + { kind: "telegram_maintenance", run: service.processPendingTelegramActions }, { kind: "provider_effect", run: service.processPendingProviderEffects }, { kind: "github_webhook_ingress", run: service.processPendingGitHubWebhookIngress }, { kind: "slack_session_sync", run: service.processPendingSlackSessionSyncs }, @@ -101,5 +116,15 @@ export function registerChatActionWork( queue: DELIVERY_QUEUES.chatVerification, run: service.processPendingVerificationMessages, next: service.nextVerificationMessageAt, }, canRun)); + // Startup recovery visits the full inventory in bounded pages. Configuration + // commits only enqueue the changed connection, never restart a global sweep. + const discovery = coordinator.register(DELIVERY_QUEUES.chatTelegramSubscriptions, { + retryMs: 1000, + run: signal => service.processTelegramSubscriptionDiscovery(signal), + onSettled: hints => { for (const id of hints ?? []) service.scheduleTelegramSubscriptionDiscovery(id); }, + // Preserve a configuration wake received during idle drain without SQL. + hasPending: async () => !canRun() || service.hasPendingTelegramSubscriptionDiscovery(), + }); + workers.push(discovery); return { ready: Promise.all(workers.map(worker => worker.ready)) }; } diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 729af208be0..e87273b1d1f 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1380,7 +1380,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const { handleTelegramSlashCommand } = createTelegramCommands({ runtime, runtimeFor, handleSdkMessage }); const { stageGitHubWebhookIngress, githubIngressAcceptedResponse, processGitHubWebhookIngress, processFailedGitHubWebhookDeliveries, processPendingGitHubWebhookIngress } = createGitHubWebhookIngress(db, { endpointRecord, runtimeContextForRecord, resolveCredentialRefs, runtimeGeneration, credentialFingerprint, getWebhookPublicBaseUrl, redactError, handleWebhook, runtimeCallbackEndpoint, fetchImpl, persistence, withCredentialMutationLease, isShuttingDown: () => shuttingDown, get githubWebhookReplayBarrier() { return options.githubWebhookReplayBarrier; }, supportedWebhookEvents: SUPPORTED_GITHUB_WEBHOOK_EVENTS }); const { stageSlackSessionStop, processSlackSessionStop, processPendingSlackSessionStops } = createSlackSessionStop(db, { runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, stageAuthorizedTaskControlPublication, getPublicBaseUrl, get heartbeat() { return options.heartbeat; }, redactError }); - const { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, hasTelegramStopSubscription, recordTelegramStopSubscription } = createTelegramMaintenance(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, resolveCredentials, clearCredentials, runtimeCallbackEndpoint, getWebhookPublicBaseUrl, fetchImpl, runtimeGeneration, credentialFingerprint, redactError, credentialCheckTimeoutMs: PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS }); + const { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, processPendingTelegramActions, processTelegramSubscriptionDiscovery, scheduleTelegramSubscriptionDiscovery, hasPendingTelegramSubscriptionDiscovery, hasTelegramStopSubscription, recordTelegramStopSubscription } = createTelegramMaintenance(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, resolveCredentials, clearCredentials, runtimeCallbackEndpoint, getWebhookPublicBaseUrl, fetchImpl, runtimeGeneration, credentialFingerprint, redactError, credentialCheckTimeoutMs: PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS }); const { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability } = createChatResourceReconciliation(db, { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT }); const { applyGitHubInstallationLifecycle } = createGitHubInstallationLifecycle(db, { endpointRecord, resolveCredentials, runtimeCallbackEndpoint, runtimeGeneration, credentialFingerprint, @@ -1546,7 +1546,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if (!current) return; const connection = await tx.select().from(toolConnections).where(eq(toolConnections.id, current.connectionId)).then((rows) => rows[0]); if (!connection?.enabled) throw new Error("Photon endpoint requires operator recovery"); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx.update(chatEndpoints).set({ status: current.setup.step === "complete" ? "active" : "verifying", healthMessage: "Photon receiver connected", lastError: null, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpoint.id)); }); }, @@ -7854,6 +7854,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { processQueuedDeliveries: () => processPendingDeliveries(25, undefined, false), scheduleQueuedPublications: () => processPendingPublications(25, { waitForCompletion: false, maintenance: false }), processPendingChatMaintenance, + processPendingTelegramActions, + processTelegramSubscriptionDiscovery, + scheduleTelegramSubscriptionDiscovery, + hasPendingTelegramSubscriptionDiscovery, processPendingSlackBoardMessages, processPendingSlackTaskStarts, processPendingVerificationMessages: slackRegistration.processPendingVerificationMessages, diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts index b7cd9e4c22e..80fbaafd756 100644 --- a/server/src/services/chat-channels/configuration.ts +++ b/server/src/services/chat-channels/configuration.ts @@ -181,7 +181,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption code: "chat_endpoint_not_active", }); } - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) .set({ @@ -317,7 +317,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption inArray(chatDeliveries.state, ["received", "retry"]), ), ); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); [activatedEndpoint] = await tx .update(chatEndpoints) .set({ @@ -364,7 +364,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption const failedAt = new Date(); await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) .set({ @@ -467,7 +467,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption code: "chat_endpoint_already_removed", }); } - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) .set({ @@ -698,7 +698,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption try { const claimed = await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); const rows = await tx .update(chatEndpoints) .set({ @@ -778,7 +778,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) .set({ @@ -863,7 +863,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption await invalidateRuntime(endpoint.id).catch(() => undefined); await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) .set({ diff --git a/server/src/services/chat-channels/discord/gateway-events.ts b/server/src/services/chat-channels/discord/gateway-events.ts index e283fdc8630..f21c78cc11f 100644 --- a/server/src/services/chat-channels/discord/gateway-events.ts +++ b/server/src/services/chat-channels/discord/gateway-events.ts @@ -157,7 +157,7 @@ export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEvents if (current.status === "attention" && !gatewayOwnedAttention) return; const status = current.setup.step === "complete" ? "active" : "verifying"; - if (status !== current.status) await notifyChatEndpointWork(tx); + if (status !== current.status) await notifyChatEndpointWork(tx, current.connectionId); await tx .update(chatEndpoints) .set({ diff --git a/server/src/services/chat-channels/resource-reconciliation.ts b/server/src/services/chat-channels/resource-reconciliation.ts index 72ff07925b1..66a82e76ff3 100644 --- a/server/src/services/chat-channels/resource-reconciliation.ts +++ b/server/src/services/chat-channels/resource-reconciliation.ts @@ -508,7 +508,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc : currentEndpoint.status === "attention" ? "verifying" : currentEndpoint.status; - if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx); + if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx, currentEndpoint.connectionId); await tx .update(chatEndpoints) .set({ diff --git a/server/src/services/chat-channels/setup-probes.ts b/server/src/services/chat-channels/setup-probes.ts index 36d0f8f9f66..3aeb21f8833 100644 --- a/server/src/services/chat-channels/setup-probes.ts +++ b/server/src/services/chat-channels/setup-probes.ts @@ -296,7 +296,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) { )).limit(1).then((rows) => rows[0]); if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your connected account is no longer authorized"); } - await notifyChatEndpointWork(tx); + await notifyChatEndpointWork(tx, endpoint.connectionId); const [activated] = await tx .update(chatEndpoints) .set({ diff --git a/server/src/services/chat-channels/telegram/maintenance.ts b/server/src/services/chat-channels/telegram/maintenance.ts index 2d468d287f6..7caa0e994ec 100644 --- a/server/src/services/chat-channels/telegram/maintenance.ts +++ b/server/src/services/chat-channels/telegram/maintenance.ts @@ -1,6 +1,7 @@ import type { EndpointRow, DbTransaction, LifecycleRuntimeFence } from "../runtime-types.js"; -import { PROVIDER_EFFECT_STALE_MS } from "../../chat-action-work.js"; +import { PROVIDER_EFFECT_STALE_MS, dueChatAction } from "../../chat-action-work.js"; +import { notifyChatActionWork } from "../../chat-work-notifications.js"; import type { chatCredentialMutationLease, CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; @@ -12,8 +13,6 @@ import { asc, eq, gt, - lte, - or, sql, } from "drizzle-orm"; @@ -103,6 +102,16 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp const { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, resolveCredentials, clearCredentials, runtimeCallbackEndpoint, getWebhookPublicBaseUrl, fetchImpl, runtimeGeneration, credentialFingerprint, redactError, credentialCheckTimeoutMs: PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS } = options; let telegramSubscriptionScanAfter: string | null = null; + let startupDiscoveryPending = true; + let startupDiscoveryAfter: string | null = null; + const pendingDiscovery = new Map(); + + function withMaintenanceWrite(write: (tx: DbTransaction) => Promise): Promise { + return db.transaction(async tx => { + await notifyChatActionWork(tx, "telegram_maintenance"); + return write(tx); + }); + } async function stageTelegramMaintenance( endpoint: EndpointRow, @@ -122,7 +131,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp ? `${context.generation}:${record.endpoint.setup.testStartedAt ?? record.endpoint.updatedAt.toISOString()}` : String(context.generation); const providerActionId = `telegram_maintenance:${operation}:${operationGeneration}`; - const [inserted] = await db + const [inserted] = await withMaintenanceWrite(tx => tx .insert(chatActions) .values({ companyId: endpoint.companyId, @@ -144,7 +153,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp status: "received", }) .onConflictDoNothing() - .returning(); + .returning()); return ( inserted ?? (await db @@ -241,7 +250,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp if (action.status === "processed") return "processed"; const payload = telegramMaintenancePayload(action.payload); if (!payload) { - await db + await withMaintenanceWrite(tx => tx .update(chatActions) .set({ status: "failed", @@ -251,18 +260,18 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp }, updatedAt: new Date(), }) - .where(eq(chatActions.id, action.id)); + .where(eq(chatActions.id, action!.id))); return "failed"; } if (action.status === "processing") { if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) return "pending"; - await db + await withMaintenanceWrite(tx => tx .update(chatActions) .set({ status: "failed", result: { - ...(action.result ?? {}), + ...(action!.result ?? {}), retryable: true, retryAt: new Date().toISOString(), }, @@ -270,11 +279,11 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp }) .where( and( - eq(chatActions.id, action.id), + eq(chatActions.id, action!.id), eq(chatActions.status, "processing"), - eq(chatActions.updatedAt, action.updatedAt), + eq(chatActions.updatedAt, action!.updatedAt), ), - ); + )); action = await db .select() .from(chatActions) @@ -297,7 +306,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp ? action.result.attempts : 0) + 1; let providerConfirmed = action.result?.providerConfirmed === true; - const [claimed] = await db.transaction(async (tx) => { + const [claimed] = await withMaintenanceWrite(async (tx) => { await credentialLease.assertOwned(tx); const rows = await tx .update(chatActions) @@ -334,7 +343,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp // Empty refs prove removal cleanup crossed its final local boundary; // settle the idempotent action instead of misclassifying it as a // credential rotation. - await db.transaction(async (tx) => { + await withMaintenanceWrite(async (tx) => { await credentialLease.assertOwned(tx); await tx .update(chatActions) @@ -361,7 +370,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration) ) { - await db.transaction(async (tx) => { + await withMaintenanceWrite(async (tx) => { await credentialLease.assertOwned(tx); await tx .update(chatActions) @@ -422,7 +431,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp providerConfirmed = true; } await credentialLease.assertOwned(); - await db.transaction(async (tx) => { + await withMaintenanceWrite(async (tx) => { await credentialLease.assertOwned(tx); const confirmed = await tx .update(chatActions) @@ -450,7 +459,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp await credentialLease.assertOwned(); await clearCredentials(record.endpoint, credentialLease); } - await db.transaction(async (tx) => { + await withMaintenanceWrite(async (tx) => { await credentialLease.assertOwned(tx); const processed = await tx .update(chatActions) @@ -499,7 +508,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp ? new Date(Date.now() + retryAfterMs).toISOString() : null; const diagnostic = redactError(error); - await db + await withMaintenanceWrite(tx => tx .update(chatActions) .set({ status: "failed", @@ -507,7 +516,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp attempts: attempt, code: `telegram_maintenance_${disposition.kind}`, providerConfirmed: - providerConfirmed || action.result?.providerConfirmed === true, + providerConfirmed || action!.result?.providerConfirmed === true, redactedError: diagnostic, retryable, retryAt, @@ -516,11 +525,11 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp }) .where( and( - eq(chatActions.id, action.id), + eq(chatActions.id, action!.id), eq(chatActions.status, "processing"), sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, ), - ); + )); await logActivity(db, { companyId: action.companyId, actorType: "system", @@ -554,7 +563,7 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp } } - async function processPendingTelegramMaintenance(limit = 25) { + async function discoverTelegramSubscriptions(limit: number, after: string | null, connectionId?: string, signal?: AbortSignal) { // Keyset scanning bounds each sweep without starving endpoints behind a // page whose subscriptions were already confirmed. No provider I/O here. const candidates = await db @@ -573,18 +582,16 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp eq(chatEndpoints.status, "active"), eq(toolConnections.status, "active"), eq(toolConnections.enabled, true), - telegramSubscriptionScanAfter - ? gt(chatEndpoints.id, telegramSubscriptionScanAfter) + connectionId !== undefined ? eq(chatEndpoints.connectionId, connectionId) : undefined, + after + ? gt(chatEndpoints.id, after) : undefined, ), ) .orderBy(asc(chatEndpoints.id)) .limit(Math.max(1, Math.min(limit, 100))); - telegramSubscriptionScanAfter = - candidates.length >= Math.max(1, Math.min(limit, 100)) - ? candidates.at(-1)!.endpoint.id - : null; for (const { endpoint } of candidates) { + signal?.throwIfAborted(); try { const record = await endpointRecord(endpoint.id); if ( @@ -595,42 +602,58 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp )) ) await stageTelegramMaintenance(record.endpoint, "stop_subscription"); - } catch { + } catch (error) { logger.warn( { endpointId: endpoint.id }, "Telegram subscription maintenance could not be staged", ); + // Keep the pass recoverable. Earlier staged rows are idempotent, and + // failed discovery must not become invisible until the next restart. + throw error; } } - const now = new Date(); - const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS); + return candidates.length >= Math.max(1, Math.min(limit, 100)) + ? candidates.at(-1)!.endpoint.id : null; + } + + async function processTelegramSubscriptionDiscovery(signal?: AbortSignal) { + signal?.throwIfAborted(); + const scoped = pendingDiscovery.entries().next().value; + if (scoped) { + const [connectionId, request] = scoped; + const after = await discoverTelegramSubscriptions(100, request.after, connectionId, signal); + // A commit during this page replaces the request and requires a fresh + // scoped pass, including changes behind its cursor. + if (pendingDiscovery.get(connectionId) === request) { + if (after === null) pendingDiscovery.delete(connectionId); + else request.after = after; + } + } else if (startupDiscoveryPending) { + startupDiscoveryAfter = await discoverTelegramSubscriptions(100, startupDiscoveryAfter, undefined, signal); + startupDiscoveryPending = startupDiscoveryAfter !== null; + } + } + + function scheduleTelegramSubscriptionDiscovery(connectionId: string) { + pendingDiscovery.set(connectionId, { after: null }); + } + + function hasPendingTelegramSubscriptionDiscovery() { + return startupDiscoveryPending || pendingDiscovery.size > 0; + } + + async function processPendingTelegramMaintenance(limit = 25) { + // Preserve the explicit bounded recovery helper. The app's periodic + // reconciliation no longer calls it; registered workers own both lanes. + telegramSubscriptionScanAfter = await discoverTelegramSubscriptions(limit, telegramSubscriptionScanAfter); + return processPendingTelegramActions(limit); + } + + async function processPendingTelegramActions(limit = 25) { const actions = await db .select({ id: chatActions.id }) .from(chatActions) - .where( - and( - eq(chatActions.kind, "telegram_maintenance"), - sql`(${chatActions.payload}->>'operation' <> 'stop_subscription' or exists ( - select 1 from ${chatEndpoints} inner join ${toolConnections} - on ${toolConnections.id} = ${chatEndpoints.connectionId} and ${toolConnections.companyId} = ${chatEndpoints.companyId} - where ${chatEndpoints.id} = ${chatActions.endpointId} and ${chatEndpoints.companyId} = ${chatActions.companyId} - and ${chatEndpoints.provider} = 'telegram' and ${chatEndpoints.status} = 'active' - and ${toolConnections.status} = 'active' and ${toolConnections.enabled} = true - ))`, - or( - eq(chatActions.status, "received"), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, staleBefore), - ), - and( - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, - sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${now.toISOString()}::timestamptz)`, - ), - ), - ), - ) + .where(dueChatAction("telegram_maintenance")) .orderBy(asc(chatActions.createdAt)) .limit(limit); for (const action of actions) await processTelegramMaintenance(action.id); @@ -927,5 +950,5 @@ export function createTelegramMaintenance(db: Db, options: TelegramMaintenanceOp await assertCurrent(); await recordTelegramStopSubscription(endpoint, context, lease); } - return { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, hasTelegramStopSubscription, recordTelegramStopSubscription }; + return { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, processPendingTelegramActions, processTelegramSubscriptionDiscovery, scheduleTelegramSubscriptionDiscovery, hasPendingTelegramSubscriptionDiscovery, hasTelegramStopSubscription, recordTelegramStopSubscription }; } diff --git a/server/src/services/chat-work-notifications.ts b/server/src/services/chat-work-notifications.ts index 16e2ac75135..1d8b1ffa518 100644 --- a/server/src/services/chat-work-notifications.ts +++ b/server/src/services/chat-work-notifications.ts @@ -14,6 +14,7 @@ export const CHAT_ACTION_QUEUES = { provider_effect: DELIVERY_QUEUES.chatProviderEffects, github_webhook_ingress: DELIVERY_QUEUES.chatGitHubIngress, failed_run_retry: DELIVERY_QUEUES.chatFailedRunRetries, + telegram_maintenance: DELIVERY_QUEUES.chatTelegramMaintenance, slack_session_sync: DELIVERY_QUEUES.chatSessionSyncs, slack_board_message: DELIVERY_QUEUES.chatBoardMessages, slash_task_start: DELIVERY_QUEUES.chatTaskStarts, @@ -30,9 +31,10 @@ export async function notifyChatVerificationWork(tx: object): Promise { await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatVerification); } -export async function notifyChatEndpointWork(tx: object): Promise { +export async function notifyChatEndpointWork(tx: object, connectionId: string): Promise { await notifyChatDeliveryWork(tx); await notifyChatPublicationWork(tx); for (const queue of Object.values(CHAT_ACTION_QUEUES)) await notifyDeliveryWork(tx, queue); await notifyChatVerificationWork(tx); + await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatTelegramSubscriptions, connectionId); } diff --git a/server/src/services/delivery-work-coordinator.ts b/server/src/services/delivery-work-coordinator.ts index 0272fc2ed72..b0b580a2289 100644 --- a/server/src/services/delivery-work-coordinator.ts +++ b/server/src/services/delivery-work-coordinator.ts @@ -22,6 +22,8 @@ export function createDeliveryWorkCoordinator(input: { retryMs: number; run: (signal: AbortSignal) => Promise; hasPending: () => Promise; + // Scoped hints belong to the outer transaction, including uncertainty recovery. + onSettled?: (hints?: ReadonlySet) => void; // Optional maintenance/retry deadline restored from durable state. nextRunAt?: () => number | null; }) { @@ -72,7 +74,7 @@ export function createDeliveryWorkCoordinator(input: { dirty = true; if (!running) schedule(0); } - const unsubscribe = subscribeDatabaseWork(input.owner, queue, event => { + const unsubscribe = subscribeDatabaseWork(input.owner, queue, (event, hints) => { if (workerStopped) return; if (databaseWorkPending(input.owner, queue)) { finishIntent ??= beginIdleTrackedWork(); @@ -80,7 +82,7 @@ export function createDeliveryWorkCoordinator(input: { // replaces this deadline with an immediate wake on the normal path. if (!running) schedule(task.retryMs); } - if (event === "settled") wake(); + if (event === "settled") { task.onSettled?.(hints); wake(); } }); const worker = { wake, async stop() { workerStopped = true; diff --git a/server/src/services/delivery-work-notifications.ts b/server/src/services/delivery-work-notifications.ts index ab4ba5fe257..2bc6840e690 100644 --- a/server/src/services/delivery-work-notifications.ts +++ b/server/src/services/delivery-work-notifications.ts @@ -9,6 +9,8 @@ export const DELIVERY_QUEUES = { chatProviderEffects: "chat-provider-effects", chatGitHubIngress: "chat-github-ingress", chatFailedRunRetries: "chat-failed-run-retries", + chatTelegramMaintenance: "chat-telegram-maintenance", + chatTelegramSubscriptions: "chat-telegram-subscriptions", chatSessionSyncs: "chat-session-syncs", chatVerification: "chat-verification", chatInbound: "chat-inbound", @@ -25,8 +27,8 @@ export const DELIVERY_QUEUES = { } as const; export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES]; -export async function notifyDeliveryWork(transaction: object, queue: DeliveryQueue): Promise { - await signalDatabaseWork(transaction, queue); +export async function notifyDeliveryWork(transaction: object, queue: DeliveryQueue, hint?: string): Promise { + await signalDatabaseWork(transaction, queue, hint); } // Public delivery notification observers only see settled work. The coordinator // uses the lower-level lifecycle subscription to also fence in-flight writes. diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 65afe97f792..7a3d42eed73 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,5 @@ import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js"; +import { notifyChatEndpointWork } from "./chat-work-notifications.js"; import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; import { chatEndpoints } from "@paperclipai/db"; import { AGGREGATOR_NAMES, isAppAggregator, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "@paperclipai/shared/aggregator-apps"; @@ -19047,7 +19048,7 @@ export function toolAccessService( ...(input.credentialRefs ?? existing.credentialRefs), ...(input.credentialSecretRefs ?? existing.credentialSecretRefs), ]); - const [row] = await db + const write = (database: Db) => database .update(toolConnections) .set({ name: input.name ?? existing.name, @@ -19066,7 +19067,17 @@ export function toolAccessService( }) .where(eq(toolConnections.id, connectionId)) .returning(); - await syncCredentialBindings(row); + const [row] = existing.transport === "chat_sdk" + ? await db.transaction(async tx => { + await notifyChatEndpointWork(tx, existing.id); + const rows = await write(tx as unknown as Db); + // Credential resolution must never observe the binding rebuild's + // delete/insert gap after a configuration wake. + await syncCredentialBindings(rows[0]!, [], tx); + return rows; + }) + : await write(db); + if (existing.transport !== "chat_sdk") await syncCredentialBindings(row); await ensureRuntimeSlot(row); emitConnectionUpdated(row, existing, "api"); return toConnection(row); From 0b0e53029fa572429b892d13fcb72b22decb0d38 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:14:45 -0500 Subject: [PATCH 18/85] fix: recover saved workspace results automatically (#15824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path - Paperclip preserves accepted agent results before workspace copyback and result commitment. - A replaced controller can leave a physical copyback owner receipt behind. A lost database lock or expired lease cannot prove that its tar/Git children stopped. - Infrastructure recovery should belong to the runtime and deployment harness; task users should not investigate processes or attest shutdown. - New receipts record the exact deployment. The harness independently verifies that deployment fully stopped and signs a short-lived proof bound to the stack, run, owner and deployment. - The existing guarded reconciler then finishes the same accepted result without a new agent turn. Legacy receipts retain the documented administrator API after independent platform verification. ## Linked Issues or Issue Description **What happened?** An accepted native result can remain at `workspace_finalizing` with an abandoned physical copyback owner. Repeated recovery sweeps could replace specific instructions with advice to start new agent work. The first version of this PR exposed a shutdown-evidence form in the task; that asks end users to resolve infrastructure details. **Expected behavior** Automatically verify and recover the exact stopped deployment, preserve the original accepted result and existing approval/monitor waits, and show only a quiet task status. Unknown or live workers must remain fenced. Older receipts without platform identity need independently verified operator assistance through the administrator API. **Steps to reproduce** 1. Persist an accepted native result and a copyback receipt from a prior controller. 2. Replace that deployment before workspace finalization commits. 3. Let the normal finalization reconciler revisit the saved result. 4. With a platform-bound receipt and a fully stopped deployment, recovery should finish automatically. With missing, live, unverifiable or changed ownership, it must not write files or create another provider turn. Related public work: #14402 introduced copyback ownership; #9687 handles dead-run finalize barriers; #15795 handles terminal stale-run cleanup. This change addresses an accepted result whose physical copyback owner prevents commitment. ## What Changed - Record Railway project, environment, service and deployment identity on new physical copyback owners. - Request read-only platform verification from the managed cloud harness. Require an Ed25519 stop receipt with a disjoint purpose, configured signing key, exact stack/run/owner/deployment binding and valid expiry. - Keep live, unknown, malformed, unavailable and legacy owners fenced. Compare the whole prior receipt during ownership takeover, under the existing dedicated advisory lock. - Persist platform proof and audit automatic takeover. Resume normal finalization of the same saved result, preserving task ownership, approved boundaries, monitors and pending confirmations. - Remove process metadata, shutdown evidence, confirmation checkboxes and infrastructure-repair forms from task views. Suppress the duplicate technical owner-recovery card. Show “Restoring saved work…” only from server-confirmed recovery or queued operator recovery; platform identity alone shows the operator-assistance fallback. Remove the status when recovery resolves. - Share current-task, current-running-execution, accepted-result, authoritative-reviewer and newer-execution checks between automatic and operator recovery. Validate after the provider lookup, atomically with the owner claim, and again before file publication; closed or superseded tasks cannot start copyback. - Keep the separately authorized exact-owner API for operator recovery of legacy receipts, with active-lock, live-controller, stale-result and newer-execution rejection. Generic dismissal cannot bypass the copyback fence. - Replace obsolete form stories with production-backed automatic, queued, legacy, completed and mobile examples; retain stable suite IDs. Update the operator runbook and scoped Storybook catalog. ## Verification - Red/green: new platform-proof tests failed before implementation; the database takeover regression failed against the original ownership guard; reduced-UI assertions failed against the original form. All pass after the changes. - Passed: 59 physical-owner/finalization database tests using isolated PostgreSQL, including real reconciler commitment from all three saved-result phases through both automatic and operator recovery. They preserve result identity, exact saved file bytes, pending approval and overdue monitor waits, and create no new run or wake request. Unknown evidence and changed ownership remain fenced. - Passed: 162 focused proof, cloud identity, recovery route, recovery-card and reduced-UI tests. - Passed: full workspace typecheck and build, final server/UI typechecks, token gates, and Storybook build. - Browser inspection: desktop task composition shows only the quiet status; completed example removes it and retains the approval-wait message. Mobile shows the existing bottom navigation with no horizontal overflow. These are fixture UI observations, not live provider acceptance. - A companion harness change implements the actual provider check, signed receipts, server-token authentication and bounded/coalesced provider reads. Its full typecheck and test suite passed: 3,240 passed, 92 skipped, zero failures. This application alone cannot enable platform verification. - The updated full application `pnpm test:run` was stopped after repeated local embedded-Postgres startup failures; it is not claimed green. The same affected recovery suites pass against isolated PostgreSQL. CI passed on final head `9ad5c3529e`: 54 successful checks, two intentional skips, and successful legacy Snyk status. Greptile is 5/5 with no unresolved findings. - Global Storybook catalog validation is green: 225 files, 2,224 stories. Its 17 catalog/index tests pass. Master was merged and the inherited index metadata was reconciled after source review. - Review correction red/green: two UI and two database assertions reproduced misleading progress / missing server recovery state; all pass after server-confirmed state was added. All 153 focused server tests and 68 UI tests pass on the final recovery code, including repeated-sweep idempotence. Full workspace typecheck/build, token gates and Storybook build also pass. - Safety regression red/green: stale-task copyback and publication tests failed against the previous guard. The final suite covers changes during proof lookup, closed/cancelled tasks, replaced execution/checkout, newer and queued legacy runs, reassignment and rejected/unbound results, plus the real reconciler selecting closed or superseded tasks. Files remain untouched and no workspace operation starts. - No staging mutations, deployment, live provider probes or live production recovery were performed. The three historical receipts lack deployment identity and still require independent operator proof. ## Risks Automatic takeover requires the companion harness release and configured runtime-identity keys. Deploying only this application leaves unavailable evidence fenced. Legacy receipts are not retrospectively assigned a deployment identity. An authorized operator can still make an incorrect attestation through the fallback API; its administrator gate and exact-owner safeguards remain. Provider history that is missing or deeper than the bounded lookup defers automatic recovery. Copyback can still fail under its existing retained-workspace policy. No migration, telemetry change or cloud provider credential delivery is required. The companion harness PR also has passing CI and a 5/5 review; neither branch is deployed. ## Model Used OpenAI Codex, GPT-6, with repository inspection, browser verification, tool use and code execution. The exact serving model ID and context-window size were 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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run the relevant 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 Co-authored-by: Paperclip Co-authored-by: Devin Foley --- doc/native-workspace-finalization-recovery.md | 86 ++++- maps/agent-access-map/recovery.md | 29 +- maps/feature-map/recovery.md | 28 +- maps/storybook-map/INDEX.md | 10 +- maps/storybook-map/REVIEW.md | 78 ++++ maps/storybook-map/catalog.json | 121 ++++-- packages/shared/src/index.ts | 1 + packages/shared/src/validators/index.ts | 1 + packages/shared/src/validators/issue.ts | 8 + .../__tests__/authorization-service.test.ts | 35 ++ ...ue-agent-mutation-ownership-routes.test.ts | 70 +++- server/src/routes/issues.ts | 17 + server/src/routes/openapi.ts | 10 + server/src/services/authorization.ts | 16 + server/src/services/cloud-runtime-identity.ts | 6 +- server/src/services/issue-recovery-actions.ts | 17 +- ...native-workspace-finalization-ownership.ts | 99 ++++- .../native-workspace-finalizer.test.ts | 361 ++++++++++++++++++ .../native-workspace-owner-recovery.ts | 87 +++++ .../native-workspace-platform-proof.test.ts | 34 ++ .../native-workspace-platform-proof.ts | 65 ++++ .../native-workspace-recovery-binding.ts | 36 ++ ui/src/api/issues.ts | 2 + .../IssueRecoveryActionCard.test.tsx | 7 + ui/src/components/IssueRecoveryActionCard.tsx | 1 + .../WorkspaceExportRecovery.test.tsx | 88 ++--- ui/src/components/WorkspaceExportRecovery.tsx | 50 +-- ui/src/pages/IssueDetail.tsx | 8 +- .../01-notice.stories.tsx | 29 ++ .../02-task-journey.stories.tsx | 43 +++ .../fixtures.tsx | 37 ++ 31 files changed, 1306 insertions(+), 174 deletions(-) create mode 100644 server/src/services/native-runtime/native-workspace-owner-recovery.ts create mode 100644 server/src/services/native-runtime/native-workspace-platform-proof.test.ts create mode 100644 server/src/services/native-runtime/native-workspace-platform-proof.ts create mode 100644 server/src/services/native-runtime/native-workspace-recovery-binding.ts create mode 100644 ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx create mode 100644 ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx create mode 100644 ui/storybook/support/workspace-finalization-recovery/fixtures.tsx diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md index 01201315417..a9a6e6365f1 100644 --- a/doc/native-workspace-finalization-recovery.md +++ b/doc/native-workspace-finalization-recovery.md @@ -35,6 +35,46 @@ leave tar/Git children alive, so incomplete copyback requires operator stop verification even on the same host. PID reuse is checked against its recorded start time rather than trusted by PID alone. +## Automatic cloud recovery + +New Railway copyback receipts record the exact project, environment, service and +deployment ID. On takeover, the existing finalization reconciler asks the cloud +harness to inspect that deployment. The harness resolves the stack's owned +resources independently and issues a short-lived Ed25519 receipt only when the +exact deployment is `REMOVED` with `deploymentStopped: true`. This covers all +processes in the deployment, including orphaned copyback children. Missing, +truncated, live or unavailable provider evidence leaves the physical fence intact. + +The tenant verifies the configured harness key, disjoint receipt audience, stack, +run, owner token, deployment identity and expiry. It records proof and an activity +entry when replacing only the exact owner under the existing advisory lock. The +normal reconciler resumes the accepted result; it does not create a provider turn +or change approval/monitor waits. A changed owner during verification fails closed. + +Stop proof alone does not authorize old work to overwrite a current task. After +the provider read, automatic recovery uses the operator path's shared binding +checks under short coordinator, issue and run row locks, atomically with the +owner claim. The task must still be open, assigned to the run or its authoritative +review participant, and bound to that running native execution and accepted +result. A different checkout, newer execution or queued/running legacy execution +prevents takeover. Publication checks repeat this validation; row locks are never +held across physical copyback I/O. + +The harness endpoint is `POST /v1/tenant/workspace-worker-stop`. The fleet-wide +server token authorizes a read request, never stop evidence: responses must be +signed by the harness. This endpoint requires the companion cloud harness change. +Deploying only the application keeps unknown owners fenced. No cloud provider +credentials are delivered to tenant applications. + +The task view shows only “Restoring saved work…” after the server confirms recovery +or an operator queues it, +then removes the notice when commitment resolves recovery. It exposes no process +identifiers, shutdown form or manual resume control. Legacy receipts without +platform identity and unsupported platforms say operator assistance is needed; +operators use the documented exact-owner API below after independent stop proof. +The three diagnosed historical receipts do not gain a deployment identity merely +by upgrading. Never infer one from elapsed time or a hostname. + ## Unverified copyback after controller replacement Generic orphan recovery does not turn a missing provider PID into `process_lost` @@ -66,6 +106,48 @@ exact prior controller **and its copyback subprocesses** have stopped. Retain th sandbox, accepted native result, and workspace descriptor. Do not run a new provider turn, delete workspace contents, or relax archive confinement. +For legacy receipts, an authorized company owner, company administrator or +instance administrator records independently verified platform evidence through +the operator API. The task has no shutdown form or resume control. The API is +`POST /api/issues/:id/recovery-actions/resume-workspace-finalization` with: + +```json +{ + "actionId": "", + "runId": "", + "ownerToken": "", + "controllerAndCopybackStopped": true, + "stopEvidence": "" +} +``` + +This is an operator attestation, not an automatic deployment-platform probe. +The separate `runtime:confirm_owner_stop` authorization action limits this +attestation to active company owners or administrators, the trusted local board, +or an instance administrator. Ordinary runtime managers and agents cannot release +the physical owner receipt. Missing role data keeps the client notice read-only. +A +missing advisory connection alone is insufficient. Admission rejects an active +copyback lock, an observably live local controller, a changed owner, a newer run, +or an unbound result. It records the exact owner and operator evidence atomically +with releasing the receipt. The accepted result, workspace descriptor, source +lease, task status, monitors, and approval waits are unchanged. The ordinary +reconciler performs copyback and applies the saved result through the status +arbiter. No provider wake is enqueued. The recovery action remains visible until +finalization commits. Admission supports saved-result copyback from `result_accepted`, +`workspace_finalizing`, and `retryable_failure`; an abandoned retry does not need +a replacement run. The existing retry schedule remains unchanged. +Repeating the same confirmation before reconciliation is +idempotent; it cannot release a newer owner. Generic recovery resolution, including +false-positive dispositions, cannot dismiss this hold or bypass saved-result +finalization. + +Repeated sweeps preserve these copyback-specific instructions, including for +older actions whose recovery budget was already exhausted. They must never +recommend starting a replacement provider run for this hold. + +For older servers without this action, the maintenance fallback follows. + After that platform verification, use a database maintenance transaction to release only the exact receipt shown by the recovery action. Replace the four placeholders with the action's company, run, token, and source issue. The advisory @@ -138,8 +220,8 @@ A board member with runtime management access can complete the saved result: the export failure while preserving all user files. Restore transport access or other provider prerequisites before retrying. Extending the sandbox retention window can be necessary during this repair. -3. In the task's **Workspace export needs repair** notice, describe the repair and - choose **Retry workspace export**. The equivalent board API is +3. Use the operator API to record the repair and request export-only retry. The + task shows operator assistance is needed, without an infrastructure form. The board API is `POST /api/issues/:id/recovery-actions/retry-workspace-export` with `{ "actionId": "", "runId": "", "repairNote": "" }`. 4. Confirm the same run commits, the saved result determines the task outcome, diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index 1b90fa083d4..c7cc042f969 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -129,23 +129,40 @@ Review: `baseline`. API: `present` — representative operation IDs: - `POST /api/issues/{id}/recovery-actions/retry-workspace-export`. +- `POST /api/issues/{id}/recovery-actions/resume-workspace-finalization`. - `POST /api/execution-workspaces/{id}/reconcile-branch`. Agent access: `restricted` — Board/runtime management APIs; agent reads diagnosis and performs permitted workspace work. -Policy: Task/run visibility, run ownership and source/action policy; workspace export -retry and some operator controls require board. +Policy: Export retry and branch reconciliation require board runtime management. +Physical-owner stop confirmation uses the separate `runtime:confirm_owner_stop` +action. It requires active company owner/admin membership, trusted local board +access, or instance administration. Agents and ordinary runtime managers are denied. +The exact source task, running native run, accepted result and owner receipt must +match; active copyback locks, live local writers, and newer runs prevent admission. +An authorized administrator must first verify through the deployment platform that +the exact controller and all copyback children stopped, then attest that evidence. +Elapsed time, an expired coordinator lease or a lost advisory connection is not proof. +Only the exact owner receipt is released; normal reconciliation performs export and +commitment without another provider turn or bypassing existing task waits. -Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at -the reviewed commit above. +Evidence: `source-only` — [route](../../server/src/routes/issues.ts), +[administrator authorization](../../server/src/services/authorization.ts), and +[owner admission](../../server/src/services/native-runtime/native-workspace-owner-recovery.ts) +reviewed for this change. Local authorization, route, UI and finalization regressions +cover member/agent denial, exact-owner admission and the real reconciler preserving +monitor and approval waits. This is not live provider-stop proof. -Verification: `not-run` — assert the documented agent denial with an owned test fixture; +Verification: `not-run` for live agent/deployment execution — assert the documented agent denial with an owned test fixture; verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. Gaps: Export retry and branch reconciliation require board runtime:manage at -their HTTP routes. Native `call_api` preserves these denials. +their HTTP routes. The exact-owner operator fallback additionally requires +runtime:confirm_owner_stop. Native call_api preserves these agent denials. +Automatic managed-cloud recovery uses independently verified, signed platform +proof through the finalization reconciler; agents cannot attest owner shutdown. ## `unknown-outcome` diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index 3cd71abaffc..7d5a5945d6d 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -119,7 +119,22 @@ Automated: [disposition notices](../../ui/src/components/DispositionRecoveryNoti tests pending/error/gated states and waiting for the real retry acknowledgement. The recovery-card suite covers workspace divergence, reissue, reconcile, and confirmation. [Workspace export](../../ui/src/components/WorkspaceExportRecovery.test.tsx) -covers its separate repair surface. +covers quiet restoration, legacy/operator assistance and resolved states. + +For an abandoned native copyback owner, the harness verifies the exact stopped +deployment and the reconciler restores saved work automatically. Task users see +no process details or shutdown form. The documented operator API handles older +receipts after independent platform verification, through a separate administrator +gate. The [authorization regressions](../../server/src/__tests__/authorization-service.test.ts) +and [route regressions](../../server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts) +cover administrator admission, ordinary-member and agent denial, suspended and +foreign membership, missing role data, and stale cloud admin rows. +The [native workspace finalizer suite](../../server/src/services/native-runtime/native-workspace-finalizer.test.ts) +runs the real reconciler with a valid saved yield from `result_accepted`, +`workspace_finalizing`, and `retryable_failure`. It verifies physical fencing +before verified stop, automatic and operator recovery, successful export and commitment, hold resolution, +unchanged saved results, retained approval/monitor waits, and no new provider wake. +These local regressions do not prove a deployment platform stopped a foreign writer. Manual: reproduce an exhausted disposable action. Click once, verify pending state prevents repeated clicks, and inspect a successful continuation or an @@ -128,6 +143,17 @@ repair, record the compared branches/revisions and preserve uncommitted work; confirm the repair's actual result before resuming. Check the denied and stale action paths. A changed card label is insufficient proof of workspace integrity. +For an abandoned native controller, follow the [stop-verification procedure](../../doc/native-workspace-finalization-recovery.md) +with a disposable deployment. A new platform-bound owner recovers automatically +only after the harness signs proof that the exact deployment fully stopped. +Legacy owners require independently verified stop evidence through the operator +API; the task has no shutdown form. Confirm +the original accepted result commits, the recovery hold resolves, saved files +remain intact, and existing approval waits or monitor checks remain. No new agent +turn should start. Repeat with a retry-phase owner and an ordinary runtime manager; +the first can resume through automatic proof or authorized operator confirmation, +while the second cannot use the operator API. + ### `post-stop-message` Automated: [legacy continuation](../../server/src/services/recovery/legacy-continuation.test.ts), diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index ec3a4c08b5a..cc36bca0cc8 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -4,7 +4,7 @@ [Contract and maintenance](README.md) · [Review findings](REVIEW.md) -223 story files; 2215 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. +225 story files; 2224 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file. @@ -335,10 +335,12 @@ Stopped work, workspace repair, bounded continuation, and reconnect. | [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | current / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) | `recovery/bounded-continuation` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Source review confirms ordinary recovery fixtures remain applicable after the native-owner resolution gate; native-owner stop verification has its own suite. | | [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) | `recovery/diagnosis`, `recovery/source-action` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Projects and execution/Recovery/Workspace branch recovery/01 Repair card](../../ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 8 / 4 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Projects and execution/Recovery/Workspace branch recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx) | `recovery/workspace` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Master reconciliation inspected assignment-history recipient suggestions and manual-selection retention; existing stories do not establish the new assignment-history behavior. Expanded upstream verification recipes were inspected; the existing fixture scenarios do not prove the added backend handoff, workspace, recovery or authorization contracts. | +| [Projects and execution/Recovery/Workspace finalization recovery/01 Notice](../../ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx) | `recovery/workspace`, `recovery/source-action` | current / production / fixtures | 6 / 2 | Production quiet status; automatic restoration, legacy operator fallback, queued and resolved states. No task repair form, process details or API simulation. Provider proof is verified by separate server and harness tests. | +| [Projects and execution/Recovery/Workspace finalization recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx) | `recovery/workspace`, `recovery/source-action` | unreviewed / mixed / fixtures | 3 / 0 | Production shell, status, bubbles and composer in a local conversation composition; shows restoration and saved work waiting for confirmation. Fixture messages; not the shipped task page or live provider acceptance. | ## Apps, channels, and extensions @@ -667,9 +669,9 @@ References are navigation links, not proof of parity. An absent link means no ca - `execution-environments/images`: No cataloged story. - `recovery/diagnosis`: [Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab](../../ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice](../../ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx) (unreviewed), [Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page](../../ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx) (unreviewed), [Projects and execution/Recovery/Task chat/Recovery notice](../../ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Tasks/Execution recovery](../../ui/storybook/stories/recovery/execution-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue/Blocked notice](../../ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx) (unreviewed), [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed), [Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff](../../ui/storybook/stories/tasks/successful-run-handoff.stories.tsx) (unreviewed) - `recovery/bounded-continuation`: [Projects and execution/Recovery/Tasks/Model capacity retry](../../ui/storybook/stories/recovery/model-capacity-retry.stories.tsx) (unreviewed), [Projects and execution/Recovery/Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/recovery/scheduled-retry.stories.tsx) (unreviewed) -- `recovery/source-action`: [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed) +- `recovery/source-action`: [Projects and execution/Recovery/Design previews/Recovery notice](../../ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx) (current), [Projects and execution/Recovery/Paperclip/Source Issue Recovery](../../ui/storybook/stories/recovery/source-issue-recovery.stories.tsx) (unreviewed), [Projects and execution/Recovery/Paperclip/Source-resolved Fold](../../ui/storybook/stories/recovery/source-resolved-fold.stories.tsx) (unreviewed), [Projects and execution/Recovery/Workspace finalization recovery/01 Notice](../../ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx) (current), [Projects and execution/Recovery/Workspace finalization recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx) (unreviewed) - `recovery/exhaustion`: No cataloged story. -- `recovery/workspace`: [Projects and execution/Recovery/Workspace branch recovery/01 Repair card](../../ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx) (unreviewed), [Projects and execution/Recovery/Workspace branch recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx) (unreviewed) +- `recovery/workspace`: [Projects and execution/Recovery/Workspace branch recovery/01 Repair card](../../ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx) (unreviewed), [Projects and execution/Recovery/Workspace branch recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx) (unreviewed), [Projects and execution/Recovery/Workspace finalization recovery/01 Notice](../../ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx) (current), [Projects and execution/Recovery/Workspace finalization recovery/02 Task journey](../../ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx) (unreviewed) - `recovery/unknown-outcome`: No cataloged story. - `recovery/user-continuation`: No cataloged story. - `recovery/reconnect`: [Projects and execution/Recovery/App/Connection recovery](../../ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx) (unreviewed) diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index b1ab397fee0..e3575ea0ee7 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -274,6 +274,35 @@ Reviewed the same twelve workspace-recipe-dependent entries after extending the Reviewed the twelve unchanged workspace recipe subjects and fixtures after correcting implicit default selection. Task creation and controller classification now share configured-policy, primary, creation-time and ID precedence. Explicit choices and retained denial markers still suppress controller hooks. Project strategy fixtures show configuration, while runtime status and file/browser/artifact/service fixtures do not execute source scripts or prove this backend ordering. Refreshed only their reviewed recipe fingerprints and retained all classifications and stable IDs. No browser render, play, screenshot, Storybook build or live runtime acceptance was performed. +## Workspace finalization UI refinement — 2026-10-13 + +Source base: `f07efa2802`, followed by scoped edits to the production recovery notice and its two story suites. Reviewed `recovery/workspace` and `recovery/source-action`; stable story IDs and administrator admission remain unchanged. The notice leads with saved work, discloses controller metadata, explains evidence/confirmation requirements, and distinguishes queue acknowledgement from completion. A stale controller error preserves the note and clears confirmation so the operator must verify again. The task composition uses viewer identity for human bubbles and omits a routine status badge. + +MacOS in-app browser inspection passed for the task journey at desktop and 390px mobile widths: controller disclosure with Enter, evidence without confirmation remaining disabled, explicit confirmation enabling submission, and a simulated queued result in place. The `owner-changed` story's play assertions passed; the friendly error was visible, the note remained, confirmation cleared and the action was disabled. The `long-controller-name` story rendered at 390px without horizontal overflow. The mobile task shell showed its bottom navigation; evidence text computed to 16px and Tab reached the enabled action. No physical mobile keyboard, Linux screenshot baseline, live deployment stop, real API admission or provider copyback acceptance is claimed. Other story states received source review, not a full browser execution pass. + +Refreshed the two reviewed catalog entries only. Global catalog validation already reported unrelated source-fingerprint drift before this work; those entries require their own review. + +## Automatic workspace recovery revision — 2026-10-13 + +Supersedes the task shutdown form reviewed above. The production task now renders +only a quiet restoration status or an honest operator-assistance fallback. It +contains no process metadata, evidence field, confirmation checkbox or resume +button. The task thread omits the duplicate technical recovery card. Obsolete +form stories and their simulated API fixture were removed; suite IDs remain +stable. The composed task journey includes a saved state without the notice. + +Observed in the macOS in-app browser: automatic status in the production shell, +completed example without the status, retained approval-wait message, and mobile +bottom navigation with equal content and viewport widths (no horizontal overflow). +These are fixture UI observations, not live provider shutdown or task commitment. +The production component test verifies removal on a resolved action update. The +real reconciler's database tests separately preserve accepted-result identity, +monitor and approval waits, and create no new run or wake request. + +Only the two reviewed suites have refreshed fingerprints. Global validation has +71 other source-fingerprint errors, including recovery-recipe dependents after +the operator instructions changed. It is not globally green. No unrelated review +fingerprints or visual baselines were accepted. ## Inherited activity, inbox and recovery recipes — 2026-10-12 @@ -435,3 +464,52 @@ and three also reference the Slack-only settings copy. Other source bytes and all classifications, fixture boundaries, story IDs and sidebar titles remain unchanged from that baseline. Catalog checks validate these references and fingerprints, not live Slack behavior. + +### Current-master reconciliation + +Merged master `5e8e992df2`. The newer catalog supplies the baseline associations +and reviews. Compared every declared input against that master: only seven +inputs changed for this feature (recovery recipe, recovery card, task page, quiet +status, two stories and their fixture). Reviewed those changes and the unchanged +subjects/fixtures they affect; refreshed the 26 dependent fingerprints while +retaining their classifications and simulation boundaries. The task-page change +adds the quiet status and suppresses only the owner-unverified duplicate card; +other task variants retain their behavior. The recovery-card change blocks +unsafe generic dismissal. Existing card regressions cover that denial. + +Five remaining errors have unchanged source bytes and metadata inherited from +master: shared setup, decision-card, decisions-desk, search and what-needs-me. +These require a separate review; no hashes or visual baselines were accepted for +them. The earlier 71-error count above describes the pre-merge baseline. + +### Inherited index metadata follow-up + +The current master's personal-toast recipe also left shared setup and four inbox +suites with stale metadata. Reviewed the recipe's added toast capability and +verification limits: decision-card, decisions-desk and search still demonstrate +their existing isolated subjects; what-needs-me's toast fixture does not prove +viewer eligibility. Their subject and fixture bytes are unchanged from master; +retained all unreviewed classifications and fixture boundaries. The generator +adds the missing toast capability and description to the existing inbox feature, +without changing feature order or IDs. Reviewed that generated diff and refreshed +these four entries, the shared setup and Overview fingerprints. No toast behavior, +new browser acceptance or visual baseline is claimed. Global catalog validation +now passes. This supersedes the five inherited-error count above. + +### Confirmed recovery status + +Reviewed the shipped notice, server evidence contract and both recovery fixtures. +Platform identity alone no longer means work is restoring: the server marks the +exact recovery receipt resuming only after verified stop proof and a successful +owner claim. Missing proof remains operator-required. The new unavailable-proof +story covers a platform-bound receipt without progress; confirmed and queued +stories retain the quiet status. Updated only the two affected fingerprints. +This is component/fixture evidence, not deployed harness acceptance. + +### Latest master reconciliation + +Retained master’s task-creation visibility scenarios, source-review notes and +unreviewed classifications. Seven overlapping entries differ from master only +in the already reviewed recovery recipe or task-detail recovery notice wiring. +Refreshed those combined-source fingerprints and regenerated the index. No new +creation-visibility acceptance or visual baseline is claimed. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 1c81c9a3adf..005c7a6f2bf 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -304,7 +304,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx" ], - "fingerprint": "6994574cfda7901b4cec355add8dadb58146bd4cf0d3488796fe27e19e34cdc3", + "fingerprint": "0fc8fe3663215c79493116ec27e13daf92b8893806ee4e19e7e4143e97e4fe5f", "stableId": "design-explorations-agent-chat-sidebar-pages", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages" }, @@ -364,7 +364,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/agent-chat.stories.tsx" ], - "fingerprint": "c3faad0b551a84f6c7ddf5cf9566b96e0d435d28a4ac3035d147b069723b7152", + "fingerprint": "96b384fffda6225b3f00714d1fa33dc208806d9a502e2dcc46fba3c667010d18", "stableId": "design-explorations-agent-chat", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat" }, @@ -513,7 +513,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/agents/agent-persona-pages.stories.tsx" ], - "fingerprint": "35454c02bbae403677d0d79644dc92da6c96172265d0f7581508e7d111463e0f", + "fingerprint": "672f406658a3ade96fed4f5a18d2a413ec9888e348b74244b5d37cb3db760dde", "stableId": "agents-personas-full-pages", "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages" }, @@ -1196,7 +1196,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx" ], - "fingerprint": "cdb63642517b31c68df9ca8d62e60039f9f4d69d95d1a96dc96235fb7db1487d", + "fingerprint": "d6e735a2c56092274f139709aba0c1c555f3f3cb0fbf9820d50ee02874907b79", "stableId": "paperclip-assigned-backlog-safeguards", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards" }, @@ -1220,7 +1220,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx" ], - "fingerprint": "bcb6c0a3cb34fcd06e7b056521ad84fb968f73ba46a7ba00eda2a86bb060b11b", + "fingerprint": "70b54f5ce7c083b8e64c28501d66247d4753adabd732b377f7ad18309dc90ffe", "stableId": "product-inbox-blocked-tab", "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab" }, @@ -1456,7 +1456,7 @@ "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", "ui/storybook/stories/agent-chat/chat-entry.stories.tsx" ], - "fingerprint": "444465b306686cc36d85d4d2af43f9cd31f3c7c34a5fd05f8413aa639becf535", + "fingerprint": "3e85e934a82dae8089b73a1bddb0538640259ce1c70e7d16da38ee1e91ab0efc", "replacement": "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", "stableId": "design-explorations-chat-entry", "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry" @@ -1499,13 +1499,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/CloudAccessGate.tsx", "ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx" ], - "fingerprint": "bbec224b127ec6898cf95dfae922e6747483c0d0d4ec55ff7d1560c0c4a5c041", + "fingerprint": "64b2422bdabe2b07b563354481000171455841e49ccbf6a72270066d4e27e9e0", "stableId": "app-connection-recovery", "sidebarTitle": "Projects and execution/Recovery/App/Connection recovery" }, @@ -1829,14 +1829,14 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "maps/feature-map/secrets.md", "ui/src/components/task-chat/TaskChatMarker.tsx", "ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx" ], - "fingerprint": "2eba045db9127e5cd8d8bf7c9664e4598a5c84be117ae6159ce7171b24f2005d", + "fingerprint": "14757d026311d143e9301d6294d2f46f40dad25af8ea3f8d5cf1893b260f82db", "stableId": "task-credential-access-01-notice", "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice" }, @@ -1862,7 +1862,7 @@ "ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx", "ui/storybook/support/credential-access/CredentialTaskPage.tsx" ], - "fingerprint": "70a07a45f3633984db4bbadd599c3745e098f17100f8421b0c3cd1fec46e2186", + "fingerprint": "1289c5e6659cd318f00b63d4f16790bbac988d8d18f6726e10262223c11e66fe", "stableId": "task-credential-access-02-task-page", "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page" }, @@ -2210,13 +2210,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/ExecutionBlockerNotice.tsx", "ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx" ], - "fingerprint": "bee366ba1266e6692c8f0ee027c449fdda6b51762369ba431871a5af5ee018c6", + "fingerprint": "018d8d687921ea109cb98f587abd59b94722f1f148940852326417ce8a457b04", "stableId": "task-chat-recovery-notice", "sidebarTitle": "Projects and execution/Recovery/Task chat/Recovery notice" }, @@ -2230,7 +2230,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/ActiveAgentsPanel.tsx", @@ -2240,7 +2240,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/recovery/execution-recovery.stories.tsx" ], - "fingerprint": "6f8b245b169d95046bdb52513dfaf4937a96f807b72470c198863471f59c8149", + "fingerprint": "82ddff0cf6d98e558f9038d59960758347ad39f3c7a9aaf18f29214a8ad81756", "stableId": "tasks-execution-recovery", "sidebarTitle": "Projects and execution/Recovery/Tasks/Execution recovery" }, @@ -2861,7 +2861,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "maps/feature-map/task-coordination.md", @@ -2869,7 +2869,7 @@ "ui/src/components/task-chat/TaskChatBlockerLinks.tsx", "ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx" ], - "fingerprint": "6a84d7f60318d33a1835a668873095aaad1c215a83bbbb4735823fdcb3291140", + "fingerprint": "52f861389de245bda4d9c582581b49ce0b9442d60ee7eff950adc6801aaca8a4", "stableId": "product-issue-blocked-notice", "sidebarTitle": "Projects and execution/Recovery/Product/Issue/Blocked notice" }, @@ -3093,13 +3093,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/IssueScheduledRetryCard.tsx", "ui/storybook/stories/recovery/model-capacity-retry.stories.tsx" ], - "fingerprint": "5e08f681f3f96cb7be32e316d3f4066e5239dcf0f4393cb9ec09d599734f201e", + "fingerprint": "1db367a2b77b8c747ab5c81c1fe3317da02bf294308e9e09af827bcd12ee0b9f", "stableId": "tasks-model-capacity-retry", "sidebarTitle": "Projects and execution/Recovery/Tasks/Model capacity retry" }, @@ -3358,7 +3358,7 @@ "ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx", "ui/storybook/stories/task-coordination/originating-tasks.stories.tsx" ], - "fingerprint": "59123e73a0286fd11a48376ee152a974d730c59bd5421fd273cfca3a185f6e08", + "fingerprint": "32c9981ce024550b26636b97d7337f7b34a1207e8bcdd5759a11d9a7ddb204fe", "stableId": "ux-labs-tasks-created-from-a-task", "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task" }, @@ -3370,7 +3370,7 @@ "implementation": "guide", "boundary": "static", "notes": "Matches the upstream feature index and order, including features with no cataloged examples. Related suite links and classifications do not imply execution coverage.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "ui/src/components/ui/badge.tsx", "ui/src/components/ui/input.tsx", @@ -3793,7 +3793,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "d4b5ba6bce33a9657d859d3278b1f84bbc9b77cffd24e370214a0fce24c0a02b", + "fingerprint": "ebd9132a1f0228f775b1f382fc30dcae68e65284c8118c7b8a2eee3219c741ed", "stableId": "private-tasks-01-creation", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/01 Creation" }, @@ -3939,7 +3939,7 @@ "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "5fb218d88c3b6815839732e4226a27f4b40a2df46c8cbad6709cb665546b4248", + "fingerprint": "6069116fffc3a661df1fb10245f3f6d8045086efd71c886a8064a27eed09ac42", "stableId": "private-tasks-06-full-product-pages", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages" }, @@ -3986,7 +3986,7 @@ "ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx", "ui/storybook/support/private-tasks/PrivacyStory.tsx" ], - "fingerprint": "5fbe5c61f8754bc0c1db1c6a990b9c44bc5d941b7b61de77fac76300e6c42a22", + "fingerprint": "483a8bfae265844bfa10ebd8529572f43c23c8474d414b45ba0b663efc280dbd", "stableId": "private-tasks-07-user-stories", "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/07 User stories" }, @@ -4437,7 +4437,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/DispositionRecoveryNotice.tsx", @@ -4446,7 +4446,7 @@ "ui/storybook/prototypes/recovery-notice/RecoveryNoticePreview.tsx", "ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx" ], - "fingerprint": "cfd7345545112ea9ecc13035ebd187451117de5ed010f439cfb8c6962461fb3f", + "fingerprint": "e83bcaf099227bfcd17d571f680121b5b51d5f605d76479165000a575229a884", "stableId": "design-previews-recovery-notice", "sidebarTitle": "Projects and execution/Recovery/Design previews/Recovery notice" }, @@ -4697,7 +4697,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/IssueProperties.tsx", @@ -4705,7 +4705,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/recovery/scheduled-retry.stories.tsx" ], - "fingerprint": "43ef1917079251d558b3f52e5af750dfb69a54d3326d31494dbf037baeefe193", + "fingerprint": "f360f4207ef2b1cef18eee2307c50a7bce3a5f5bbf8966b24cd86b3ec50d21b4", "stableId": "product-issue-scheduled-retry-surfaces", "sidebarTitle": "Projects and execution/Recovery/Product/Issue Scheduled retry surfaces" }, @@ -5009,8 +5009,8 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Source review confirms ordinary recovery fixtures remain applicable after the native-owner resolution gate; native-owner stop verification has its own suite.", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/IssueBlockedNotice.tsx", @@ -5019,7 +5019,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/recovery/source-issue-recovery.stories.tsx" ], - "fingerprint": "1b6e44d4fb4ab7e2e63fb8816aa31fbe3267e10647f238ed82e6591a82d2e918", + "fingerprint": "949814263788ae0e3501b67bbd26118f150db219fc04e5cf653f18dd345ec8d5", "stableId": "paperclip-source-issue-recovery", "sidebarTitle": "Projects and execution/Recovery/Paperclip/Source Issue Recovery" }, @@ -5034,14 +5034,14 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/SourceResolvedFoldBadge.tsx", "ui/src/components/SourceResolvedFoldCallout.tsx", "ui/storybook/stories/recovery/source-resolved-fold.stories.tsx" ], - "fingerprint": "3af3daff618b8c278bcbf10ba3e5d081d87240c680758ccb3078eccdf9283122", + "fingerprint": "70cab38574df19267e35ab13c10c4a3eea444bb3b6e72c81f739f21cfc519df0", "stableId": "paperclip-source-resolved-fold", "sidebarTitle": "Projects and execution/Recovery/Paperclip/Source-resolved Fold" }, @@ -5122,7 +5122,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/successful-run-handoff.stories.tsx" ], - "fingerprint": "2a7db108d46f35f37dd9346d2fc039e61dff8c688e09d9ed7b0e777e7f79af0d", + "fingerprint": "27ad7c3c2b09acf5ac07163d44026511bd0c2b806387b591fef5fca343b0c63b", "stableId": "paperclip-successful-run-handoff", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff" }, @@ -5575,14 +5575,14 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/recovery.md", "ui/src/components/WorkspaceBaseRefRecoveryNotice.tsx", "ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx", "ui/storybook/support/workspace-branch-recovery/fixtures.ts" ], - "fingerprint": "77f708cc660f6829cadaffa6139930a102109976c41f7e24e2f662ed687d2412", + "fingerprint": "eeaa7044a1d5a2b5355a8ac2375820e3611c71920e88c04d87e5d7ce64ed929a", "stableId": "workspace-branch-recovery-01-repair-card", "sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/01 Repair card" }, @@ -5607,7 +5607,7 @@ "ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx", "ui/storybook/support/workspace-branch-recovery/fixtures.ts" ], - "fingerprint": "5eedfcb5738c125d3fa8ed7dc4b3d20a300b216de51f1ae5e58ede98d38fd75c", + "fingerprint": "f8a090cfdbadfe77d8ee064b99fc11b8f5e097d4e72173df2496280ef52152d1", "stableId": "workspace-branch-recovery-02-task-journey", "sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/02 Task journey" }, @@ -5671,6 +5671,53 @@ "fingerprint": "b9cd51c58b31ab445e2997b9f2a23c7a7b3f4ecd7b6dd39fc0f35ae0779311df", "stableId": "workspaces-service-control-bar", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar" + }, + { + "file": "ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx", + "capabilities": [ + "recovery/workspace", + "recovery/source-action" + ], + "purpose": "Workspace finalization recovery/01 Notice", + "status": "current", + "implementation": "production", + "boundary": "fixtures", + "notes": "Production quiet status; automatic restoration, legacy operator fallback, queued and resolved states. No task repair form, process details or API simulation. Provider proof is verified by separate server and harness tests.", + "reviewedAt": "2026-10-13", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/WorkspaceExportRecovery.tsx", + "ui/storybook/stories/recovery/workspace-finalization-recovery/01-notice.stories.tsx", + "ui/storybook/support/workspace-finalization-recovery/fixtures.tsx" + ], + "fingerprint": "45bf8c793c91362634b7ce32ed0ec2007861b7fefbfd0e2da692c3dc804a53c9", + "stableId": "workspace-finalization-recovery-01-notice", + "sidebarTitle": "Projects and execution/Recovery/Workspace finalization recovery/01 Notice" + }, + { + "file": "ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx", + "capabilities": [ + "recovery/workspace", + "recovery/source-action" + ], + "purpose": "Workspace finalization recovery/02 Task journey", + "status": "unreviewed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Production shell, status, bubbles and composer in a local conversation composition; shows restoration and saved work waiting for confirmation. Fixture messages; not the shipped task page or live provider acceptance.", + "reviewedAt": "2026-10-13", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/Layout.tsx", + "ui/src/components/WorkspaceExportRecovery.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/storybook/stories/recovery/workspace-finalization-recovery/02-task-journey.stories.tsx", + "ui/storybook/support/workspace-finalization-recovery/fixtures.tsx" + ], + "fingerprint": "aa76cd28dde8d50abd208b34283249170f3a85435ba82fff169a1c33433dfca1", + "stableId": "workspace-finalization-recovery-02-task-journey", + "sidebarTitle": "Projects and execution/Recovery/Workspace finalization recovery/02 Task journey" } ] } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b1655cf3169..48c3110dac1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1999,6 +1999,7 @@ export { resolveIssueRecoveryActionSchema, isValidExistingBranchName, retryWorkspaceExportSchema, + resumeWorkspaceFinalizationSchema, issueReviewRequestSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e7b3197a550..8e224de0924 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -441,6 +441,7 @@ export { resolveIssueRecoveryActionSchema, isValidExistingBranchName, retryWorkspaceExportSchema, + resumeWorkspaceFinalizationSchema, issueReviewRequestSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 8fddad793ba..0d9ea3bdceb 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -557,6 +557,14 @@ const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [ "cancelled", ] as const; +export const resumeWorkspaceFinalizationSchema = z.object({ + actionId: z.string().guid(), + runId: z.string().guid(), + ownerToken: z.string().guid(), + controllerAndCopybackStopped: z.literal(true), + stopEvidence: z.string().trim().min(20).max(12000), +}).strict(); + export const retryWorkspaceExportSchema = z.object({ actionId: z.string().guid(), runId: z.string().guid(), diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 4e1f3f63c2e..a2b28921c5e 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1810,6 +1810,41 @@ describeEmbeddedPostgres("authorization service", () => { expect(await auth.decide({ actor, action: "agent:wake", resource: { ...resource, companyId: otherCompany.id } })).toMatchObject({ allowed: false }); }); + it.each(["session", "cloud_tenant"] as const)("restricts %s workspace-owner stop confirmation to administrators", async source => { + const company = await createCompany(db, "OwnerStop"); + const otherCompany = await createCompany(db, "OtherOwnerStop"); + const userId = await createUser(db); + await db.insert(companyMemberships).values({ companyId: company.id, + principalType: "user", principalId: userId, status: "active", membershipRole: "owner" }); + const actor = { type: "board" as const, source, userId, companyIds: [company.id] }; + const auth = authorizationService(db); + const request = { actor, action: "runtime:confirm_owner_stop" as const, + resource: { type: "company" as const, companyId: company.id } }; + for (const membershipRole of ["owner", "admin", "operator", "member", "viewer", null]) { + await db.update(companyMemberships).set({ membershipRole }).where(eq(companyMemberships.principalId, userId)); + expect((await auth.decide(request)).allowed, `role ${membershipRole}`).toBe(membershipRole === "owner" || membershipRole === "admin"); + } + await db.update(companyMemberships).set({ membershipRole: "admin", status: "suspended" }).where(eq(companyMemberships.principalId, userId)); + expect((await auth.decide(request)).allowed).toBe(false); + await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId)); + expect((await auth.decide({ ...request, resource: { type: "company", companyId: otherCompany.id } })).allowed).toBe(false); + }); + + it("does not allow agent delegation or stale cloud admin rows to confirm an owner stop", async () => { + const company = await createCompany(db, "OwnerStopDelegation"); + const userId = await createUser(db); + const agent = await createAgent(db, company.id, { role: "ceo" }); + await db.insert(companyMemberships).values({ companyId: company.id, + principalType: "user", principalId: userId, status: "active", membershipRole: "operator" }); + await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" }); + const auth = authorizationService(db); + const request = { action: "runtime:confirm_owner_stop" as const, resource: { type: "company" as const, companyId: company.id } }; + expect((await auth.decide({ ...request, actor: { type: "board", userId, source: "cloud_tenant", isInstanceAdmin: false } })).allowed).toBe(false); + expect((await auth.decide({ ...request, actor: { type: "agent", agentId: agent.id, companyId: company.id, onBehalfOfUserId: userId } })).allowed).toBe(false); + expect((await auth.decide({ ...request, actor: { type: "board", userId, source: "cloud_tenant", isInstanceAdmin: true } })).allowed).toBe(true); + expect((await auth.decide({ ...request, actor: { type: "board", source: "local_implicit" } })).allowed).toBe(true); + }); + it("limits viewer members to read-only visibility actions", async () => { const company = await createCompany(db, "BoardViewerVisibility"); const userId = `user-${randomUUID()}`; 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 6aa18723cba..4afb8bad37c 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -162,8 +162,10 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null)); const mockRetryWorkspaceExport = vi.hoisted(() => vi.fn()); +const mockResumeWorkspaceFinalization = vi.hoisted(() => vi.fn()); function registerRouteMocks() { + vi.doMock("../services/native-runtime/native-workspace-owner-recovery.js", () => ({ resumeNativeWorkspaceAfterOwnerStop: mockResumeWorkspaceFinalization })); vi.doMock("../services/native-runtime/native-workspace-export-retry.js", () => ({ retryNativeWorkspaceExport: mockRetryWorkspaceExport })); vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), @@ -2009,6 +2011,52 @@ describe("agent issue mutation checkout ownership", () => { ); }); + it("resumes a verified stopped workspace owner without waking the provider", async () => { + mockAccessService.decide.mockResolvedValue({ allowed: true }); + mockResumeWorkspaceFinalization.mockResolvedValue({ runId: ownerRunId, status: "queued" }); + const res = await request(await createApp(boardActor())) + .post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`) + .send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId, + controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." }); + expect(res.status).toBe(202); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "runtime:confirm_owner_stop" })); + expect(mockResumeWorkspaceFinalization).toHaveBeenCalledWith(expect.objectContaining({ companyId, issueId, runId: ownerRunId, ownerToken: ownerRunId, actorId: "board-user" })); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it.each([{ controllerAndCopybackStopped: false }, { stopEvidence: "" }])("requires explicit stop evidence: %j", async invalid => { + const res = await request(await createApp(boardActor())) + .post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`) + .send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId, + controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited.", ...invalid }); + expect(res.status).toBe(400); + expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled(); + }); + + it.each(["agent", "viewer"])("rejects %s workspace-owner release before admission", async kind => { + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read", explanation: "No runtime access" })); + const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor())) + .post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`) + .send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId, + controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." }); + expect(res.status).toBe(403); + expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled(); + }); + + it("rejects an ordinary runtime manager's claimed foreign-owner stop", async () => { + mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({ + allowed: action === "issue:read" || action === "runtime:manage", + explanation: "A deployment administrator must verify the controller stopped", + })); + const res = await request(await createApp(boardActor())) + .post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`) + .send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId, + controllerAndCopybackStopped: true, stopEvidence: "I claim that the foreign controller and all its processes have stopped." }); + expect(res.status).toBe(403); + expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("queues board export-only recovery without calling provider wake", async () => { mockAccessService.decide.mockResolvedValue({ allowed: true }); mockRetryWorkspaceExport.mockResolvedValue({ runId: ownerRunId, status: "queued" }); @@ -2030,7 +2078,25 @@ describe("agent issue mutation checkout ownership", () => { expect(res.status).toBe(403); expect(mockRetryWorkspaceExport).not.toHaveBeenCalled(); }); - it.each(["todo", "done", "in_review"].flatMap(sourceIssueStatus => ["native_workspace_sync_out_unsafe_archive", "native_workspace_sync_out_retry_exhausted"].map(cause => ({ sourceIssueStatus, cause }))))("does not resolve accepted export recovery through an ordinary $sourceIssueStatus transition: $cause", async ({ sourceIssueStatus, cause }) => { + it.each(["done", "in_review"])("does not bypass unverified copyback via false_positive to %s", async sourceIssueStatus => { + const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }); + mockIssueService.getById.mockResolvedValue(sourceIssue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...sourceIssue, ...patch })); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, status: "active", kind: "active_run_watchdog", ownerType: "board", + ownerAgentId: null, returnOwnerAgentId: ownerAgentId, + cause: "native_workspace_finalization_owner_unverified", evidence: { runId: ownerRunId }, + }); + const res = await request(await createApp(boardActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ actionId: recoveryActionId, outcome: "false_positive", sourceIssueStatus }); + expect(res.status).toBe(409); + expect(res.body.details?.code).toBe("workspace_owner_stop_required"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it.each(["todo", "done", "in_review"].flatMap(sourceIssueStatus => ["native_workspace_sync_out_unsafe_archive", "native_workspace_sync_out_retry_exhausted", "native_workspace_finalization_owner_unverified"].map(cause => ({ sourceIssueStatus, cause }))))("does not resolve accepted export recovery through an ordinary $sourceIssueStatus transition: $cause", async ({ sourceIssueStatus, cause }) => { const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }); mockIssueService.getById.mockResolvedValue(sourceIssue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...sourceIssue, ...patch })); @@ -2043,7 +2109,7 @@ describe("agent issue mutation checkout ownership", () => { .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus }); expect.soft(res.status).toBe(409); - expect.soft(res.body.details?.code).toBe(cause === "native_workspace_sync_out_unsafe_archive" ? "workspace_export_automatic_recovery" : "workspace_export_retry_required"); + expect.soft(res.body.details?.code).toBe(cause === "native_workspace_sync_out_unsafe_archive" ? "workspace_export_automatic_recovery" : cause === "native_workspace_finalization_owner_unverified" ? "workspace_owner_stop_required" : "workspace_export_retry_required"); expect.soft(mockIssueService.update).not.toHaveBeenCalled(); expect.soft(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7af8d4c1239..fa2abf0c522 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -17,6 +17,7 @@ import { setIssueTitleSchema } from "@paperclipai/shared"; import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js"; import { createIssueReadTiming } from "../services/issue-read-timing.js"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; +import { resumeNativeWorkspaceAfterOwnerStop } from "../services/native-runtime/native-workspace-owner-recovery.js"; import { retryNativeWorkspaceExport } from "../services/native-runtime/native-workspace-export-retry.js"; import { queuedInteractionId, readQueuedInteractionResponse, hasQueuedInteractionResponse } from "../services/queued-interaction-response.js"; import { deliverConversationComments, isConversation } from "../services/agent-conversations.js"; @@ -96,6 +97,7 @@ import { resolveCreateIssueStatusDefault, resolveIssueRecoveryActionSchema, retryWorkspaceExportSchema, + resumeWorkspaceFinalizationSchema, runnerGoalActionRequestSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, @@ -9511,6 +9513,17 @@ export function issueRoutes( }); }); + router.post("/issues/:id/recovery-actions/resume-workspace-finalization", validate(resumeWorkspaceFinalizationSchema), async (req, res) => { + assertBoard(req); + 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 decision = await access.decide({ actor: req.actor, action: "runtime:confirm_owner_stop", resource: { type: "company", companyId: issue.companyId } }); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + const receipt = await resumeNativeWorkspaceAfterOwnerStop({ db, companyId: issue.companyId, issueId: issue.id, + ...req.body, actorId: getActorInfo(req).actorId }); + res.status(202).json(receipt); + }); + router.post("/issues/:id/recovery-actions/retry-workspace-export", validate(retryWorkspaceExportSchema), async (req, res) => { assertBoard(req); const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); @@ -9757,6 +9770,10 @@ export function issueRoutes( throw conflict("Unsafe workspace export recovers automatically without another provider turn.", { code: "workspace_export_automatic_recovery" }); } + if ((outcome === "restored" || outcome === "false_positive") && activeRecoveryAction.cause === "native_workspace_finalization_owner_unverified") { + throw conflict("Verify the previous controller and its copyback processes stopped, then use Resume saved result.", { code: "workspace_owner_stop_required" }); + } + if (outcome === "restored" && isNativeWorkspaceExportRepairCause(activeRecoveryAction.cause)) { throw conflict("Repair the retained sandbox, then use Retry workspace export to finish the accepted result without another provider turn.", { code: "workspace_export_retry_required" }); } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 532e3a66a22..e749eaeef3c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -240,6 +240,7 @@ import { createAcceptedPlanDecompositionSchema, resolveIssueRecoveryActionSchema, retryWorkspaceExportSchema, + resumeWorkspaceFinalizationSchema, cancelIssueThreadInteractionSchema, // Secret provider configs and remote import createSecretProviderConfigSchema, @@ -10635,6 +10636,15 @@ registerCurrentRoute({ summary: "List issue recovery actions", }); +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/recovery-actions/resume-workspace-finalization", + tags: ["issues"], + summary: "Resume a saved native result after operator verification of the prior workspace owner's stop", + body: resumeWorkspaceFinalizationSchema, + responses: { 202: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + registerCurrentRoute({ method: "post", path: "/api/issues/{id}/recovery-actions/retry-workspace-export", diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 7bc119b86a4..9b68990080e 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -94,6 +94,7 @@ export type AuthorizationAction = | "project:read" | "project:write_workspace" | "runtime:manage" + | "runtime:confirm_owner_stop" | "secrets:read" | "secrets:propose"; @@ -184,6 +185,7 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null action === "issue:read" || action === "project:read" || action === "runtime:manage" || + action === "runtime:confirm_owner_stop" || action === "secrets:read" || action === "secrets:propose" ) { @@ -1858,6 +1860,11 @@ export function authorizationService(db: Db | DbTransaction) { }); } + if (input.action === "runtime:confirm_owner_stop" && input.actor.type !== "board") { + return deny({ action: input.action, reason: "deny_unsupported_action", + explanation: "Only a deployment administrator can confirm a workspace controller has stopped." }); + } + if (input.actor.type === "board") { let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null; if (input.actor.source === "local_implicit") { @@ -1886,6 +1893,15 @@ export function authorizationService(db: Db | DbTransaction) { explanation: "Allowed because the actor is an instance admin.", }); } + if (input.action === "runtime:confirm_owner_stop") { + const membership = await getActiveMembership(companyId, "user", input.actor.userId ?? ""); + if (membership?.membershipRole === "owner" || membership?.membershipRole === "admin") { + return allow({ action: input.action, reason: "allow_role_default", + explanation: "Allowed by the active company owner or administrator role." }); + } + return deny({ action: input.action, reason: "deny_missing_grant", + explanation: "Only a company owner, company administrator, or instance administrator can confirm a workspace controller has stopped." }); + } // What instance-admin elevation used to give cloud tenant users is // replaced by company-scoped visibility: an active membership in the // resource company grants the same read surface a same-company agent diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index e6567f2cf9e..435c7f7562b 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -231,7 +231,7 @@ function decodeJsonPart(part: string, label: string): Record { return parsed as Record; } -function publicKeyForKid(env: NodeJS.ProcessEnv, kid: string) { +export function cloudRuntimePublicKeyForKid(env: NodeJS.ProcessEnv, kid: string) { const raw = nonEmpty(env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS); if (!raw) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is not configured"); let parsed: unknown; @@ -276,7 +276,7 @@ function verifyClaims(input: { ) { throw new Error("Cloud runtime identity protected header is invalid"); } - const key = publicKeyForKid(input.env, header.kid); + const key = cloudRuntimePublicKeyForKid(input.env, header.kid); const signature = Buffer.from(encodedSignature, "base64url"); const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii"); if (!verify(null, signingInput, key, signature)) { @@ -523,7 +523,7 @@ export function verifyCloudControlAssertion(input: { ) { throw new Error("Cloud control protected header is invalid"); } - const key = publicKeyForKid(env, header.kid); + const key = cloudRuntimePublicKeyForKid(env, header.kid); const signature = Buffer.from(encodedSignature, "base64url"); const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii"); if (!verify(null, signingInput, key, signature)) { diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts index aeb7384bee3..6dd31f091cb 100644 --- a/server/src/services/issue-recovery-actions.ts +++ b/server/src/services/issue-recovery-actions.ts @@ -375,6 +375,18 @@ export function issueRecoveryActionService(db: Db) { // beyond the advertised cap. A distinct identity can still supersede the // exhausted action through the branch above. if (isRecoveryBudgetExhausted(existing.evidence ?? {})) { + // Older sweeps replaced this operator-only hold with instructions to + // start another run. Restore the exact copyback recovery instructions + // without reopening its budget or erasing a recorded stop confirmation. + if (existing.cause === "native_workspace_finalization_owner_unverified" + && input.cause === existing.cause && input.fingerprint === existing.fingerprint + && !existing.evidence?.workspaceOwnerStop && existing.nextAction !== input.nextAction) { + const [updated] = await db.update(issueRecoveryActions).set({ nextAction: input.nextAction, updatedAt: now }) + .where(and(eq(issueRecoveryActions.id, existing.id), + inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]), + sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`)).returning(); + if (updated) return toReadModel(updated); + } return existing; } const nextAttemptCount = @@ -417,8 +429,9 @@ export function issueRecoveryActionService(db: Db) { fingerprint: existing.fingerprint, }, }, - nextAction: - `Automatic recovery exhausted after ${attemptsUsed}/${effectiveMaxAttempts} attempts. ` + + nextAction: existing.cause === "native_workspace_finalization_owner_unverified" + ? input.nextAction + : `Automatic recovery exhausted after ${attemptsUsed}/${effectiveMaxAttempts} attempts. ` + "Review the infrastructure failure and explicitly choose a replacement run or provider configuration.", wakePolicy: null, monitorPolicy: null, diff --git a/server/src/services/native-runtime/native-workspace-finalization-ownership.ts b/server/src/services/native-runtime/native-workspace-finalization-ownership.ts index fcbff05fdee..5168b9bf856 100644 --- a/server/src/services/native-runtime/native-workspace-finalization-ownership.ts +++ b/server/src/services/native-runtime/native-workspace-finalization-ownership.ts @@ -1,15 +1,19 @@ +import { logActivity } from "../activity-log.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { nativeSha256 } from "./canonical.js"; import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js"; import { randomUUID } from "node:crypto"; import os from "node:os"; -import { and, eq, sql } from "drizzle-orm"; -import { heartbeatRuns, workspaceOperations, withDedicatedDbConnection, type Db } from "@paperclipai/db"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, workspaceOperations, withDedicatedDbConnection, type Db } from "@paperclipai/db"; +import { currentNativeWorkspaceRecoveryResult } from "./native-workspace-recovery-binding.js"; import { currentNativeControllerIdentity, evaluateNativeControllerTakeover, } from "./native-restart-recovery.js"; +import { currentWorkspaceWorker, requestWorkspaceWorkerStopProof, type WorkspaceWorker, type WorkspaceWorkerStopProof } from "./native-workspace-platform-proof.js"; + const OWNER_KEY = "nativeWorkspaceFinalizationOwner"; const activeOwnerTokens = new Set(); // Positive same-boot evidence, retained only when durable cleanup could not be @@ -21,6 +25,7 @@ type Owner = { hostname: string; pid: number; processStartedAt: string; + worker?: WorkspaceWorker; }; export type NativeWorkspaceFinalizationOwnership = { token: string; @@ -58,7 +63,7 @@ export async function withNativeWorkspaceFinalizationOwnership( const identity = await currentNativeControllerIdentity(); const owner: Owner = { token: randomUUID(), controllerBootId: identity.bootId, hostname: os.hostname(), pid: identity.pid, - processStartedAt: identity.processStartedAt.toISOString(), + processStartedAt: identity.processStartedAt.toISOString(), worker: currentWorkspaceWorker(), }; const scope = and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.runtimeMode, "native")); const owned = and(scope, sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${owner.token}`); @@ -73,13 +78,42 @@ export async function withNativeWorkspaceFinalizationOwnership( if (!run) throw new Error("native_workspace_finalization_binding_missing"); const rawPrior = run.profile?.[OWNER_KEY]; const prior = readOwner(rawPrior); + let automaticResultId: string | undefined; + const currentRecoveryResult = async (db: Db) => { + if (!run.issueId) return null; + // Use the arbiter's lock order and release row locks before copyback I/O. + const [coordinator] = await db.select().from(nativeRunFinalizations).where(and( + eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId), + eq(nativeRunFinalizations.issueId, run.issueId), + )).for("update"); + const [issue] = await db.select().from(issues).where(and( + eq(issues.companyId, input.companyId), eq(issues.id, run.issueId), + )).for("update"); + const [currentRun] = await db.select().from(heartbeatRuns).where(scope).for("update"); + return coordinator && issue && currentRun ? currentNativeWorkspaceRecoveryResult(db, { + companyId: input.companyId, coordinator, issue, run: currentRun, + }) : null; + }; + let platformProof: WorkspaceWorkerStopProof | null = null; if (rawPrior != null) { const requireStopVerification = async () => { const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior }); + const nextAction = "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn."; const recovery = issueRecoveryActionService(input.db); const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null; // Rechecking the same physical owner is not a new recovery attempt. if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) { + if (!existing.evidence?.workspaceOwnerStop) { + await input.db.update(issueRecoveryActions).set({ nextAction, updatedAt: new Date(), + evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"operator_required"}'::jsonb)`, + }).where(and( + eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.id, existing.id), + eq(issueRecoveryActions.fingerprint, fingerprint), eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED), + inArray(issueRecoveryActions.status, ["active", "escalated"]), + sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`, + sql`(${issueRecoveryActions.nextAction} is distinct from ${nextAction} or ${issueRecoveryActions.evidence}#>>'{workspaceOwnerRecovery,state}' is distinct from 'operator_required')`, + )); + } return { acquired: false } as const; } if (run.issueId) await recovery.upsertSourceScoped({ @@ -87,18 +121,21 @@ export async function withNativeWorkspaceFinalizationOwnership( kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId, cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED, fingerprint, - evidence: { runId: input.runId, owner: rawPrior }, - nextAction: "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.", + evidence: { runId: input.runId, owner: rawPrior, workspaceOwnerRecovery: { state: "operator_required" } }, + nextAction, wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true, }); return { acquired: false } as const; }; - // A foreign host or malformed receipt cannot prove the old physical owner stopped. - if (!prior || prior.hostname !== owner.hostname) return requireStopVerification(); + // The harness checks the exact owned deployment, including orphaned children. + // A live deployment, unsigned answer or older receipt stays fenced. + if (prior?.worker) platformProof = await requestWorkspaceWorkerStopProof({ runId: input.runId, ownerToken: prior.token, worker: prior.worker }); + if (!prior) return requireStopVerification(); + if (!platformProof && prior.hostname !== owner.hostname) return requireStopVerification(); const knownJoinedHere = prior.controllerBootId === identity.bootId && prior.pid === identity.pid && joinedOwnerTokens.has(prior.token) && !activeOwnerTokens.has(prior.token); - if (!knownJoinedHere) { + if (!platformProof && !knownJoinedHere) { const takeover = await evaluateNativeControllerTakeover({ owner: { leaseOwner: prior.token, leaseExpiresAt: new Date(0), controllerPid: prior.pid, controllerProcessStartedAt: new Date(prior.processStartedAt) }, now: new Date(), @@ -119,15 +156,32 @@ export async function withNativeWorkspaceFinalizationOwnership( } // Claim before the potentially ambiguous write so finally can remove // only our token even if the server committed but its response was lost. - activeOwnerTokens.add(owner.token); - claimed = true; - const [claimedRun] = await input.db.update(heartbeatRuns).set({ - runnerProfileJson: sql`jsonb_set(coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb), array[${OWNER_KEY}], ${JSON.stringify(owner)}::jsonb)`, - }).where(and(scope, prior - ? sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${prior.token}` - : sql`${heartbeatRuns.runnerProfileJson}->>${OWNER_KEY} is null` - )).returning({ id: heartbeatRuns.id }); + const claimOwner = async (db: Db) => { + activeOwnerTokens.add(owner.token); + claimed = true; + const [claimedRun] = await db.update(heartbeatRuns).set({ + runnerProfileJson: sql`jsonb_set(coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb), array[${OWNER_KEY}], ${JSON.stringify(owner)}::jsonb) || ${JSON.stringify(platformProof ? { nativeWorkspaceOwnerStopProof: platformProof } : {})}::jsonb`, + }).where(and(scope, prior + ? sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY} = ${JSON.stringify(rawPrior)}::jsonb` + : sql`${heartbeatRuns.runnerProfileJson}->>${OWNER_KEY} is null` + )).returning({ id: heartbeatRuns.id }); + return claimedRun; + }; + // A stop receipt proves the old worker exited, not that its task is still + // current. Revalidate after the provider read and atomically with the CAS. + const claimedRun = platformProof ? await input.db.transaction(async tx => { + const result = await currentRecoveryResult(tx as unknown as Db); + if (!result) return null; + const claimedRun = await claimOwner(tx as unknown as Db); + if (claimedRun) automaticResultId = result.id; + return claimedRun; + }) : await claimOwner(input.db); if (!claimedRun) return { acquired: false } as const; + if (platformProof && run.issueId) await logActivity(input.db, { + companyId: input.companyId, actorType: "system", actorId: "workspace-recovery", + action: "issue.workspace_owner_stop_verified", entityType: "issue", entityId: run.issueId, + runId: input.runId, details: { proof: platformProof }, + }); if (prior) joinedOwnerTokens.delete(prior.token); const ownership: NativeWorkspaceFinalizationOwnership = { token: owner.token, @@ -137,12 +191,25 @@ export async function withNativeWorkspaceFinalizationOwnership( await lock.execute(sql`select 1`); const [current] = await input.db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(owned).limit(1); if (!current) throw new Error("owner_replaced"); + if (automaticResultId) { + const result = await input.db.transaction(tx => currentRecoveryResult(tx as unknown as Db)); + if (result?.id !== automaticResultId) throw new Error("task_replaced"); + } } catch (cause) { throw new NativeWorkspaceFinalizationOwnershipLostError({ cause }); } }, }; await ownership.assertHeld(); + if (platformProof && run.issueId) await input.db.update(issueRecoveryActions).set({ + evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"resuming"}'::jsonb)`, + updatedAt: new Date(), + }).where(and( + eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, run.issueId), + eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED), + eq(issueRecoveryActions.fingerprint, nativeSha256({ runId: input.runId, owner: rawPrior })), + inArray(issueRecoveryActions.status, ["active", "escalated"]), + )); actionPromise = action(ownership); const value = await actionPromise; await ownership.assertHeld(); diff --git a/server/src/services/native-runtime/native-workspace-finalizer.test.ts b/server/src/services/native-runtime/native-workspace-finalizer.test.ts index 766dd1cf539..30737efb30b 100644 --- a/server/src/services/native-runtime/native-workspace-finalizer.test.ts +++ b/server/src/services/native-runtime/native-workspace-finalizer.test.ts @@ -1,3 +1,8 @@ +import * as platformProof from "./native-workspace-platform-proof.js"; +import { currentNativeControllerIdentity } from "./native-restart-recovery.js"; +import { nativeSha256 } from "./canonical.js"; +import { resumeNativeWorkspaceAfterOwnerStop } from "./native-workspace-owner-recovery.js"; +import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readProcessStartedAt } from "../hot-restart.js"; @@ -9,6 +14,8 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { and, desc, eq, sql } from "drizzle-orm"; import { + activityLog, + agentWakeupRequests, agents, companies, completionContracts, @@ -19,11 +26,15 @@ import { nativeRunFinalizations, nativeRunResults, issueRecoveryActions, + issueThreadInteractions, projects, workspaceOperations, + workAssessments, + statusDecisions, } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js"; +import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js"; describe("native workspace finalization recovery", () => { let temporary: Awaited>; @@ -159,6 +170,225 @@ describe("native workspace finalization recovery", () => { } }); + async function seedAbandonedCopyback() { + const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Accepted yield, abandoned copyback" }); + const owner = { token: randomUUID(), hostname: "replaced-controller", pid: 2, + processStartedAt: "2026-10-10T00:02:55.560Z", controllerBootId: randomUUID() }; + await db.update(heartbeatRuns).set({ status: "running", nativePhase: "workspace_finalizing", + runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner, preserved: "workspace descriptor" }, + }).where(eq(heartbeatRuns.id, seeded.runId)); + await db.update(issues).set({ executionRunId: seeded.runId }).where(eq(issues.id, seeded.issueId)); + await db.update(nativeRunFinalizations).set({ phase: "workspace_finalizing" }).where(eq(nativeRunFinalizations.runId, seeded.runId)); + const recovery = issueRecoveryActionService(db); + const hold = { companyId, sourceIssueId: seeded.issueId, kind: "active_run_watchdog" as const, + ownerType: "board" as const, cause: "native_workspace_finalization_owner_unverified", + fingerprint: nativeSha256({ runId: seeded.runId, owner }), evidence: { runId: seeded.runId, owner }, maxAttempts: 1, + nextAction: "Verify the previous controller and its workspace-copyback processes have stopped. Resume workspace finalization without another provider turn." }; + const action = await recovery.upsertSourceScoped(hold); + await recovery.upsertSourceScoped(hold); + return { ...seeded, owner, actionId: action.id, companyId, db, ownerToken: owner.token, + actorId: "operator", controllerAndCopybackStopped: true as const, + stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." }; + } + + it("retains copyback-specific instructions after repeated recovery sweeps", async () => { + const seed = await seedAbandonedCopyback(); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)); + expect(action.status).toBe("escalated"); + expect(action.nextAction).toContain("workspace-copyback processes have stopped"); + expect(action.nextAction).not.toContain("replacement run"); + await db.update(issueRecoveryActions).set({ nextAction: "Start a replacement run" }).where(eq(issueRecoveryActions.id, seed.actionId)); + await withNativeWorkspaceFinalizationOwnership(seed, async () => "unexpected"); + const [repaired] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)); + expect(repaired.nextAction).toContain("workspace-copyback processes have stopped"); + expect(repaired.attemptCount).toBe(action.attemptCount); + expect(repaired.evidence?.recoveryBudget).toEqual(action.evidence?.recoveryBudget); + }); + + it("releases only the verified owner and resumes the saved result without changing its disposition", async () => { + const seed = await seedAbandonedCopyback(); + const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)); + expect(await withNativeWorkspaceFinalizationOwnership(seed, async () => "unexpected")).toEqual({ acquired: false }); + const receipt = await resumeNativeWorkspaceAfterOwnerStop(seed); + expect(receipt).toEqual({ runId: seed.runId, resultId: before.resultId, status: "queued" }); + const [after] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)); + expect(after).toEqual(before); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson).toEqual({ preserved: "workspace descriptor" }); + expect(run.status).toBe("running"); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)); + expect(action.evidence?.workspaceOwnerStop).toMatchObject({ owner: seed.owner, actorId: "operator", resultId: before.resultId, stopEvidence: seed.stopEvidence }); + expect(action.status).toBe("escalated"); // Only commitment resolves the hold. + expect(action.wakePolicy).toBeNull(); + expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(1); + const file = path.join(workspaceRoot, seed.runId); + expect(await withNativeWorkspaceFinalizationOwnership(seed, async () => { + await fs.writeFile(file, "accepted work"); + return "resumed"; + })).toEqual({ acquired: true, value: "resumed" }); + expect(await fs.readFile(file, "utf8")).toBe("accepted work"); + // A duplicate confirmation neither clears a newer owner nor writes another audit. + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toEqual(receipt); + expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(1); + }); + + it.each(["expired", "live", "unknown"] as const)("checks the coordinator lease before verified recovery: %s", async state => { + const seed = await seedAbandonedCopyback(); + const leaseExpiresAt = state === "unknown" ? null : new Date(Date.now() + (state === "expired" ? -60_000 : 60_000)); + await db.update(nativeRunFinalizations).set({ leaseOwner: "previous-reconciler", leaseExpiresAt }).where(eq(nativeRunFinalizations.runId, seed.runId)); + if (state === "expired") { + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toMatchObject({ status: "queued" }); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].leaseOwner).toBe("previous-reconciler"); + } else { + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toMatchObject({ status: 409 }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)))[0].runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(seed.owner); + } + }); + + it.each(["pending", "resolved", "superseded", "unassigned"] as const)("requires authoritative reviewer recovery: %s", async state => { + const seed = await seedAbandonedCopyback(); + const reviewerId = randomUUID(), sourceRunId = randomUUID(), sourceResultId = randomUUID(), assessmentId = randomUUID(); + const decisionId = randomUUID(), interactionId = randomUUID(); + await db.insert(agents).values({ id: reviewerId, companyId, name: "Review agent", adapterType: "paperclip_runner", status: "running" }); + const [actingRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, nativeIssueId: seed.issueId, runtimeMode: "native", status: "succeeded", completionContractId: actingRun.completionContractId, createdAt: new Date(actingRun.createdAt.getTime() - 60_000) }); + await db.insert(nativeRunResults).values({ id: sourceResultId, companyId, issueId: seed.issueId, runId: sourceRunId, completionContractId: actingRun.completionContractId!, serverFingerprint: sourceResultId, schemaStatus: "accepted", resultJson: {}, canonicalSha256: sourceResultId }); + await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId: seed.issueId, runId: sourceRunId, contractId: actingRun.completionContractId!, resultId: sourceResultId, triggerKind: "native_result", triggerActorCompanyId: companyId, priorIssueStatus: "in_progress", priorStatusVersion: 0, policyVersion: "fixture", assessmentJson: {}, inputDigest: assessmentId }); + const [issue] = await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, seed.issueId)).returning(); + await db.insert(statusDecisions).values({ id: decisionId, companyId, issueId: seed.issueId, runId: sourceRunId, assessmentId, decisionVersion: 1, policyVersion: "fixture", fromStatus: "in_progress", toStatus: "in_review", reasonCode: "explicit_review", decisionJson: { projectedStatusVersion: issue.statusVersion }, decisionDigest: decisionId, applicationState: "applied" }); + await db.update(issues).set({ lastStatusDecisionId: state === "superseded" ? null : decisionId }).where(eq(issues.id, seed.issueId)); + await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId: seed.issueId, kind: "request_confirmation", sourceRunId, addresseeAgentId: state === "unassigned" ? agentId : reviewerId, createdByAgentId: agentId, continuationPolicy: "wake_assignee", status: state === "resolved" ? "accepted" : "pending", ...(state === "resolved" ? { resolvedByRunId: seed.runId, resolvedByAgentId: reviewerId } : {}), payload: { version: 1, prompt: "Review the completed work.", target: { type: "custom", key: "native_completion_review", revisionId: decisionId } } }); + await db.update(heartbeatRuns).set({ agentId: reviewerId, contextSnapshot: { issueId: seed.issueId, nativeReviewInteractionId: interactionId, nativeReviewDecisionId: decisionId } }).where(eq(heartbeatRuns.id, seed.runId)); + if (state === "pending" || state === "resolved") { + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toMatchObject({ status: "queued" }); + expect((await db.select().from(issues).where(eq(issues.id, seed.issueId)))[0].assigneeAgentId).toBe(agentId); + } else { + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toMatchObject({ status: 409 }); + } + }); + + it.each(([ + ["workspace_finalizing", "monitor"], ["workspace_finalizing", "approval"], + ["retryable_failure", "monitor"], ["retryable_failure", "approval"], + ["result_accepted", "monitor"], ["result_accepted", "approval"], + ] as const).flatMap(([phase, wait]) => (["operator", "automatic"] as const).map(mode => [phase, wait, mode] as const)))("commits the saved yield from %s preserving its %s wait via %s recovery", async (phase, wait, mode) => { + const seed = await seedAbandonedCopyback(); + await db.update(nativeRunFinalizations).set({ phase, + ...(phase === "retryable_failure" ? { attempt: 1, nextAttemptAt: new Date(Date.now() - 60_000), + failureCode: "native_workspace_sync_out_failed" } : {}), + }).where(eq(nativeRunFinalizations.runId, seed.runId)); + await db.update(heartbeatRuns).set({ nativePhase: phase }).where(eq(heartbeatRuns.id, seed.runId)); + const workspaceId = randomUUID(); + const cwd = path.join(workspaceRoot, seed.runId); + await fs.mkdir(cwd); + await fs.writeFile(path.join(cwd, "saved.txt"), "accepted work"); + await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, + mode: "local", strategyType: "local_directory", name: "Retained workspace", cwd }); + const nextCheckAt = new Date(Date.now() - 60_000); // Already due, like the staging runs. + const policy = { monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "Check the existing PR." } }; + await db.update(issues).set({ executionWorkspaceId: workspaceId, + ...(wait === "monitor" ? { executionPolicy: policy, monitorNextCheckAt: nextCheckAt } : {}), + }).where(eq(issues.id, seed.issueId)); + const interactionId = randomUUID(); + if (wait === "approval") await db.insert(issueThreadInteractions).values({ + id: interactionId, companyId, issueId: seed.issueId, sourceRunId: seed.runId, + createdByAgentId: agentId, kind: "request_confirmation", status: "pending", + payload: { version: 1, prompt: "Approve the plan?", target: { type: "issue_document", + issueId: seed.issueId, key: "plan", revisionId: randomUUID() } }, + }); + const resultJson = { + result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", + summary: "Wait for the existing continuation.", + completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [], + remainingWork: [{ description: "Wait for the check or plan confirmation.", blocksCompletion: true }] }, + continuation: { kind: wait === "monitor" ? "monitor" : "response_wake", + summary: "Wait for the existing continuation.", idempotencyKey: "saved-wait" }, + evidence: [], verification: [], attentionRequests: [], artifacts: [] }, + terminal: { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", + runTerminalState: "succeeded", reportedWorkDisposition: "yielded" }, + }; + await db.update(nativeRunResults).set({ resultJson }).where(eq(nativeRunResults.runId, seed.runId)); + const [accepted] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, seed.runId)); + + // The real reconciler must remain fenced before operator confirmation. + await reconcileNativeFinalizations(db, [seed.runId]); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].phase).toBe(phase); + const [beforeConfirmation] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)); + if (mode === "operator") { + await resumeNativeWorkspaceAfterOwnerStop(seed); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0]).toEqual(beforeConfirmation); + await reconcileNativeFinalizations(db, [seed.runId]); + } else { + const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" }; + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson, nativeWorkspaceFinalizationOwner: { ...seed.owner, worker } } }).where(eq(heartbeatRuns.id, seed.runId)); + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: seed.owner.token, worker, stopped: true, iat: 1, exp: 61 }); + try { await reconcileNativeFinalizations(db, [seed.runId]); } finally { probe.mockRestore(); } + } + + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0]).toMatchObject({ phase: "committed", resultId: accepted.id }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)))[0]).toMatchObject({ status: "succeeded", nativePhase: "committed" }); + expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, seed.runId))).toEqual([accepted]); + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)))[0].status).toBe("resolved"); + const [issue] = await db.select().from(issues).where(eq(issues.id, seed.issueId)); + expect(issue.status).toBe(wait === "monitor" ? "in_progress" : "in_review"); + if (wait === "monitor") { + expect(issue.monitorNextCheckAt).toEqual(nextCheckAt); + expect(issue.executionPolicy).toEqual(policy); + } else { + expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("pending"); + } + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.nativeIssueId, seed.issueId))).toHaveLength(1); + expect(await db.select().from(agentWakeupRequests).where(sql`${agentWakeupRequests.payload}->>'issueId' = ${seed.issueId}`)).toHaveLength(0); + expect(await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.heartbeatRunId, seed.runId), + eq(workspaceOperations.phase, "workspace_finalize"), eq(workspaceOperations.status, "succeeded")))).toHaveLength(1); + expect(await fs.readFile(path.join(cwd, "saved.txt"), "utf8")).toBe("accepted work"); + }); + + it.each(["token", "company", "result", "newer owner", "missing confirmation", "malformed owner"])("rejects stale or unverified copyback recovery: %s", async changed => { + const seed = await seedAbandonedCopyback(); + if (changed === "malformed owner") { + const owner = { token: seed.ownerToken }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + await db.update(issueRecoveryActions).set({ evidence: { runId: seed.runId, owner } }).where(eq(issueRecoveryActions.id, seed.actionId)); + } + if (changed === "token") seed.ownerToken = randomUUID(); + if (changed === "company") seed.companyId = foreignCompanyId; + if (changed === "result") await db.update(nativeRunFinalizations).set({ resultId: null }).where(eq(nativeRunFinalizations.runId, seed.runId)); + if (changed === "newer owner") await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: { ...seed.owner, token: randomUUID() } } }).where(eq(heartbeatRuns.id, seed.runId)); + if (changed === "missing confirmation") Object.assign(seed, { controllerAndCopybackStopped: false }); + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow(); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toBeDefined(); + expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(0); + }); + + it("rejects a locally live owner even after its advisory connection is gone", async () => { + const seed = await seedAbandonedCopyback(); + const identity = await currentNativeControllerIdentity(); + const owner = { ...seed.owner, hostname: os.hostname(), pid: identity.pid, + processStartedAt: identity.processStartedAt.toISOString(), controllerBootId: identity.bootId }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + await db.update(issueRecoveryActions).set({ evidence: { runId: seed.runId, owner } }).where(eq(issueRecoveryActions.id, seed.actionId)); + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("still active"); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(owner); + }); + + it("does not reopen an old result after a newer task execution exists", async () => { + const seed = await seedAbandonedCopyback(); + await db.insert(heartbeatRuns).values({ companyId, agentId, status: "queued", nativeIssueId: seed.issueId }); + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("no longer current"); + }); + + it("rejects stop confirmation while another controller holds the physical lock", async () => { + const seed = await seedAbandonedCopyback(); + await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`native-workspace-finalization:${companyId}:${seed.runId}`}, 0))`); + await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("still active"); + }); + }); + it("reuses a successful export even if an old controller wrote a later failed barrier", async () => { const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Late failed barrier" }); const [success] = await db.insert(workspaceOperations).values({ companyId, heartbeatRunId: seeded.runId, @@ -311,6 +541,137 @@ describe("native workspace finalization recovery", () => { .toEqual({ acquired: true, value: "recovered" }); }); + it("automatically adopts a proven stopped deployment without another agent turn", async () => { + const seed = await seedAbandonedCopyback(); + const worker = { provider: "railway" as const, projectId: "fixture-project", environmentId: "fixture-env", serviceId: "fixture-service", deploymentId: "fixture-old" }; + const owner = { ...seed.owner, worker }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner, preserved: "workspace descriptor" } }).where(eq(heartbeatRuns.id, seed.runId)); + await db.update(issueRecoveryActions).set({ fingerprint: nativeSha256({ runId: seed.runId, owner }), evidence: { runId: seed.runId, owner, preserved: "recovery evidence" } }).where(eq(issueRecoveryActions.id, seed.actionId)); + const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)); + const proof = { v: 1 as const, iss: "paperclip-cloud" as const, aud: "paperclip-workspace-worker-stop/v1" as const, sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true as const, iat: 1, exp: 61 }; + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue(proof); + try { + const work = vi.fn(async () => { + const [recovery] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)); + expect(recovery.evidence?.workspaceOwnerRecovery).toEqual({ state: "resuming" }); + expect(recovery.evidence?.preserved).toBe("recovery evidence"); + return "saved workspace copied"; + }); + expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: true, value: "saved workspace copied" }); + expect(probe).toHaveBeenCalledWith({ runId: seed.runId, ownerToken: owner.token, worker }); + expect(work).toHaveBeenCalledOnce(); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toBeUndefined(); + expect(run.runnerProfileJson?.nativeWorkspaceOwnerStopProof).toEqual(proof); + expect(run.runnerProfileJson?.preserved).toBe("workspace descriptor"); + const [after] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)); + expect(after).toEqual(before); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).toHaveLength(0); + } finally { probe.mockRestore(); } + }); + + it("retains the fence for unknown platform evidence and owner changes during verification", async () => { + const seed = await seedAbandonedCopyback(); + const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" }; + const owner = { ...seed.owner, worker }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue(null); + const work = vi.fn(); + try { + expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: false }); + const [recovery] = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, seed.issueId), eq(issueRecoveryActions.fingerprint, nativeSha256({ runId: seed.runId, owner })))); + expect(recovery.evidence?.workspaceOwnerRecovery).toEqual({ state: "operator_required" }); + const replacement = { ...owner, token: randomUUID() }; + probe.mockImplementation(async () => { + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: replacement } }).where(eq(heartbeatRuns.id, seed.runId)); + return { v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 }; + }); + expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: false }); + expect(work).not.toHaveBeenCalled(); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(replacement); + } finally { probe.mockRestore(); } + }); + + it.each(["done", "cancelled", "execution replaced", "checkout replaced", "newer run", "queued legacy run", "reassigned", "result unbound", "result rejected", "run stopped"])("does not copy saved files after the task moves on during platform verification: %s", async changed => { + const seed = await seedAbandonedCopyback(); + const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" }; + const owner = { ...seed.owner, worker }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + const file = path.join(workspaceRoot, `${seed.runId}.txt`); + await fs.writeFile(file, "new task work"); + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockImplementation(async () => { + if (changed === "done" || changed === "cancelled") await db.update(issues).set({ status: changed }).where(eq(issues.id, seed.issueId)); + if (changed === "execution replaced") await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seed.issueId)); + if (changed === "checkout replaced") { + const [other] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded" }).returning(); + await db.update(issues).set({ checkoutRunId: other.id }).where(eq(issues.id, seed.issueId)); + } + if (changed === "reassigned") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, seed.issueId)); + if (changed === "result unbound") await db.update(nativeRunFinalizations).set({ resultId: null }).where(eq(nativeRunFinalizations.runId, seed.runId)); + if (changed === "result rejected") await db.update(nativeRunResults).set({ schemaStatus: "rejected" }).where(eq(nativeRunResults.runId, seed.runId)); + if (changed === "run stopped") await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, seed.runId)); + if (changed === "newer run" || changed === "queued legacy run") { + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + await db.insert(heartbeatRuns).values({ companyId, agentId, + runtimeMode: changed === "newer run" ? "native" : "legacy", + nativeIssueId: changed === "newer run" ? seed.issueId : null, + contextSnapshot: { issueId: seed.issueId }, status: changed === "newer run" ? "succeeded" : "queued", + createdAt: new Date(run.createdAt.getTime() + (changed === "newer run" ? 60_000 : -60_000)), + }); + } + return { v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 }; + }); + const copyback = vi.fn(async () => fs.writeFile(file, "old sandbox work")); + try { + expect(await withNativeWorkspaceFinalizationOwnership(seed, copyback)).toEqual({ acquired: false }); + expect(copyback).not.toHaveBeenCalled(); + expect(await fs.readFile(file, "utf8")).toBe("new task work"); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)); + expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(owner); + expect(run.runnerProfileJson?.nativeWorkspaceOwnerStopProof).toBeUndefined(); + } finally { probe.mockRestore(); } + }); + + it("rechecks automatic task ownership at the copyback publication boundary", async () => { + const seed = await seedAbandonedCopyback(); + const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" }; + const owner = { ...seed.owner, worker }; + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 }); + const write = vi.fn(); + try { + await expect(withNativeWorkspaceFinalizationOwnership(seed, async ownership => { + await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, seed.issueId)); + await ownership.assertHeld(); + write(); + })).rejects.toThrow("native_workspace_finalization_ownership_lost"); + expect(write).not.toHaveBeenCalled(); + } finally { probe.mockRestore(); } + }); + + it.each(["closed", "superseded"])("does not start a workspace operation when the real reconciler selects a %s task", async changed => { + const seed = await seedAbandonedCopyback(); + const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" }; + const owner = { ...seed.owner, worker }; + const workspaceId = randomUUID(), cwd = path.join(workspaceRoot, seed.runId); + await fs.mkdir(cwd); + await fs.writeFile(path.join(cwd, "current.txt"), "new task work"); + await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, mode: "local", strategyType: "local_directory", name: "Current workspace", cwd }); + await db.update(issues).set({ executionWorkspaceId: workspaceId, + ...(changed === "closed" ? { status: "done" } : { executionRunId: null }), + }).where(eq(issues.id, seed.issueId)); + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId)); + const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 }); + try { + await reconcileNativeFinalizations(db, [seed.runId]); + expect(probe).toHaveBeenCalledOnce(); + expect(await db.select().from(workspaceOperations).where(eq(workspaceOperations.heartbeatRunId, seed.runId))).toHaveLength(0); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].phase).toBe("workspace_finalizing"); + expect(await fs.readFile(path.join(cwd, "current.txt"), "utf8")).toBe("new task work"); + } finally { probe.mockRestore(); } + }); + it("surfaces an unverifiable foreign controller as board recovery without physical writes", async () => { const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Foreign copyback controller" }); const owner = { token: randomUUID(), hostname: "other-controller.invalid", pid: 42, processStartedAt: new Date().toISOString() }; diff --git a/server/src/services/native-runtime/native-workspace-owner-recovery.ts b/server/src/services/native-runtime/native-workspace-owner-recovery.ts new file mode 100644 index 00000000000..4b3e19e3311 --- /dev/null +++ b/server/src/services/native-runtime/native-workspace-owner-recovery.ts @@ -0,0 +1,87 @@ +import os from "node:os"; +import { and, eq, sql } from "drizzle-orm"; +import { heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, type Db } from "@paperclipai/db"; +import { resumeWorkspaceFinalizationSchema } from "@paperclipai/shared"; +import { conflict } from "../../errors.js"; +import { persistActivity, publishActivity } from "../activity-log.js"; +import { currentNativeWorkspaceRecoveryResult } from "./native-workspace-recovery-binding.js"; +import { evaluateNativeControllerTakeover } from "./native-restart-recovery.js"; + +const OWNER_KEY = "nativeWorkspaceFinalizationOwner"; +const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const changed = () => conflict("The recorded workspace owner is no longer current. Refresh the task and inspect its run.", { code: "workspace_owner_recovery_stale" }); + +/** Board-only admission after deployment-platform verification. A missing DB + * lock or an expired lease alone never proves physical copyback has stopped. */ +export async function resumeNativeWorkspaceAfterOwnerStop(input: { + db: Db; companyId: string; issueId: string; actionId: string; runId: string; + ownerToken: string; controllerAndCopybackStopped: true; stopEvidence: string; actorId: string; +}) { + const confirmation = resumeWorkspaceFinalizationSchema.parse({ + actionId: input.actionId, runId: input.runId, ownerToken: input.ownerToken, + controllerAndCopybackStopped: input.controllerAndCopybackStopped, stopEvidence: input.stopEvidence, + }); + const admitted = await input.db.transaction(async tx => { + // Share the physical copyback lock. Do not wait behind an active exporter + // and then apply a confirmation that described an earlier owner. + const locks = await tx.execute(sql`select pg_try_advisory_xact_lock(hashtextextended(${`native-workspace-finalization:${input.companyId}:${input.runId}`}, 0)) as acquired`); + if (locks[0]?.acquired !== true) throw conflict("Workspace copyback is still active. Wait for it to stop before confirming recovery."); + // Match the status arbiter's coordinator -> issue -> run lock order. + const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), eq(nativeRunFinalizations.runId, input.runId))).for("update"); + const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("update"); + const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.id, input.runId))).for("update"); + const [action] = await tx.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, input.issueId), eq(issueRecoveryActions.id, input.actionId))).for("update"); + if (!issue || !run || !coordinator || !action || action.evidence?.runId !== run.id + || action.cause !== "native_workspace_finalization_owner_unverified" || action.ownerType !== "board" + || action.kind !== "active_run_watchdog" || !["active", "escalated"].includes(action.status) + || (coordinator.leaseOwner && (!coordinator.leaseExpiresAt || coordinator.leaseExpiresAt.getTime() > Date.now()))) throw changed(); + const result = await currentNativeWorkspaceRecoveryResult(tx as unknown as Db, { + companyId: input.companyId, issue, run, coordinator, + }); + if (!result) throw changed(); + const prior = record(run.runnerProfileJson?.[OWNER_KEY]); + const recordedOwner = record(action.evidence?.owner); + const stop = record(action.evidence?.workspaceOwnerStop); + const receipt = { runId: run.id, resultId: result.id, status: "queued" as const }; + if (run.runnerProfileJson?.[OWNER_KEY] == null && record(stop.owner).token === input.ownerToken + && stop.resultId === result.id) return { receipt, publication: null }; + if (typeof prior.hostname !== "string" || !prior.hostname + || !Number.isInteger(prior.pid) || Number(prior.pid) <= 0 + || typeof prior.processStartedAt !== "string" || !Number.isFinite(Date.parse(prior.processStartedAt)) + || prior.token !== input.ownerToken || recordedOwner.token !== input.ownerToken + || prior.hostname !== recordedOwner.hostname || prior.pid !== recordedOwner.pid + || prior.processStartedAt !== recordedOwner.processStartedAt + || prior.controllerBootId !== recordedOwner.controllerBootId) throw changed(); + // Reject a confirmation contradicted by a locally observable live owner, + // even if that owner's advisory connection has already disconnected. + if (prior.hostname === os.hostname()) { + const takeover = await evaluateNativeControllerTakeover({ owner: { + leaseOwner: input.ownerToken, leaseExpiresAt: new Date(0), + controllerPid: typeof prior.pid === "number" ? prior.pid : null, + controllerProcessStartedAt: typeof prior.processStartedAt === "string" ? new Date(prior.processStartedAt) : null, + }, now: new Date() }); + if (!takeover.allowed) throw conflict("The previous workspace controller is still active or its stop cannot be verified."); + } + const now = new Date(); + const proof = { owner: prior, resultId: result.id, actorId: input.actorId, + stopEvidence: confirmation.stopEvidence, confirmedAt: now.toISOString() }; + await tx.update(heartbeatRuns).set({ + runnerProfileJson: sql`${heartbeatRuns.runnerProfileJson} - ${OWNER_KEY}`, updatedAt: now, + }).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, run.id), + sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${input.ownerToken}`)); + await tx.update(issueRecoveryActions).set({ evidence: { ...action.evidence, workspaceOwnerStop: proof }, + nextAction: "Workspace finalization is queued for the saved result. The agent will not repeat its work.", + wakePolicy: null, monitorPolicy: null, updatedAt: now, + }).where(eq(issueRecoveryActions.id, action.id)); + const publication = await persistActivity(tx as unknown as Db, { companyId: input.companyId, + actorType: "user", actorId: input.actorId, action: "issue.workspace_owner_stop_confirmed", + entityType: "issue", entityId: input.issueId, runId: input.runId, + details: { recoveryActionId: input.actionId, ...proof }, + }); + // Leave the result, coordinator, source lease, task status and wait paths + // untouched. The normal reconciler resumes copyback and arbitrates it. + return { receipt, publication }; + }); + if (admitted.publication) publishActivity(admitted.publication.publication); + return admitted.receipt; +} diff --git a/server/src/services/native-runtime/native-workspace-platform-proof.test.ts b/server/src/services/native-runtime/native-workspace-platform-proof.test.ts new file mode 100644 index 00000000000..fb3297ca80b --- /dev/null +++ b/server/src/services/native-runtime/native-workspace-platform-proof.test.ts @@ -0,0 +1,34 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { verifyWorkspaceWorkerStopReceipt, requestWorkspaceWorkerStopProof, currentWorkspaceWorker } from "./native-workspace-platform-proof.js"; +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +const env = { PAPERCLIP_CLOUD_STACK_ID: "stack", PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example", PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "fixture-token", PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [{ ...publicKey.export({ format: "jwk" }), kid: "test", alg: "EdDSA", use: "sig" }] }) }; +const worker = { provider: "railway" as const, projectId: "project", environmentId: "environment", serviceId: "service", deploymentId: "old-deployment" }; +const binding = { runId: "run", ownerToken: "owner", worker }; +const now = new Date("2099-01-01T00:00:00Z"); +function receipt(overrides = {}) { + const header = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: "test", typ: "paperclip-workspace-worker-stop+jwt" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", ...binding, stopped: true, iat: now.getTime()/1000, exp: now.getTime()/1000+60, ...overrides })).toString("base64url"); + return `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`), privateKey).toString("base64url")}`; +} +describe("workspace platform stop proof", () => { + it("accepts only signed proof for the exact stack, owner, run and full stopped deployment", () => { + expect(verifyWorkspaceWorkerStopReceipt(receipt(), binding, env, now)).toMatchObject({ stopped: true, worker }); + for (const invalid of [{ sub: "other-stack" }, { runId: "other-run" }, { ownerToken: "other-owner" }, { stopped: false }, { exp: now.getTime()/1000-1 }, { worker: { ...worker, deploymentId: "other" } }, { aud: "paperclip-cloud-control/v1" }]) { + expect(verifyWorkspaceWorkerStopReceipt(receipt(invalid), binding, env, now)).toBeNull(); + } + expect(verifyWorkspaceWorkerStopReceipt(receipt().slice(0,-5)+"wrong", binding, env, now)).toBeNull(); + }); + it("fails closed on live, unknown, unreachable and unsigned platform responses", async () => { + for (const response of [{ stopped: false }, { stopped: true }, { receipt: "forged" }]) { + const fetchImpl = vi.fn().mockResolvedValue(Response.json(response)); + expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl })).toBeNull(); + } + expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl: vi.fn().mockRejectedValue(new Error("offline")) })).toBeNull(); + expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl: vi.fn().mockResolvedValue(Response.json({ receipt: receipt() })) })).toMatchObject({ stopped: true }); + }); + it("records a complete platform identity, and does not guess legacy ownership", () => { + expect(currentWorkspaceWorker({ RAILWAY_PROJECT_ID: "project", RAILWAY_ENVIRONMENT_ID: "environment", RAILWAY_SERVICE_ID: "service", RAILWAY_DEPLOYMENT_ID: "old-deployment" })).toEqual(worker); + expect(currentWorkspaceWorker({ RAILWAY_DEPLOYMENT_ID: "old-deployment" })).toBeUndefined(); + }); +}); diff --git a/server/src/services/native-runtime/native-workspace-platform-proof.ts b/server/src/services/native-runtime/native-workspace-platform-proof.ts new file mode 100644 index 00000000000..04b452d1d76 --- /dev/null +++ b/server/src/services/native-runtime/native-workspace-platform-proof.ts @@ -0,0 +1,65 @@ +import { verify } from "node:crypto"; +import { cloudRuntimePublicKeyForKid } from "../cloud-runtime-identity.js"; +import { getCloudStackContext } from "../cloud-instance.js"; + +/** Deployment identity covers the controller and every orphaned tar/Git child. */ +export type WorkspaceWorker = { + provider: "railway"; projectId: string; environmentId: string; serviceId: string; deploymentId: string; +}; +export type WorkspaceStopBinding = { runId: string; ownerToken: string; worker: WorkspaceWorker }; +export type WorkspaceWorkerStopProof = WorkspaceStopBinding & { + v: 1; iss: "paperclip-cloud"; aud: "paperclip-workspace-worker-stop/v1"; sub: string; + stopped: true; iat: number; exp: number; +}; +function validWorker(worker: unknown): worker is WorkspaceWorker { + if (!worker || typeof worker !== "object" || Array.isArray(worker)) return false; + const value = worker as Record; + return value.provider === "railway" && ["projectId", "environmentId", "serviceId", "deploymentId"].every(key => + typeof value[key] === "string" && value[key].length > 0 && value[key].length <= 200); +} +export function currentWorkspaceWorker(env: NodeJS.ProcessEnv = process.env): WorkspaceWorker | undefined { + const ids = [env.RAILWAY_PROJECT_ID, env.RAILWAY_ENVIRONMENT_ID, env.RAILWAY_SERVICE_ID, env.RAILWAY_DEPLOYMENT_ID]; + if (ids.some(id => !id?.trim())) return undefined; + return { provider: "railway", projectId: ids[0]!, environmentId: ids[1]!, serviceId: ids[2]!, deploymentId: ids[3]! }; +} +export function verifyWorkspaceWorkerStopReceipt( + receipt: string, binding: WorkspaceStopBinding, env: NodeJS.ProcessEnv = process.env, now = new Date(), +): WorkspaceWorkerStopProof | null { + try { + if (receipt.length > 16_000 || !validWorker(binding.worker)) return null; + const parts = receipt.split("."); + if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part))) return null; + const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")); + if (header.alg !== "EdDSA" || header.typ !== "paperclip-workspace-worker-stop+jwt" || typeof header.kid !== "string") return null; + if (!verify(null, Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), cloudRuntimePublicKeyForKid(env, header.kid), Buffer.from(parts[2], "base64url"))) return null; + const proof = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as WorkspaceWorkerStopProof; + const seconds = Math.floor(now.getTime()/1000); + if (proof.v !== 1 || proof.iss !== "paperclip-cloud" || proof.aud !== "paperclip-workspace-worker-stop/v1" + || !env.PAPERCLIP_CLOUD_STACK_ID || proof.sub !== env.PAPERCLIP_CLOUD_STACK_ID || proof.stopped !== true + || proof.runId !== binding.runId || proof.ownerToken !== binding.ownerToken + || !Number.isInteger(proof.iat) || !Number.isInteger(proof.exp) || proof.iat > seconds+30 + || proof.exp <= seconds || proof.exp <= proof.iat || proof.exp-proof.iat > 60 + || !validWorker(proof.worker) || Object.keys(binding.worker).some(key => proof.worker[key as keyof WorkspaceWorker] !== binding.worker[key as keyof WorkspaceWorker])) return null; + return proof; + } catch { return null; } +} +/** Unsupported platforms, legacy receipts and unavailable evidence never authorize takeover. */ +export async function requestWorkspaceWorkerStopProof(binding: WorkspaceStopBinding, options: { + env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; now?: Date; +} = {}): Promise { + const env = options.env ?? process.env; + const context = getCloudStackContext(env); + const token = env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); + if (!context?.stackId || !context.cloudOrigin || !token || !validWorker(binding.worker)) return null; + try { + const origin = new URL(context.cloudOrigin); + if (origin.protocol !== "https:" || origin.username || origin.password) return null; + const response = await (options.fetchImpl ?? fetch)(`${origin.origin}/v1/tenant/workspace-worker-stop`, { + method: "POST", redirect: "error", headers: { authorization: `Bearer ${token}`, "x-paperclip-cloud-stack-id": context.stackId, "content-type": "application/json" }, + body: JSON.stringify(binding), signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) return null; + const payload = await response.json() as { receipt?: unknown }; + return typeof payload.receipt === "string" ? verifyWorkspaceWorkerStopReceipt(payload.receipt, binding, env, options.now) : null; + } catch { return null; } +} diff --git a/server/src/services/native-runtime/native-workspace-recovery-binding.ts b/server/src/services/native-runtime/native-workspace-recovery-binding.ts new file mode 100644 index 00000000000..20c4ee6861f --- /dev/null +++ b/server/src/services/native-runtime/native-workspace-recovery-binding.ts @@ -0,0 +1,36 @@ +import { and, eq, gt, inArray, ne, or, sql } from "drizzle-orm"; +import { heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db"; +import { getNativeReviewAssignment } from "./native-review-participant.js"; + +/** Both operator and platform recovery must still own the task's accepted work. + * Call with coordinator -> issue -> run rows locked in a short transaction. */ +export async function currentNativeWorkspaceRecoveryResult(db: Db, input: { + companyId: string; + issue: typeof issues.$inferSelect; + run: typeof heartbeatRuns.$inferSelect; + coordinator: typeof nativeRunFinalizations.$inferSelect; +}) { + const { companyId, issue, run, coordinator } = input; + if (issue.companyId !== companyId || run.companyId !== companyId || coordinator.companyId !== companyId + || run.nativeIssueId !== issue.id || coordinator.issueId !== issue.id || coordinator.runId !== run.id + || run.runtimeMode !== "native" || run.status !== "running" + || !["result_accepted", "workspace_finalizing", "retryable_failure"].includes(coordinator.phase) + || !coordinator.resultId || ["done", "cancelled"].includes(issue.status) + || issue.executionRunId !== run.id || (issue.checkoutRunId && issue.checkoutRunId !== run.id)) return null; + if (issue.assigneeAgentId !== run.agentId && !await getNativeReviewAssignment(db, { + companyId, issueId: issue.id, agentId: run.agentId, + contextSnapshot: run.contextSnapshot, actingRunId: run.id, issueExecutionRunId: issue.executionRunId, + allowResolvedByRunId: run.id, + })) return null; + const [result] = await db.select().from(nativeRunResults).where(and( + eq(nativeRunResults.id, coordinator.resultId), eq(nativeRunResults.companyId, companyId), + eq(nativeRunResults.issueId, issue.id), eq(nativeRunResults.runId, run.id), eq(nativeRunResults.schemaStatus, "accepted"), + )); + if (!result || result.completionContractId !== run.completionContractId) return null; + const [newer] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, companyId), ne(heartbeatRuns.id, run.id), + or(eq(heartbeatRuns.nativeIssueId, issue.id), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`), + or(gt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, ["queued", "running"])), + )).limit(1); + return newer ? null : result; +} diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 314064ea27a..f3a56c5ef63 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -283,6 +283,8 @@ export const issuesApi = { `/issues/${id}/stalled-review-decision`, data, ), + resumeWorkspaceFinalization: (id: string, data: { actionId: string; runId: string; ownerToken: string; controllerAndCopybackStopped: true; stopEvidence: string }) => + api.post<{ runId: string; resultId: string; status: "queued" }>(`/issues/${id}/recovery-actions/resume-workspace-finalization`, data), retryWorkspaceExport: (id: string, data: { actionId: string; runId: string; repairNote: string }) => api.post<{ runId: string; resultId: string; leaseId: string; status: "queued" }>(`/issues/${id}/recovery-actions/retry-workspace-export`, data), resolveRecoveryAction: ( diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 88e6cc7b133..cd513226984 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -1161,3 +1161,10 @@ it("requires export retry for ordinary restoration while keeping explicit board expect(document.body.textContent).not.toContain("Send for review"); expect(document.body.textContent).toContain("False positive, done"); }); + +it("requires owner-stop recovery even for board false-positive dispositions", () => { + const node = render( {}} canFalsePositive />); + expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).toBeNull(); + expect(node.textContent).not.toContain("False positive"); +}); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index beb84076e1b..e61adbbb966 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -1067,6 +1067,7 @@ export function IssueRecoveryActionCard({ } satisfies Record)[cardState]; const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => { + if (action.cause === "native_workspace_finalization_owner_unverified") return false; if (isNativeWorkspaceExportRepairCause(action.cause) && ["todo", "done", "in_review"].includes(option.outcome)) return false; if (option.outcome === "todo" && requiresExecutionReconciliation(action.cause)) return false; if (option.boardOnly && !canFalsePositive) return false; diff --git a/ui/src/components/WorkspaceExportRecovery.test.tsx b/ui/src/components/WorkspaceExportRecovery.test.tsx index 10f4c243eac..28a2409d2f1 100644 --- a/ui/src/components/WorkspaceExportRecovery.test.tsx +++ b/ui/src/components/WorkspaceExportRecovery.test.tsx @@ -1,72 +1,44 @@ // @vitest-environment jsdom import { act } 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 { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import { WorkspaceExportRecovery } from "./WorkspaceExportRecovery"; -const retry = vi.hoisted(() => vi.fn()); -vi.mock("../api/issues", () => ({ issuesApi: { retryWorkspaceExport: retry } })); -const action = { id: "action", cause: "native_workspace_sync_out_retry_exhausted", status: "active", updatedAt: "2026-01-01T00:00:00Z", ownerType: "board", evidence: { runId: "run" }, wakePolicy: null } as unknown as IssueRecoveryAction; +const action = { cause: "native_workspace_finalization_owner_unverified", status: "escalated", ownerType: "board", evidence: { runId: "run", owner: { token: "owner", hostname: "secret-host", pid: 2 } }, wakePolicy: null } as unknown as IssueRecoveryAction; describe("WorkspaceExportRecovery", () => { - let root: Root, container: HTMLDivElement, client: QueryClient; - const onQueued = vi.fn(); - beforeEach(() => { - retry.mockReset().mockResolvedValue({ status: "queued" }); onQueued.mockReset(); - container = document.createElement("div"); document.body.append(container); root = createRoot(container); - client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); - }); - afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); - async function mount(overrides: Partial[0]> = {}) { - await act(async () => root.render()); + let root: Root, container: HTMLDivElement; + beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); + afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + async function mount(value: IssueRecoveryAction | null) { + await act(async () => root.render()); + expect(container.querySelector("textarea, button, details, input")).toBeNull(); + expect(container.textContent).not.toContain("secret-host"); } - async function enterNote() { - const textarea = container.querySelector("textarea")!; - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "Restored provider connectivity while preserving all saved files."); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - } - async function submit() { - await act(async () => container.querySelector("button")!.click()); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); - } - it("requires a repair note and submits only the exact recorded run", async () => { - await mount(); expect(container.querySelector("button")!.disabled).toBe(true); - await enterNote(); await submit(); - expect(retry).toHaveBeenCalledWith("issue", { actionId: "action", runId: "run", repairNote: "Restored provider connectivity while preserving all saved files." }); - expect(onQueued).toHaveBeenCalledOnce(); - expect(container.querySelector('[role="status"]')?.textContent).toContain("agent will not repeat its work"); + it("shows a quiet status while the harness restores work", async () => { + await mount({ ...action, evidence: { ...action.evidence, workspaceOwnerRecovery: { state: "resuming" } } }); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Restoring saved work…"); }); - it("offers repair again when a queued export publishes a new permanent failure", async () => { - await mount(); await enterNote(); await submit(); - expect(container.querySelector("button")).toBeNull(); - await mount({ action: { ...action, updatedAt: "2026-01-01T00:01:00Z", wakePolicy: null } }); - expect(container.querySelector("button")?.textContent).toBe("Retry workspace export"); + it("does not mistake platform identity for confirmed recovery", async () => { + await mount({ ...action, evidence: { ...action.evidence, owner: { ...(action.evidence.owner as object), worker: { provider: "railway", deploymentId: "old" } } } }); + expect(container.textContent).toContain("operator assistance"); }); - it("keeps unavailable-sandbox errors actionable inline", async () => { - retry.mockRejectedValueOnce(new Error("Resume the retained sandbox before retrying.")); await mount(); - await enterNote(); await submit(); - expect(container.querySelector('[role="alert"]')?.textContent).toContain("Resume the retained sandbox"); + it("states that an operator is needed for legacy receipts", async () => { + await mount(action); + expect(container.textContent).toContain("Workspace recovery needs operator assistance"); }); - it("does not offer the action without runtime access", async () => { - await mount({ canManage: false }); expect(container.querySelector("button")).toBeNull(); + it("keeps export repair out of the task", async () => { + const cause = "native_workspace_sync_out_retry_exhausted"; + await mount({ ...action, cause }); + expect(container.textContent).toContain("operator assistance"); }); - it("offers saved-result export after transient retries are exhausted without claiming an unsafe link", async () => { - await mount({ action: { ...action, cause: "native_workspace_sync_out_retry_exhausted" } }); - expect(container.querySelector("button")?.textContent).toBe("Retry workspace export"); - expect(container.textContent).toContain("export failure"); - expect(container.textContent).not.toContain("unsafe link"); - await enterNote(); await submit(); - expect(retry).toHaveBeenCalledWith("issue", expect.objectContaining({ runId: "run" })); + it("removes the status when recovery resolves, including updates in place", async () => { + await mount({ ...action, evidence: { ...action.evidence, workspaceOwnerStop: {} } }); + expect(container.textContent).toBe("Restoring saved work…"); + await mount({ ...action, status: "resolved" }); expect(container.textContent).toBe(""); + await mount(null); expect(container.textContent).toBe(""); }); - it("never asks users to repair historical unsafe exports", async () => { - await mount({ action: { ...action, cause: "native_workspace_sync_out_unsafe_archive" } }); - expect(container.textContent).toBe(""); - expect(retry).not.toHaveBeenCalled(); - }); - it("does not offer a second retry while the same export is queued", async () => { - await mount({ action: { ...action, wakePolicy: { kind: "resume_native_run" } } }); - expect(container.querySelector('[role="status"]')?.textContent).toContain("Export is queued"); expect(container.querySelector("button")).toBeNull(); + it("does not show unrelated or historical unsafe-export notices", async () => { + await mount({ ...action, cause: "no_live_path", evidence: {} }); expect(container.textContent).toBe(""); + await mount({ ...action, cause: "native_workspace_sync_out_unsafe_archive" }); expect(container.textContent).toBe(""); }); }); diff --git a/ui/src/components/WorkspaceExportRecovery.tsx b/ui/src/components/WorkspaceExportRecovery.tsx index 98376816817..a5b3dcb45e8 100644 --- a/ui/src/components/WorkspaceExportRecovery.tsx +++ b/ui/src/components/WorkspaceExportRecovery.tsx @@ -1,45 +1,17 @@ -import { useId, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; -import { issuesApi } from "../api/issues"; -import { cn } from "../lib/utils"; -import { Button } from "./ui/button"; -import { Label } from "./ui/label"; -import { Textarea } from "./ui/textarea"; +import { asObject, cn } from "../lib/utils"; -/** Copyback repair preserves the accepted result and never wakes the provider. */ -export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued, className }: { - className?: string; - issueId: string; action: IssueRecoveryAction | null; canManage: boolean; onQueued: () => void; +/** Infrastructure repair belongs to the harness/operator, never a task form. */ +export function WorkspaceExportRecovery({ action, className }: { + className?: string; action: IssueRecoveryAction | null; }) { - const noteId = useId(); - const [repairNote, setRepairNote] = useState(""); - const [queuedActionVersion, setQueuedActionVersion] = useState(null); - const retry = useMutation({ - mutationFn: () => issuesApi.retryWorkspaceExport(issueId, { - actionId: action!.id, runId: action!.evidence.runId as string, repairNote: repairNote.trim(), - }), - onSuccess: () => { setQueuedActionVersion(String(action!.updatedAt)); onQueued(); }, - }); - if (!action || !isNativeWorkspaceExportRepairCause(action.cause) || action.ownerType !== "board" + const ownerRecovery = action?.cause === "native_workspace_finalization_owner_unverified"; + if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board" || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null; - const queued = queuedActionVersion === String(action.updatedAt) || action.wakePolicy?.kind === "resume_native_run"; - return
    -

    Workspace export needs repair

    - {queued ?

    Export is queued for the saved result. The agent will not repeat its work.

    : <> -

    {"Automatic workspace export retries stopped. Inspect the export failure, restore provider or destination availability, and preserve the saved files in the retained sandbox. Retry export here when the cause is resolved."}

    - {canManage ? <> - -